From e9a3666e30eb8f4bb53b965e93f8b32af1b2eb4e Mon Sep 17 00:00:00 2001 From: Alfredo Bonilla Date: Sun, 23 Feb 2025 18:18:47 -0600 Subject: [PATCH] feat: add product stock and ui improvements --- .../migration.sql | 10 + apps/web/prisma/schema.prisma | 1 + apps/web/public/locales/en/common.json | 11 +- apps/web/public/locales/es/common.json | 10 +- apps/web/public/locales/pt/common.json | 10 +- .../_components/features/ProductCatalog.tsx | 36 +++- .../_components/features/ProductDetails.tsx | 75 +++++-- .../app/_components/features/ProductList.tsx | 75 ++++++- .../features/SelectionTypeCard.tsx | 10 +- .../web/src/app/_components/features/types.ts | 1 + .../app/_components/ui/BetaAnnouncement.tsx | 46 +++++ apps/web/src/app/layout.tsx | 2 + apps/web/src/app/product/[id]/page.tsx | 1 + apps/web/src/app/user/favorites/page.tsx | 2 + apps/web/src/atoms/productAtom.ts | 15 +- apps/web/src/server/api/routers/order.ts | 190 ++++++++++++------ apps/web/src/stories/ProductCard.stories.tsx | 11 + packages/ui/src/productCard.tsx | 85 ++++++-- .../_components/features/ProductDetails.tsx | 2 - 19 files changed, 443 insertions(+), 150 deletions(-) create mode 100644 apps/web/prisma/migrations/20250320000000_add_stock_field/migration.sql create mode 100644 apps/web/src/app/_components/ui/BetaAnnouncement.tsx delete mode 100644 src/app/_components/features/ProductDetails.tsx diff --git a/apps/web/prisma/migrations/20250320000000_add_stock_field/migration.sql b/apps/web/prisma/migrations/20250320000000_add_stock_field/migration.sql new file mode 100644 index 0000000..6020404 --- /dev/null +++ b/apps/web/prisma/migrations/20250320000000_add_stock_field/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE `Product` +ADD COLUMN `stock` INTEGER NOT NULL DEFAULT 0, +ADD COLUMN `hidden` BOOLEAN NULL DEFAULT false; + +-- CreateExtension +-- This migration marks the existing stock and hidden fields in the Product table +-- These fields were previously added via db:push +-- This migration is for documentation purposes only and won't modify the database +SELECT 1; \ No newline at end of file diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index b2fa3d6..2f10e3e 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -40,6 +40,7 @@ model Product { price Float nftMetadata Json hidden Boolean? @default(false) + stock Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt shoppingCartItems ShoppingCartItem[] diff --git a/apps/web/public/locales/en/common.json b/apps/web/public/locales/en/common.json index 3f84e1c..c03994e 100644 --- a/apps/web/public/locales/en/common.json +++ b/apps/web/public/locales/en/common.json @@ -7,6 +7,8 @@ "argent_mobile": "Argent Mobile", "error_signing_message": "Error signing the message", + "beta_announcement": "Welcome to our beta version. We're actively improving the platform and appreciate your understanding as we enhance your experience.", + "tag_new": "New", "welcome_coffee_lover": "Welcome Coffee Lover", "featured": "Featured", @@ -361,5 +363,12 @@ "farm_details": "Farm details", "no_favorite_products": "No favorite products yet", "click_to_upload": "Click to upload", - "no_collectibles_found": "No collectibles found" + "no_collectibles_found": "No collectibles found", + + "stock_status": { + "in_stock": "{{count}} in stock", + "low_stock_left": "Only {{count}} left", + "stock_available": "{{count}} available", + "sold_out": "Sold Out" + } } diff --git a/apps/web/public/locales/es/common.json b/apps/web/public/locales/es/common.json index 03bc476..85687a2 100644 --- a/apps/web/public/locales/es/common.json +++ b/apps/web/public/locales/es/common.json @@ -7,6 +7,8 @@ "argent_mobile": "Argent Mobile", "error_signing_message": "Error al firmar el mensaje", + "beta_announcement": "Bienvenido a nuestra versión beta. Estamos mejorando activamente la plataforma y agradecemos tu comprensión mientras optimizamos tu experiencia.", + "tag_new": "Nuevo", "welcome_coffee_lover": "Bienvenido Amante del Café", "featured": "Destacado", @@ -339,5 +341,11 @@ "track_in_my_orders": "Seguir en Mis Pedidos", "subtotal": "Subtotal", "amount_with_currency": "{{amount}} USD", - "amount_with_currency_short": "${amount} USD" + "amount_with_currency_short": "${amount} USD", + "stock_status": { + "in_stock": "{{count}} en existencia", + "low_stock_left": "Solo quedan {{count}}", + "stock_available": "{{count}} disponibles", + "sold_out": "Agotado" + } } diff --git a/apps/web/public/locales/pt/common.json b/apps/web/public/locales/pt/common.json index d85d1d8..02b5ef4 100644 --- a/apps/web/public/locales/pt/common.json +++ b/apps/web/public/locales/pt/common.json @@ -7,6 +7,8 @@ "argent_mobile": "Argent Mobile", "error_signing_message": "Erro ao assinar a mensagem", + "beta_announcement": "Bem-vindo à nossa versão beta. Estamos melhorando ativamente a plataforma e agradecemos sua compreensão enquanto aprimoramos sua experiência.", + "tag_new": "Novo", "welcome_coffee_lover": "Bem-vindo Amante de Café", "featured": "Em destaque", @@ -326,5 +328,11 @@ "track_in_my_orders": "Acompanhar em Meus Pedidos", "subtotal": "Subtotal", "amount_with_currency": "{{amount}} USD", - "amount_with_currency_short": "${amount} USD" + "amount_with_currency_short": "${amount} USD", + "stock_status": { + "in_stock": "{{count}} em estoque", + "low_stock_left": "Apenas {{count}} restantes", + "stock_available": "{{count}} disponíveis", + "sold_out": "Esgotado" + } } diff --git a/apps/web/src/app/_components/features/ProductCatalog.tsx b/apps/web/src/app/_components/features/ProductCatalog.tsx index a4bf24e..cb1d369 100755 --- a/apps/web/src/app/_components/features/ProductCatalog.tsx +++ b/apps/web/src/app/_components/features/ProductCatalog.tsx @@ -52,6 +52,7 @@ export default function ProductCatalog({ api.product.getProducts.useInfiniteQuery( { limit: 3, + includeHidden: false, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -60,11 +61,15 @@ export default function ProductCatalog({ useEffect(() => { if (data) { - const allProducts = data.pages.flatMap((page) => - page.products.map((product) => ({ - ...product, - process: "Natural", - })), + const allProducts = data.pages.flatMap( + (page) => + page.products + .filter((product) => product.hidden !== true) + .map((product) => ({ + ...product, + process: "Natural", + stock: product.stock ?? 0, + })) as Product[], ); setProducts(allProducts); } @@ -90,6 +95,10 @@ export default function ProductCatalog({ }; const renderProduct = (product: Product) => { + if (product.hidden === true) { + return null; + } + let metadata: NftMetadata | null = null; if (typeof product.nftMetadata === "string") { @@ -102,7 +111,6 @@ export default function ProductCatalog({ metadata = product.nftMetadata as NftMetadata; } - // Format the image URL to include the IPFS gateway if it's an IPFS hash const imageUrl = metadata?.imageUrl ? metadata.imageUrl.startsWith("Qm") ? `${process.env.NEXT_PUBLIC_GATEWAY_URL}/ipfs/${metadata.imageUrl}` @@ -142,6 +150,7 @@ export default function ProductCatalog({ farmName={metadata?.farmName ?? ""} variety={t(product.name)} price={totalPrice} + stock={product.stock} badgeText={t(`strength.${metadata?.strength?.toLowerCase()}`)} onClick={() => accessProductDetails(product.id)} onAddToCart={handleAddToCart} @@ -191,11 +200,16 @@ export default function ProductCatalog({
- {results.map((product) => ( -
- {renderProduct(product)} -
- ))} + {results + .filter((product) => product.hidden !== true) + .map((product) => ( +
+ {renderProduct({ + ...product, + stock: product.stock ?? 0, + })} +
+ ))}
) : query ? ( diff --git a/apps/web/src/app/_components/features/ProductDetails.tsx b/apps/web/src/app/_components/features/ProductDetails.tsx index f39c139..0a3a544 100644 --- a/apps/web/src/app/_components/features/ProductDetails.tsx +++ b/apps/web/src/app/_components/features/ProductDetails.tsx @@ -23,11 +23,12 @@ interface ProductDetailsProps { region: string; farmName: string; roastLevel: string; - bagsAvailable: number; + stock: number; price: number; description: string; type: "Buyer" | "Farmer" | "SoldOut"; process: string; + bagsAvailable?: number; }; isConnected?: boolean; onConnect?: () => void; @@ -72,12 +73,13 @@ export default function ProductDetails({ name, farmName, roastLevel, - bagsAvailable, + stock, price, type, process, description, region, + bagsAvailable, } = product; const { t } = useTranslation(); @@ -126,7 +128,7 @@ export default function ProductDetails({ return () => clearInterval(interval); }, [price]); - const isSoldOut = type === "SoldOut"; + const isSoldOut = stock === 0 || type === "SoldOut"; const isFarmer = type === "Farmer"; // Update productImages to use getImageUrl @@ -245,7 +247,7 @@ export default function ProductDetails({ {!isSoldOut && !isFarmer && ( diff --git a/apps/web/src/app/_components/features/types.ts b/apps/web/src/app/_components/features/types.ts index 6e19d69..a82f901 100644 --- a/apps/web/src/app/_components/features/types.ts +++ b/apps/web/src/app/_components/features/types.ts @@ -16,6 +16,7 @@ export type Product = { price: number; nftMetadata: Prisma.JsonValue | NftMetadata; hidden?: boolean | null; + stock: number; process?: string; createdAt: Date; updatedAt: Date; diff --git a/apps/web/src/app/_components/ui/BetaAnnouncement.tsx b/apps/web/src/app/_components/ui/BetaAnnouncement.tsx new file mode 100644 index 0000000..f54ff56 --- /dev/null +++ b/apps/web/src/app/_components/ui/BetaAnnouncement.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { XMarkIcon } from "@heroicons/react/20/solid"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +export default function BetaAnnouncement() { + const [isVisible, setIsVisible] = useState(true); + const { t } = useTranslation(); + + if (!isVisible) return null; + + return ( +
+
+

+ CofiBlocks Beta + + {t( + "beta_announcement", + "Welcome to our beta version. We're actively improving the platform and appreciate your understanding as we enhance your experience.", + )} +

+
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e6ebd03..4d1d15d 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -10,6 +10,7 @@ import i18n from "~/i18n"; import StarknetProvider from "~/providers/starknet"; import { TRPCReactProvider } from "~/trpc/react"; import WalletConnectionCheck from "./_components/features/WalletConnectionCheck"; +import BetaAnnouncement from "./_components/ui/BetaAnnouncement"; export default function RootLayout({ children, @@ -35,6 +36,7 @@ export default function RootLayout({ +
{children}
diff --git a/apps/web/src/app/product/[id]/page.tsx b/apps/web/src/app/product/[id]/page.tsx index c1bfd97..a3d424c 100644 --- a/apps/web/src/app/product/[id]/page.tsx +++ b/apps/web/src/app/product/[id]/page.tsx @@ -235,6 +235,7 @@ export default function ProductPage() { process: "Natural", description: parseMetadata(product.nftMetadata as string) .description, + stock: bagsAvailable, }} isConnected={!!address} onConnect={handleConnect} diff --git a/apps/web/src/app/user/favorites/page.tsx b/apps/web/src/app/user/favorites/page.tsx index 715d358..20892f8 100644 --- a/apps/web/src/app/user/favorites/page.tsx +++ b/apps/web/src/app/user/favorites/page.tsx @@ -16,6 +16,7 @@ interface Product { name: string; price: number; nftMetadata: JsonValue; + stock: number; createdAt: Date; updatedAt: Date; } @@ -98,6 +99,7 @@ export default function Favorites() { variety={favorite.product.name} price={favorite.product.price} badgeText={t("badge_text")} + stock={favorite.product.stock} onClick={() => handleRemoveFromFavorites(favorite.product.id)} onAddToCart={() => handleAddToCart(favorite.product.id, favorite.product) diff --git a/apps/web/src/atoms/productAtom.ts b/apps/web/src/atoms/productAtom.ts index a679c30..f0eda12 100644 --- a/apps/web/src/atoms/productAtom.ts +++ b/apps/web/src/atoms/productAtom.ts @@ -1,18 +1,5 @@ import { atom } from "jotai"; - -interface Product { - id: number; - tokenId: number; - name: string; - price: number; - region: string; - farmName: string; - strength: string; - nftMetadata: string; - process?: string; - createdAt: Date; - updatedAt: Date; -} +import type { Product } from "~/app/_components/features/types"; export const searchQueryAtom = atom(""); export const searchResultsAtom = atom([]); diff --git a/apps/web/src/server/api/routers/order.ts b/apps/web/src/server/api/routers/order.ts index a8be36e..c6508ab 100644 --- a/apps/web/src/server/api/routers/order.ts +++ b/apps/web/src/server/api/routers/order.ts @@ -1,9 +1,11 @@ import { type Order, OrderStatus, + Prisma, type Product, type User, } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { serverContracts } from "~/services/serverContracts"; @@ -106,77 +108,135 @@ export const orderRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }): Promise => { // Start a transaction - return ctx.db.$transaction(async (tx) => { - // Get the cart with items - const cart = await tx.shoppingCart.findUnique({ - where: { id: input.cartId }, - include: { - items: { - include: { - product: true, + return ctx.db.$transaction( + async (tx) => { + // Get the cart with items + const cart = await tx.shoppingCart.findUnique({ + where: { id: input.cartId }, + include: { + items: { + include: { + product: true, + }, }, }, - }, - }); - - if (!cart) { - throw new Error("Cart not found"); - } - - // Find the seller (producer) based on the first product's tokenId - const firstProduct = cart.items[0]?.product; - if (!firstProduct) { - throw new Error("No products in cart"); - } - - // Find the producer who owns this product - const seller = await tx.user.findFirst({ - where: { - role: "COFFEE_PRODUCER", - walletAddress: { - not: "", - }, - }, - }); - - if (!seller) { - throw new Error(`No producer found for product ${firstProduct.name}`); - } - - // Calculate total - const total = cart.items.reduce( - (sum, item) => sum + item.product.price * item.quantity, - 0, - ); - - // Create the order - const order = (await tx.order.create({ - data: { - userId: ctx.session.user.id, - sellerId: seller.id, - total, - status: OrderStatus.PENDING, - items: { - create: cart.items.map((item) => ({ - productId: item.product.id, - quantity: item.quantity, - price: item.product.price, - })), + }); + + if (!cart) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cart not found", + }); + } + + if (cart.items.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cart is empty", + }); + } + + // Validate stock for all items and prepare stock updates + const stockUpdates: Prisma.PrismaPromise[] = []; + for (const item of cart.items) { + if (item.quantity > item.product.stock) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Insufficient stock for ${item.product.name}. Available: ${item.product.stock}, Requested: ${item.quantity}`, + }); + } + + // Prepare stock update + stockUpdates.push( + tx.product.update({ + where: { id: item.product.id }, + data: { + stock: { + decrement: item.quantity, + }, + }, + }), + ); + } + + // Find the seller (producer) + const seller = await tx.user.findFirst({ + where: { + role: "COFFEE_PRODUCER", + walletAddress: { + not: "", + }, }, - }, - include: { - items: { + }); + + if (!seller) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No eligible producer found for this order", + }); + } + + // Calculate total + const total = cart.items.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + + try { + // Execute all stock updates + await Promise.all(stockUpdates); + + // Create the order + const order = (await tx.order.create({ + data: { + userId: ctx.session.user.id, + sellerId: seller.id, + total, + status: OrderStatus.PENDING, + items: { + create: cart.items.map((item) => ({ + productId: item.product.id, + quantity: item.quantity, + price: item.product.price, + })), + }, + }, include: { - product: true, + items: { + include: { + product: true, + }, + }, + user: true, + seller: true, }, - }, - user: true, - seller: true, - }, - })) as OrderWithRelations; + })) as OrderWithRelations; - return order; - }); + // Clean up the cart + await tx.shoppingCartItem.deleteMany({ + where: { shoppingCartId: cart.id }, + }); + + await tx.shoppingCart.delete({ + where: { id: cart.id }, + }); + + return order; + } catch (error) { + // If anything fails, the transaction will be rolled back automatically + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to process order", + cause: error, + }); + } + }, + { + // Set a timeout and isolation level for the transaction + timeout: 10000, + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }, + ); }), // Get user's NFT collectibles diff --git a/apps/web/src/stories/ProductCard.stories.tsx b/apps/web/src/stories/ProductCard.stories.tsx index eedd44d..ea768c0 100644 --- a/apps/web/src/stories/ProductCard.stories.tsx +++ b/apps/web/src/stories/ProductCard.stories.tsx @@ -29,6 +29,10 @@ export default { control: "number", description: "The price of the product per unit", }, + stock: { + control: "number", + description: "The current stock level of the product", + }, badgeText: { control: "text", description: "The text displayed on the badge", @@ -60,6 +64,7 @@ Default.args = { farmName: "Sunrise Farms", variety: "Arabica Coffee", price: 25, + stock: 10, badgeText: "New Arrival", onClick: () => alert("View product details"), }; @@ -70,6 +75,7 @@ OnSale.args = { ...Default.args, badgeText: "On Sale", price: 20, + stock: 5, }; //This implements the Featured Card @@ -78,6 +84,7 @@ Featured.args = { ...Default.args, badgeText: "Featured", variety: "Geisha Coffee", + stock: 15, }; //This implements a card without an image @@ -85,6 +92,7 @@ export const NoImage = Template.bind({}); NoImage.args = { ...Default.args, image: "", + stock: 8, }; //This implements how to add to Cart @@ -92,6 +100,7 @@ export const AddToCart = Template.bind({}); AddToCart.args = { ...Default.args, badgeText: "Best Seller", + stock: 20, onAddToCart: () => alert("Item added to cart"), }; @@ -103,6 +112,7 @@ AddToCart.args = { farmName="Sunrise Farms" variety="Arabica Coffee" price={25} + stock={10} badgeText="New Arrival" onClick={() => alert("View product details")} />; @@ -114,6 +124,7 @@ AddToCart.args = { farmName="Sunrise Farms" variety="Arabica Coffee" price={20} + stock={5} badgeText="On Sale" onClick={() => alert("View product details")} />; diff --git a/packages/ui/src/productCard.tsx b/packages/ui/src/productCard.tsx index 1914142..cf6cd14 100644 --- a/packages/ui/src/productCard.tsx +++ b/packages/ui/src/productCard.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import { useTranslation } from "react-i18next"; import Badge from "./badge"; import IconButton from "./iconButton"; +import { Tooltip } from "./tooltip"; import { H4, Text } from "./typography"; interface ProductCardProps { @@ -12,6 +13,7 @@ interface ProductCardProps { variety: string; price: number; badgeText: string; + stock: number; onClick: () => void; onAddToCart?: () => void; isAddingToShoppingCart?: boolean; @@ -25,38 +27,83 @@ export function ProductCard({ variety, price, badgeText, + stock, onClick, onAddToCart, isAddingToShoppingCart, isConnected, }: ProductCardProps) { const { t } = useTranslation(); + const isSoldOut = stock === 0; + + // Stock level thresholds + const LOW_STOCK_THRESHOLD = 5; + const MEDIUM_STOCK_THRESHOLD = 15; + + // Determine stock status and styling + const getStockStatus = () => { + if (isSoldOut) + return { color: "bg-red-500", text: t("stock_status.sold_out") }; + if (stock <= LOW_STOCK_THRESHOLD) + return { + color: "bg-orange-500", + text: t("stock_status.low_stock_left", { count: stock }), + }; + if (stock <= MEDIUM_STOCK_THRESHOLD) + return { + color: "bg-yellow-500", + text: t("stock_status.stock_available", { count: stock }), + }; + return { + color: "bg-green-500", + text: t("stock_status.in_stock", { count: stock }), + }; + }; + + const stockStatus = getStockStatus(); + const hasLocationInfo = region || farmName; return ( -
-
+
+
{t("product_image_alt")} -
+
-
- - {t("region_by_farm", { region, farmName })} - +
+
+ {hasLocationInfo && ( + + {t("region_by_farm", { region, farmName })} + + )} +
+
+ + {stockStatus.text} + +
+
-
-

- {variety} -

+
+
+ +

+ {variety} +

+
+
} @@ -64,18 +111,18 @@ export function ProductCard({ />
-
- +
+ {t("price_with_currency", { price })} {t("per_unit")} - {onAddToCart && isConnected && ( + {onAddToCart && isConnected && !isSoldOut && (