diff --git a/apps/web/public/images/Avatar.png b/apps/web/public/images/Avatar.png new file mode 100644 index 0000000..1761591 Binary files /dev/null and b/apps/web/public/images/Avatar.png differ diff --git a/apps/web/public/images/product-details/Discount-2.svg b/apps/web/public/images/product-details/Discount-2.svg new file mode 100644 index 0000000..4652721 --- /dev/null +++ b/apps/web/public/images/product-details/Discount-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/images/product-details/Flame.svg b/apps/web/public/images/product-details/Flame.svg new file mode 100644 index 0000000..eedb4ac --- /dev/null +++ b/apps/web/public/images/product-details/Flame.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/images/product-details/Menu-4.svg b/apps/web/public/images/product-details/Menu-4.svg new file mode 100644 index 0000000..7a6dca8 --- /dev/null +++ b/apps/web/public/images/product-details/Menu-4.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/public/images/product-details/SandClock.svg b/apps/web/public/images/product-details/SandClock.svg new file mode 100644 index 0000000..d6f9050 --- /dev/null +++ b/apps/web/public/images/product-details/SandClock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/images/product-details/Shopping-bag.svg b/apps/web/public/images/product-details/Shopping-bag.svg new file mode 100644 index 0000000..de17d59 --- /dev/null +++ b/apps/web/public/images/product-details/Shopping-bag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/images/product-details/producer-info/External-link.svg b/apps/web/public/images/product-details/producer-info/External-link.svg new file mode 100644 index 0000000..1482346 --- /dev/null +++ b/apps/web/public/images/product-details/producer-info/External-link.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/public/images/product-details/producer-info/Information-circle.svg b/apps/web/public/images/product-details/producer-info/Information-circle.svg new file mode 100644 index 0000000..3160e06 --- /dev/null +++ b/apps/web/public/images/product-details/producer-info/Information-circle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/public/images/product-details/producer-info/Location.svg b/apps/web/public/images/product-details/producer-info/Location.svg new file mode 100644 index 0000000..bdcc8ea --- /dev/null +++ b/apps/web/public/images/product-details/producer-info/Location.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/public/images/product-details/producer-info/Send.svg b/apps/web/public/images/product-details/producer-info/Send.svg new file mode 100644 index 0000000..a79a001 --- /dev/null +++ b/apps/web/public/images/product-details/producer-info/Send.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/public/images/product-details/producer-info/Star-highlighted.svg b/apps/web/public/images/product-details/producer-info/Star-highlighted.svg new file mode 100644 index 0000000..e2acec4 --- /dev/null +++ b/apps/web/public/images/product-details/producer-info/Star-highlighted.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/images/product-details/producer-info/Star.svg b/apps/web/public/images/product-details/producer-info/Star.svg new file mode 100644 index 0000000..10c4049 --- /dev/null +++ b/apps/web/public/images/product-details/producer-info/Star.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/images/product-details/producer-info/farm.svg b/apps/web/public/images/product-details/producer-info/farm.svg new file mode 100644 index 0000000..8fedeb9 --- /dev/null +++ b/apps/web/public/images/product-details/producer-info/farm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/public/images/product-details/producer-info/shopping-basket.svg b/apps/web/public/images/product-details/producer-info/shopping-basket.svg new file mode 100644 index 0000000..edb43ef --- /dev/null +++ b/apps/web/public/images/product-details/producer-info/shopping-basket.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/src/app/_components/features/FarmModal.tsx b/apps/web/src/app/_components/features/FarmModal.tsx new file mode 100644 index 0000000..0a4a35f --- /dev/null +++ b/apps/web/src/app/_components/features/FarmModal.tsx @@ -0,0 +1,70 @@ +import Button from "@repo/ui/button"; +import BottomModal from "~/app/_components/ui/BottomModal"; + +interface FarmModalProps { + isOpen: boolean; + onClose: () => void; + farmData: { + name: string; + since: string; + bio: string; + experiences: string; + goodPractices: string; + }; + isEditable?: boolean; + onEdit: () => void; +} + +function FarmModal({ + isOpen, + onClose, + farmData, + isEditable, + onEdit, +}: FarmModalProps) { + return ( + +
+
+
+
+

+ {farmData.name} +

+

+ producing coffee since {farmData.since} +

+
+ +
+

Bio

+

{farmData.bio}

+
+ +
+

Experiences

+

+ {farmData.experiences} +

+
+ +
+

Good practices

+

+ {farmData.goodPractices} +

+
+ + {isEditable && ( + + )} +
+
+
+
+ ); +} + +export { FarmModal }; diff --git a/apps/web/src/app/_components/features/ProducerInfo.tsx b/apps/web/src/app/_components/features/ProducerInfo.tsx new file mode 100644 index 0000000..b8249b5 --- /dev/null +++ b/apps/web/src/app/_components/features/ProducerInfo.tsx @@ -0,0 +1,250 @@ +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FarmModal } from "./FarmModal"; + +interface ProducerInfoProps { + farmName: string; + rating: number; + salesCount: number; + altitude: number; + coordinates: string; + onWebsiteClick: () => void; + farmSince?: string; + farmBio?: string; + farmExperiences?: string; + farmGoodPractices?: string; + isEditable?: boolean; +} + +export function ProducerInfo({ + farmName, + rating = 4, + salesCount, + altitude, + coordinates, + onWebsiteClick, + farmSince, + farmBio, + farmExperiences, + farmGoodPractices, + isEditable = false, +}: ProducerInfoProps) { + const [isFarmModalOpen, setIsFarmModalOpen] = useState(false); + const router = useRouter(); + + const farmData = { + name: farmName, + since: farmSince ?? "2020", + bio: farmBio ?? "Farm bio description", + experiences: farmExperiences ?? "Farm experiences", + goodPractices: farmGoodPractices ?? "Farm good practices", + }; + + const openFarmModal = () => setIsFarmModalOpen(true); + const closeFarmModal = () => setIsFarmModalOpen(false); + + return ( +
+
+ Producer Avatar +
+

+ About the producer +

+ +
{ + if (e.key === "Enter" || e.key === " ") { + openFarmModal(); + } + }} + role="button" + tabIndex={0} + > +
+
+ Farm icon +
+ + Farm + +
+
+ {farmName} + +
+
+ +
+
+
+ Reviews icon +
+ + Reviews + +
+
+ {[1, 2, 3, 4, 5].map((starIndex) => ( + {`Star + ))} +
+
+ +
+
+
+ Sales icon +
+ + Sales on Cofiblocks + +
+
+ {salesCount} +
+
+ +
+
+
+ Location icon +
+ + Region + +
+
+ Costa Rica +
+
+ +
+
+
+ Altitude icon +
+ + Altitude + +
+
+ + {altitude} metros + +
+
+ +
+
+
+ Coordinates icon +
+ + Coordinates + +
+
+ {coordinates} +
+
+ +
{ + if (e.key === "Enter" || e.key === " ") { + onWebsiteClick(); + } + }} + role="button" + tabIndex={0} + > +
+
+ Website icon +
+ + Website + +
+
+ +
+
+ + { + closeFarmModal(); + router.push("/user/edit-profile/farm-profile"); + }} + /> +
+ ); +} diff --git a/apps/web/src/app/_components/features/ProductCatalog.tsx b/apps/web/src/app/_components/features/ProductCatalog.tsx index c240609..cc587e5 100755 --- a/apps/web/src/app/_components/features/ProductCatalog.tsx +++ b/apps/web/src/app/_components/features/ProductCatalog.tsx @@ -3,6 +3,7 @@ import { ProductCard } from "@repo/ui/productCard"; import SkeletonLoader from "@repo/ui/skeleton"; import { useAtom } from "jotai"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { isLoadingAtom, @@ -19,6 +20,15 @@ export default function ProductCatalog() { const [isLoadingResults, setIsLoading] = useAtom(isLoadingAtom); const [quantity, setQuantityProducts] = useAtom(quantityOfProducts); const [query, setQuery] = useAtom(searchQueryAtom); + const router = useRouter(); + + const utils = api.useUtils(); + + const { mutate: addItem } = api.shoppingCart.addItem.useMutation({ + onSuccess: async () => { + await utils.shoppingCart.getItems.invalidate(); + }, + }); // Using an infinite query to fetch products with pagination const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = @@ -34,7 +44,12 @@ export default function ProductCatalog() { // Effect to update local state whenever new data is loaded useEffect(() => { if (data) { - const allProducts = data.pages.flatMap((page) => page.products); // Flatten the pages to get all products + const allProducts = data.pages.flatMap((page) => + page.products.map((product) => ({ + ...product, + process: product.process ?? "Natural", + })), + ); setProducts(allProducts); } }, [data]); @@ -57,8 +72,8 @@ export default function ProductCatalog() { }, [handleScroll]); // Placeholder for adding products to the cart - const handleAddToCart = (_productId: number) => { - // TODO: Add logic for adding product to cart. + const accessProductDetails = (productId: number) => { + router.push(`/product/${productId}`); // Navigate to the product details page }; // Render each product @@ -85,7 +100,7 @@ export default function ProductCatalog() { price={product.price} badgeText={product.strength} isAddingToShoppingCart={false} // Disable shopping cart action for now - onClick={() => handleAddToCart(product.id)} // Trigger add-to-cart action + onClick={() => accessProductDetails(product.id)} // Trigger add-to-cart action /> ); }; @@ -115,7 +130,9 @@ export default function ProductCatalog() { Clear search - {results.map(renderProduct)} + {results.map((product) => ( +
{renderProduct(product)}
+ ))} ) : query ? (
@@ -138,7 +155,9 @@ export default function ProductCatalog() {
) : ( - products.map(renderProduct) + products.map((product) => ( +
{renderProduct(product)}
+ )) )} {isFetchingNextPage && } diff --git a/apps/web/src/app/_components/features/ProductDetails.tsx b/apps/web/src/app/_components/features/ProductDetails.tsx new file mode 100644 index 0000000..e841cd0 --- /dev/null +++ b/apps/web/src/app/_components/features/ProductDetails.tsx @@ -0,0 +1,160 @@ +import { HeartIcon } from "@heroicons/react/24/outline"; +import { HeartIcon as HeartSolidIcon } from "@heroicons/react/24/solid"; +import Button from "@repo/ui/button"; +import { ChatWithSeller } from "@repo/ui/chatWithSeller"; +import { DataCard } from "@repo/ui/dataCard"; +import PageHeader from "@repo/ui/pageHeader"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { ProducerInfo } from "./ProducerInfo"; +import { SelectionTypeCard } from "./SelectionTypeCard"; + +interface ProductDetailsProps { + product: { + image: string; + name: string; + region: string; + farmName: string; + roastLevel: string; + bagsAvailable: number; + price: number; + description: string; + type: "Buyer" | "Farmer" | "SoldOut"; + process: string; + }; +} + +export default function ProductDetails({ product }: ProductDetailsProps) { + const { + image, + name, + region, + farmName, + roastLevel, + bagsAvailable, + price, + type, + description, + process, + } = product; + const [quantity, setQuantity] = useState(1); + const [isLiked, setIsLiked] = useState(false); + const router = useRouter(); + + const isSoldOut = type === "SoldOut"; + const isFarmer = type === "Farmer"; + + return ( +
+
+ {name}
} + showBackButton + onBackClick={() => router.back()} + hideCart={false} + rightActions={ + + } + /> +
+ +
+ {name} +
+ +
+
+

{name}

+

+ {product.description} +

+
+ console.log("Open chat")} + /> +
+
+ + +
+
+ + +
+ + {!isSoldOut && !isFarmer && ( +
+ void 0} + /> +
+ )} + +
+
+
+
+
+ + void 0} + isEditable={true} + /> + + +
+
+
+ ); +} diff --git a/apps/web/src/app/_components/features/SearchBar.tsx b/apps/web/src/app/_components/features/SearchBar.tsx index a9822af..ed13799 100644 --- a/apps/web/src/app/_components/features/SearchBar.tsx +++ b/apps/web/src/app/_components/features/SearchBar.tsx @@ -39,8 +39,12 @@ export default function SearchBar() { setIsLoading(isLoading); if (data?.productsFound) { - setSearchResults(data.productsFound); - setQuantityProducts(data.productsFound.length); + const productsWithProcess = data.productsFound.map((product) => ({ + ...product, + process: product.process ?? "Natural", + })); + setSearchResults(productsWithProcess); + setQuantityProducts(productsWithProcess.length); } else { setSearchResults([]); setQuantityProducts(0); diff --git a/apps/web/src/app/_components/features/SelectionTypeCard.tsx b/apps/web/src/app/_components/features/SelectionTypeCard.tsx new file mode 100644 index 0000000..b2e24bc --- /dev/null +++ b/apps/web/src/app/_components/features/SelectionTypeCard.tsx @@ -0,0 +1,83 @@ +import Button from "@repo/ui/button"; +import { InfoCard } from "@repo/ui/infoCard"; +import { Text } from "@repo/ui/typography"; +import { useState } from "react"; + +interface SelectionTypeCardProps { + price: number; + quantity: number; + bagsAvailable: number; + onQuantityChange: (quantity: number) => void; + onAddToCart: () => void; +} + +export function SelectionTypeCard({ + price, + quantity, + bagsAvailable, + onQuantityChange, + onAddToCart, +}: SelectionTypeCardProps) { + const [selectedOption, setSelectedOption] = useState<"bean" | "grounded">( + "bean", + ); + + const coffeeOptions = [ + { + label: "Bean", + iconSrc: "/images/product-details/Menu-4.svg", + selected: selectedOption === "bean", + onClick: () => setSelectedOption("bean"), + }, + { + label: "Grounded", + iconSrc: "/images/product-details/Menu-4.svg", + selected: selectedOption === "grounded", + onClick: () => setSelectedOption("grounded"), + }, + ]; + + return ( + +
+ + Unit price (340g): {price} USD + +
+ + {price * quantity} USD + + /total +
+
+ +
+ + {quantity} + +
+ + +
+ ); +} diff --git a/apps/web/src/app/_components/features/types.ts b/apps/web/src/app/_components/features/types.ts index 80dc625..dc8755b 100644 --- a/apps/web/src/app/_components/features/types.ts +++ b/apps/web/src/app/_components/features/types.ts @@ -14,6 +14,7 @@ export type Product = { region: string; farmName: string; strength: string; + process?: string; createdAt: Date; updatedAt: Date; }; diff --git a/apps/web/src/app/api/product/[id]/route.ts b/apps/web/src/app/api/product/[id]/route.ts new file mode 100644 index 0000000..9727590 --- /dev/null +++ b/apps/web/src/app/api/product/[id]/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { mockedProducts } from "~/server/api/routers/mockProducts"; + +export async function GET( + request: Request, + { params }: { params: { id: string } }, +) { + const id = Number.parseInt(params.id); + const product = mockedProducts.find((p) => p.id === id); + + if (!product) { + return NextResponse.json({ error: "Product not found" }, { status: 404 }); + } + + return NextResponse.json(product); +} diff --git a/apps/web/src/app/product/[id]/page.tsx b/apps/web/src/app/product/[id]/page.tsx new file mode 100644 index 0000000..12ca56c --- /dev/null +++ b/apps/web/src/app/product/[id]/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import SkeletonLoader from "@repo/ui/skeleton"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import ProductDetails from "~/app/_components/features/ProductDetails"; + +interface Product { + image: string; + name: string; + region: string; + farmName: string; + roastLevel: string; + bagsAvailable: number; + price: number; + description: string; + type: "Buyer" | "Farmer" | "SoldOut"; + process: string; +} + +interface ApiResponse { + nftMetadata: string; + name: string; + region: string; + farmName: string; + strength: string; + bagsAvailable: number; + price: number; + process?: string; +} + +interface NftMetadata { + imageUrl: string; + description: string; +} + +function ProductPage() { + const params = useParams(); + const productId = typeof params.id === "string" ? params.id : params.id?.[0]; + const [product, setProduct] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (productId) { + void fetchProductData(productId); + } + }, [productId]); + + const fetchProductData = async (id: string) => { + try { + setIsLoading(true); + const response = await fetch(`/api/product/${id}`); + if (!response.ok) { + throw new Error(`Error fetching product: ${response.statusText}`); + } + const data = (await response.json()) as ApiResponse; + + const parsedMetadata = JSON.parse(data.nftMetadata) as NftMetadata; + + const product: Product = { + image: parsedMetadata.imageUrl, + name: data.name, + region: data.region, + farmName: data.farmName, + roastLevel: data.strength, + bagsAvailable: data.bagsAvailable ?? 10, + price: data.price, + description: parsedMetadata.description, + type: "SoldOut", + process: data.process ?? "Natural", + }; + + setProduct(product); + } catch (error) { + console.error("Failed to fetch product data:", error); + setProduct(null); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (!product) { + return ( +
+

Product not found

+
+ ); + } + + return ; +} + +export default ProductPage; diff --git a/apps/web/src/atoms/productAtom.ts b/apps/web/src/atoms/productAtom.ts index c8fcd9d..c65116c 100644 --- a/apps/web/src/atoms/productAtom.ts +++ b/apps/web/src/atoms/productAtom.ts @@ -8,6 +8,7 @@ interface Product { farmName: string; strength: string; nftMetadata: string; + process?: string; createdAt: Date; updatedAt: Date; } diff --git a/apps/web/src/server/api/routers/mockProducts.ts b/apps/web/src/server/api/routers/mockProducts.ts index cf5b9ce..3c1ba02 100644 --- a/apps/web/src/server/api/routers/mockProducts.ts +++ b/apps/web/src/server/api/routers/mockProducts.ts @@ -10,6 +10,7 @@ export const mockedProducts = [ region: "Alajuela", farmName: "Beneficio Las Peñas", strength: "Light", + process: "Honey", nftMetadata: JSON.stringify({ description: "Descripción del Café de Especialidad 1.", imageUrl: "/images/cafe1.webp", @@ -25,6 +26,7 @@ export const mockedProducts = [ region: "Cartago", farmName: "Beneficio Las Nubes", strength: "Medium", + process: "Washed", nftMetadata: JSON.stringify({ description: "Descripción del Café de Especialidad 2.", imageUrl: "/images/cafe2.webp", @@ -40,6 +42,7 @@ export const mockedProducts = [ region: "Heredia", farmName: "Beneficio Monteverde", strength: "Strong", + process: "Natural", nftMetadata: JSON.stringify({ description: "Descripción del Café de Especialidad 3.", imageUrl: "/images/cafe3.webp", diff --git a/packages/ui/src/chatWithSeller.tsx b/packages/ui/src/chatWithSeller.tsx new file mode 100644 index 0000000..2067976 --- /dev/null +++ b/packages/ui/src/chatWithSeller.tsx @@ -0,0 +1,61 @@ +import Image from "next/image"; + +interface ChatWithSellerProps { + name: string; + description: string; + avatarSrc?: string; + onClick?: () => void; +} + +export function ChatWithSeller({ + name, + description, + avatarSrc = "/images/user-profile/avatar.svg", + onClick, +}: ChatWithSellerProps) { + return ( + + ); +} diff --git a/packages/ui/src/dataCard.tsx b/packages/ui/src/dataCard.tsx new file mode 100644 index 0000000..db76124 --- /dev/null +++ b/packages/ui/src/dataCard.tsx @@ -0,0 +1,39 @@ +import Image from "next/image"; + +interface DataCardProps { + label: string; + value: string; + iconSrc?: string; + variant?: "default" | "error"; +} + +export function DataCard({ + label, + value, + iconSrc = "/images/placeholder.svg", + variant = "default", +}: DataCardProps) { + return ( +
+
+ icon +
+
+ {label} + + {value} + +
+
+ ); +} diff --git a/packages/ui/src/infoCard.tsx b/packages/ui/src/infoCard.tsx new file mode 100644 index 0000000..7e8ceaf --- /dev/null +++ b/packages/ui/src/infoCard.tsx @@ -0,0 +1,69 @@ +import Image from "next/image"; +import Button from "./button"; +import { Text } from "./typography"; + +interface Option { + label: string; + iconSrc?: string; + selected?: boolean; + onClick?: () => void; +} + +interface InfoCardProps { + title: string; + options: Option[]; + children?: React.ReactNode; +} + +export function InfoCard({ title, options, children }: InfoCardProps) { + return ( +
+
+ + {title} + + +
+ {options.map((option) => ( +
{ + if (e.key === "Enter" || e.key === " ") { + option.onClick?.(); + } + }} + role="button" + tabIndex={0} + > +
+ {option.label} +
+
+ + {option.label} + +
+
+
+ ))} +
+ + {children} +
+
+ ); +} diff --git a/packages/ui/src/pageHeader.tsx b/packages/ui/src/pageHeader.tsx index b0d31e2..c914cc1 100644 --- a/packages/ui/src/pageHeader.tsx +++ b/packages/ui/src/pageHeader.tsx @@ -10,13 +10,14 @@ const BlockiesSvg = dynamic<{ address: string; size: number; scale: number }>( ); interface PageHeaderProps { - title: string; + title: string | React.ReactNode; userAddress?: string; onLogout?: () => void; hideCart?: boolean; showBackButton?: boolean; onBackClick?: () => void; showBlockie?: boolean; + rightActions?: React.ReactNode; } function PageHeader({ @@ -27,6 +28,7 @@ function PageHeader({ showBackButton = false, onBackClick, showBlockie = true, + rightActions, }: PageHeaderProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); @@ -111,6 +113,7 @@ function PageHeader({

{title}

+ {rightActions} {!hideCart && (