From bd32258e40507c406316a1047d7ab966a78376d4 Mon Sep 17 00:00:00 2001 From: Alfredo Bonilla Date: Tue, 18 Feb 2025 23:43:08 -0600 Subject: [PATCH] fix: product details and my orders page --- CHANGELOG.md | 13 + .../_components/features/ProductDetails.tsx | 364 +++++++++++------- apps/web/src/app/product/[id]/page.tsx | 141 ++++++- apps/web/src/app/user/my-orders/[id]/page.tsx | 103 +++-- apps/web/src/app/user/my-orders/page.tsx | 167 +++++--- apps/web/src/server/api/root.ts | 2 + 6 files changed, 543 insertions(+), 247 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 663645a..7fb2acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,11 +47,19 @@ - Kept aria-labels on buttons - Maintained proper focus states - Retained semantic HTML structure +- Simplified wallet connection flow in WalletConnectFlow component +- Improved button disabled states during wallet connection +- Added loading indicator during wallet connection process +- Removed unnecessary console.log statements ### Fixed - Resolved potential issues with environment variable validation - Fixed IPFS image loading in shopping cart - Fixed cart counter synchronization with server state +- Fixed TypeScript error in WalletConnectFlow by removing incorrect await usage +- Improved wallet connection state handling and user feedback +- Added proper loading states to wallet connection buttons +- Enhanced error handling in wallet connection process ### Removed - Removed unused boilerplate code from initial setup @@ -96,3 +104,8 @@ - Check responsive behavior across different screen sizes - Validate authentication flows - Test keyboard navigation + +### Added +- Better error handling for wallet connection failures +- Loading state management for wallet connection process +- Proper state handling for connection buttons diff --git a/apps/web/src/app/_components/features/ProductDetails.tsx b/apps/web/src/app/_components/features/ProductDetails.tsx index de4a6ef..e57d77d 100644 --- a/apps/web/src/app/_components/features/ProductDetails.tsx +++ b/apps/web/src/app/_components/features/ProductDetails.tsx @@ -2,17 +2,17 @@ import { HeartIcon, ShareIcon } from "@heroicons/react/24/outline"; import { HeartIcon as HeartSolidIcon } from "@heroicons/react/24/solid"; import Button from "@repo/ui/button"; import { DataCard } from "@repo/ui/dataCard"; -import PageHeader from "@repo/ui/pageHeader"; -import { H1, H2, Text } from "@repo/ui/typography"; +import { H2, Text } from "@repo/ui/typography"; import Image from "next/image"; -import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "~/trpc/react"; import { ProducerInfo } from "./ProducerInfo"; import { SelectionTypeCard } from "./SelectionTypeCard"; +const IPFS_GATEWAY_URL = "https://gateway.pinata.cloud/ipfs/"; + interface ProductDetailsProps { product: { id: number; @@ -32,6 +32,29 @@ interface ProductDetailsProps { onConnect?: () => void; } +interface CoinGeckoResponse { + starknet: { + usd: number; + }; +} + +const getImageUrl = (src: string) => { + if (src.startsWith("Qm")) { + return `${IPFS_GATEWAY_URL}${src}`; + } + if (src.startsWith("ipfs://")) { + return `${IPFS_GATEWAY_URL}${src.replace("ipfs://", "")}`; + } + if ( + src.startsWith("http://") || + src.startsWith("https://") || + src.startsWith("/") + ) { + return src; + } + return "/images/cafe1.webp"; // Fallback image +}; + export default function ProductDetails({ product, isConnected, @@ -54,23 +77,54 @@ export default function ProductDetails({ const [quantity, setQuantity] = useState(1); const [isLiked, setIsLiked] = useState(false); const [selectedImage, setSelectedImage] = useState(0); + const [strkPrice, setStrkPrice] = useState(null); + const [isLoadingPrice, setIsLoadingPrice] = useState(false); const router = useRouter(); const [isAddingToCart, setIsAddingToCart] = useState(false); - const { data: cart, refetch: refetchCart } = api.cart.getUserCart.useQuery(); + const { refetch: refetchCart } = api.cart.getUserCart.useQuery(); const { mutate: addToCart } = api.cart.addToCart.useMutation({ onSuccess: () => { void refetchCart(); }, }); - const cartItemsCount = - cart?.items?.reduce((total, item) => total + item.quantity, 0) ?? 0; + useEffect(() => { + const fetchStrkPrice = async () => { + try { + setIsLoadingPrice(true); + const response = await fetch( + "https://api.coingecko.com/api/v3/simple/price?ids=starknet&vs_currencies=usd", + ); + const data = (await response.json()) as CoinGeckoResponse; + const strkUsdPrice = data.starknet.usd; + setStrkPrice(price / strkUsdPrice); + } catch (error) { + console.error("Error fetching STRK price:", error); + setStrkPrice(null); + } finally { + setIsLoadingPrice(false); + } + }; + + void fetchStrkPrice(); + + // Refresh price every 5 minutes + const interval = setInterval( + () => { + void fetchStrkPrice(); + }, + 5 * 60 * 1000, + ); + + return () => clearInterval(interval); + }, [price]); + const isSoldOut = type === "SoldOut"; const isFarmer = type === "Farmer"; - // Mock data for demo - in real app, these would come from API + // Update productImages to use getImageUrl const productImages = [ - { id: "main", src: image }, + { id: "main", src: getImageUrl(image) }, { id: "detail-1", src: "/images/product-detail-2.webp" }, { id: "detail-2", src: "/images/product-detail-3.webp" }, { id: "detail-3", src: "/images/product-detail-4.webp" }, @@ -106,85 +160,101 @@ export default function ProductDetails({ } }; - const cartItems = - cart?.items?.map((item) => ({ - id: item.id, - product: { - name: item.product.name, - price: item.product.price, - nftMetadata: item.product.nftMetadata - ? JSON.stringify(item.product.nftMetadata) - : "{}", - }, - quantity: item.quantity, - })) ?? []; - return (
- router.back()} - showCart={true} - cartItems={cartItems} - rightActions={ -
+ {/* Navigation Bar */} +
+
+ {/* Left Side: Breadcrumbs */} +
- } - /> - - {/* Main Content with proper top spacing */} -
-
- {/* Breadcrumb */} - -
- {/* Image Gallery */} -
-
- {productImages[selectedImage] && ( - {name} + {/* Right Side: Actions */} +
+ {/* Share and Like Buttons */} +
+ +
+ +
+ + {/* Add to Cart Button */} + {!isSoldOut && !isFarmer && ( + + )} +
+
+
+ + {/* Main Content */} +
+
+ {/* Image Gallery */} +
+
+ {productImages[selectedImage] && ( + {name} + )} +
+ {productImages.length > 1 && (
{productImages.map((img, index) => (
+ )} +
+ + {/* Product Info */} +
+
+ + {region} + + {farmName}
- {/* Product Info */} -
-
- - {region} +
+ + ${price.toFixed(2)} USD + + {t("per_unit")} + + + {isLoadingPrice ? ( + + {t("loading_strk_price")} -

{t(name)}

- {farmName} -
- -
- - ${price.toFixed(2)} USD - - {t("per_unit")} - + ) : strkPrice ? ( + + ≈ {strkPrice.toFixed(2)} STRK - {!isSoldOut && ( - - {t("bags_available_count", { count: bagsAvailable })} - - )} -
+ ) : null} + {!isSoldOut && ( + + {t("bags_available_count", { count: bagsAvailable })} + + )} +
- {description} + {description} -
- - -
+
+ + +
- {!isSoldOut && !isFarmer && ( - - )} + {!isSoldOut && !isFarmer && ( + + )} -
-

- {t("farm_details")} -

- void 0} - isEditable={isFarmer} - /> - {isFarmer && ( - - )} -
+
+

+ {t("farm_details")} +

+ void 0} + isEditable={isFarmer} + /> + {isFarmer && ( + + )}
diff --git a/apps/web/src/app/product/[id]/page.tsx b/apps/web/src/app/product/[id]/page.tsx index 6c282fb..721b6a2 100644 --- a/apps/web/src/app/product/[id]/page.tsx +++ b/apps/web/src/app/product/[id]/page.tsx @@ -1,17 +1,56 @@ "use client"; +import Skeleton from "@repo/ui/skeleton"; import { useAccount, useDisconnect } from "@starknet-react/core"; +import { useParams } from "next/navigation"; import { useState } from "react"; import ProductDetails from "~/app/_components/features/ProductDetails"; import { ProfileOptions } from "~/app/_components/features/ProfileOptions"; import WalletConnect from "~/app/_components/features/WalletConnect"; +import type { NftMetadata } from "~/app/_components/features/types"; import Header from "~/app/_components/layout/Header"; import Main from "~/app/_components/layout/Main"; +import { api } from "~/trpc/react"; + +interface ParsedMetadata extends NftMetadata { + imageUrl: string; + region: string; + farmName: string; + strength: string; + description: string; +} + +interface RawMetadata { + imageUrl?: unknown; + imageAlt?: unknown; + region?: unknown; + farmName?: unknown; + strength?: unknown; + description?: unknown; +} export default function ProductPage() { const { address } = useAccount(); const { disconnect } = useDisconnect(); const [isWalletModalOpen, setIsWalletModalOpen] = useState(false); + const params = useParams(); + const idParam = params?.id; + const id = + typeof idParam === "string" + ? idParam + : Array.isArray(idParam) + ? idParam[0] + : "0"; + const productId = id ? Number.parseInt(id, 10) : 0; + + const { data: product, isLoading: isLoadingProduct } = + api.product.getProductById.useQuery( + { id: productId }, + { + enabled: !!productId, + retry: false, + }, + ); const handleConnect = () => { setIsWalletModalOpen(true); @@ -21,20 +60,52 @@ export default function ProductPage() { setIsWalletModalOpen(false); }; - // Mock product data - in real app, this would come from an API - const product = { - id: 1, - tokenId: 1, - image: "/images/cafe1.webp", - name: "Café de Especialidad", - farmName: "Finca La Esperanza", - roastLevel: "Medium", - bagsAvailable: 10, - price: 25.0, - type: "Buyer" as const, - process: "Natural", - description: "Un café excepcional con notas a chocolate y frutos rojos.", - region: "Huehuetenango", + const parseMetadata = (metadata: string | null): ParsedMetadata => { + try { + let parsed: RawMetadata = {}; + try { + // Use type assertion for the initial parse result + const rawResult = JSON.parse(metadata ?? "{}") as unknown; + // Type guard to ensure we have an object + if ( + rawResult && + typeof rawResult === "object" && + !Array.isArray(rawResult) + ) { + const result = rawResult as Record; + parsed = { + imageUrl: result.imageUrl, + imageAlt: result.imageAlt, + region: result.region, + farmName: result.farmName, + strength: result.strength, + description: result.description, + }; + } + } catch { + // If JSON parsing fails, use empty object + } + + return { + imageUrl: typeof parsed.imageUrl === "string" ? parsed.imageUrl : "", + imageAlt: typeof parsed.imageAlt === "string" ? parsed.imageAlt : "", + region: typeof parsed.region === "string" ? parsed.region : "", + farmName: typeof parsed.farmName === "string" ? parsed.farmName : "", + strength: + typeof parsed.strength === "string" ? parsed.strength : "Medium", + description: + typeof parsed.description === "string" ? parsed.description : "", + }; + } catch { + return { + imageUrl: "", + imageAlt: "", + region: "", + farmName: "", + strength: "Medium", + description: "", + }; + } }; return ( @@ -50,11 +121,43 @@ export default function ProductPage() { } />
- + {isLoadingProduct ? ( +
+ +
+ + + +
+
+ ) : product ? ( + + ) : ( +
+

Product not found

+
+ )}
{ + try { + let parsed: RawMetadata = {}; + try { + // Use type assertion for the initial parse result + const rawResult = JSON.parse(metadata ?? "{}") as unknown; + // Type guard to ensure we have an object + if ( + rawResult && + typeof rawResult === "object" && + !Array.isArray(rawResult) + ) { + const result = rawResult as Record; + parsed = { + roast: result.roast, + }; + } + } catch { + // If JSON parsing fails, use empty object + } + + return { + roast: typeof parsed.roast === "string" ? parsed.roast : "unknown", + }; + } catch { + return { + roast: "unknown", + }; + } +}; export default function OrderDetails() { const { t } = useTranslation(); const { id: orderId } = useParams(); const { data: session } = useSession(); + const isProducer = session?.user?.role === "COFFEE_PRODUCER"; + const userId = session?.user?.id; - const [orderDetails, setOrderDetails] = useState( - null, + const { data: user } = api.user.getUser.useQuery( + { userId: userId ?? "" }, + { + enabled: !!userId, + }, ); - const isProducer = session?.user?.role === "COFFEE_PRODUCER"; - useEffect(() => { - // TODO: Fetch order details based on orderId - if (orderId) { - setOrderDetails({ - productName: t("sample_product"), - status: t("paid"), - roast: t("strong"), - type: t("grounded"), - quantity: `5 ${t("bags")}`, - delivery: t("delivery"), - address: "Av Portugal 375, ap 410 São Paulo/SP CEP 66010-100", - totalPrice: `50 ${t("usd")}`, - }); - } - }, [orderId, t]); + const { data: order, isLoading } = api.order.getOrder.useQuery( + { orderId: orderId as string }, + { + enabled: !!orderId, + }, + ); - const updateProductDetails = (productDetails: OrderDetailsType) => { - // TODO: Implement logic to update order details - setOrderDetails(productDetails); - }; + const orderDetails = order + ? { + productName: order.items[0]?.product.name ?? t("unknown_product"), + status: order.status, + roast: order.items[0]?.product.nftMetadata + ? parseMetadata(order.items[0].product.nftMetadata as string).roast + : t("unknown_roast"), + type: t("grounded"), // TODO: Add type to product metadata + quantity: `${order.items[0]?.quantity ?? 0} ${t("bags")}`, + delivery: t("delivery"), // TODO: Add delivery method to order + address: user?.physicalAddress ?? "", + totalPrice: `${order.total} ${t("usd")}`, + } + : null; return ( - {orderDetails && ( + {isLoading ? ( +
+
+
+
+
+
+
+ ) : orderDetails ? ( + ) : ( +
+

{t("order_not_found")}

+
)} ); diff --git a/apps/web/src/app/user/my-orders/page.tsx b/apps/web/src/app/user/my-orders/page.tsx index 6fed727..5a494db 100644 --- a/apps/web/src/app/user/my-orders/page.tsx +++ b/apps/web/src/app/user/my-orders/page.tsx @@ -2,6 +2,7 @@ import { FunnelIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { zodResolver } from "@hookform/resolvers/zod"; +import type { OrderStatus } from "@prisma/client"; import Button from "@repo/ui/button"; import CheckBox from "@repo/ui/form/checkBox"; import { useRouter } from "next/navigation"; @@ -11,44 +12,36 @@ import OrderListItem from "~/app/_components/features/OrderListItem"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; import BottomModal from "~/app/_components/ui/BottomModal"; import { useOrderFiltering } from "~/hooks/user/useOrderFiltering"; +import { api } from "~/trpc/react"; +import type { RouterOutputs } from "~/trpc/react"; import { type FormValues, SalesStatus, filtersSchema } from "~/types"; -const mockedOrders = [ - { - date: "october_18", - items: [ - { - id: "1", - productName: "product_name_1", - sellerName: "seller1_fullname", - status: SalesStatus.Paid, - }, - { - id: "2", - productName: "product_name_2", - sellerName: "seller2_fullname", - status: SalesStatus.Paid, - }, - ], - }, - { - date: "september_20", - items: [ - { - id: "3", - productName: "product_name_3", - sellerName: "seller1_fullname", - status: SalesStatus.Delivered, - }, - { - id: "4", - productName: "product_name_4", - sellerName: "seller2_fullname", - status: SalesStatus.Delivered, - }, - ], - }, -]; +type OrderWithItems = RouterOutputs["order"]["getUserOrders"][number]; + +interface GroupedOrderItem { + id: string; + productName: string; + sellerName: string; + status: SalesStatus; +} + +interface OrderGroup { + date: string; + items: GroupedOrderItem[]; +} + +const mapOrderStatusToSalesStatus = (status: OrderStatus): SalesStatus => { + switch (status) { + case "PENDING": + return SalesStatus.Paid; + case "COMPLETED": + return SalesStatus.Delivered; + case "CANCELLED": + return SalesStatus.Paid; // TODO: Add proper cancelled status + default: + return SalesStatus.Paid; + } +}; const filtersDefaults = { statusPaid: false, @@ -59,6 +52,9 @@ const filtersDefaults = { export default function MyOrders() { const { t } = useTranslation(); + const router = useRouter(); + const { data: orders, isLoading } = api.order.getUserOrders.useQuery(); + const { searchTerm, setSearchTerm, @@ -68,11 +64,10 @@ export default function MyOrders() { filteredOrders, applyFilters, } = useOrderFiltering({ - orders: mockedOrders, + orders: orders ? groupOrdersByDate(orders) : [], searchKey: "sellerName", filters: filtersDefaults, }); - const router = useRouter(); const { control, handleSubmit } = useForm({ resolver: zodResolver(filtersSchema), @@ -83,6 +78,46 @@ export default function MyOrders() { router.push(`/user/my-orders/${id}`); }; + // Group orders by date + function groupOrdersByDate(orders: OrderWithItems[]): OrderGroup[] { + const grouped = orders.reduce((acc: OrderGroup[], order) => { + const date = new Date(order.createdAt); + const monthYear = date.toLocaleString("default", { + month: "long", + year: "numeric", + }); + + const existingGroup = acc.find((group) => group.date === monthYear); + + if (existingGroup) { + existingGroup.items.push({ + id: order.id, + productName: order.items[0]?.product.name ?? t("unknown_product"), + sellerName: "Seller Name", // TODO: Add seller name to order data + status: mapOrderStatusToSalesStatus(order.status), + }); + } else { + acc.push({ + date: monthYear, + items: [ + { + id: order.id, + productName: order.items[0]?.product.name ?? t("unknown_product"), + sellerName: "Seller Name", // TODO: Add seller name to order data + status: mapOrderStatusToSalesStatus(order.status), + }, + ], + }); + } + + return acc; + }, []); + + return grouped.sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), + ); + } + return (
@@ -106,29 +141,43 @@ export default function MyOrders() {
- {filteredOrders.map((orderGroup, index) => ( -
-

- {t(orderGroup.date)} -

-
- {orderGroup.items.map((order, orderIndex) => ( - <> - handleItemClick(order.id)} - /> - {orderIndex < orderGroup.items.length - 1 && ( -
- )} - - ))} + {isLoading ? ( +
+
+
+
+
+
+
+ ) : filteredOrders.length > 0 ? ( + filteredOrders.map((orderGroup, index) => ( +
+

+ {orderGroup.date} +

+
+ {orderGroup.items.map((order, orderIndex) => ( + <> + handleItemClick(order.id)} + /> + {orderIndex < orderGroup.items.length - 1 && ( +
+ )} + + ))} +
+ )) + ) : ( +
+

{t("no_orders_found")}

- ))} + )}
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index e5f20da..b021ed5 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -1,4 +1,5 @@ import { cartRouter } from "~/server/api/routers/cart"; +import { orderRouter } from "~/server/api/routers/order"; import { producerRouter } from "~/server/api/routers/producer"; import { productRouter } from "~/server/api/routers/product"; import { userRouter } from "~/server/api/routers/user"; @@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({ user: userRouter, cart: cartRouter, producer: producerRouter, + order: orderRouter, }); // export type definition of API