diff --git a/apps/web/src/app/_components/features/ProductCatalog.tsx b/apps/web/src/app/_components/features/ProductCatalog.tsx index cc587e5..948f794 100755 --- a/apps/web/src/app/_components/features/ProductCatalog.tsx +++ b/apps/web/src/app/_components/features/ProductCatalog.tsx @@ -22,14 +22,6 @@ export default function ProductCatalog() { 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 } = api.product.getProducts.useInfiniteQuery( @@ -99,7 +91,6 @@ export default function ProductCatalog() { variety={product.name} price={product.price} badgeText={product.strength} - isAddingToShoppingCart={false} // Disable shopping cart action for now onClick={() => accessProductDetails(product.id)} // Trigger add-to-cart action /> ); diff --git a/apps/web/src/app/_components/features/ProductDetails.tsx b/apps/web/src/app/_components/features/ProductDetails.tsx index e841cd0..b155b8c 100644 --- a/apps/web/src/app/_components/features/ProductDetails.tsx +++ b/apps/web/src/app/_components/features/ProductDetails.tsx @@ -4,14 +4,17 @@ 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 { useAtom, useAtomValue } from "jotai"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { addItemAtom, cartItemsAtom } from "~/store/cartAtom"; import { ProducerInfo } from "./ProducerInfo"; import { SelectionTypeCard } from "./SelectionTypeCard"; interface ProductDetailsProps { product: { + id: number; image: string; name: string; region: string; @@ -29,22 +32,39 @@ 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 [isAddingToCart, setIsAddingToCart] = useState(false); + const [, addItem] = useAtom(addItemAtom); + const items = useAtomValue(cartItemsAtom); + const cartItemsCount = items.reduce( + (total, item) => total + item.quantity, + 0, + ); const isSoldOut = type === "SoldOut"; const isFarmer = type === "Farmer"; + const handleAddToCart = () => { + setIsAddingToCart(true); + addItem({ + id: String(product.id), + name: product.name, + quantity: quantity, + price: product.price, + imageUrl: product.image, + }); + setIsAddingToCart(false); + }; + return (
@@ -52,7 +72,8 @@ export default function ProductDetails({ product }: ProductDetailsProps) { title={
{name}
} showBackButton onBackClick={() => router.back()} - hideCart={false} + showCart={true} + cartItemsCount={cartItemsCount} rightActions={
)} diff --git a/apps/web/src/app/_components/features/ProductList.tsx b/apps/web/src/app/_components/features/ProductList.tsx index 6b8397b..eb5ca11 100644 --- a/apps/web/src/app/_components/features/ProductList.tsx +++ b/apps/web/src/app/_components/features/ProductList.tsx @@ -1,6 +1,7 @@ "use client"; -import { api } from "~/trpc/react"; +import { useAtom } from "jotai"; +import { addItemAtom } from "~/store/cartAtom"; interface Product { id: number; @@ -14,16 +15,16 @@ interface ProductListProps { } export default function ProductList({ products }: ProductListProps) { - const utils = api.useUtils(); + const [, addItem] = useAtom(addItemAtom); - const { mutate: addToCart } = api.shoppingCart.addItem.useMutation({ - onSuccess: async () => { - await utils.shoppingCart.getItems.invalidate(); - }, - }); - - const handleAddToCart = (productId: number) => { - addToCart({ cartId: "1", productId, quantity: 1 }); + const handleAddToCart = (product: Product) => { + addItem({ + id: String(product.id), + name: product.name, + quantity: 1, + price: product.price, + imageUrl: "/default-image.webp", + }); }; return ( @@ -38,7 +39,7 @@ export default function ProductList({ products }: ProductListProps) { ${product.price.toFixed(2)}

- ); diff --git a/apps/web/src/app/_components/features/ShoppingCart.tsx b/apps/web/src/app/_components/features/ShoppingCart.tsx index 86feb98..8607f94 100644 --- a/apps/web/src/app/_components/features/ShoppingCart.tsx +++ b/apps/web/src/app/_components/features/ShoppingCart.tsx @@ -1,39 +1,32 @@ "use client"; import { XMarkIcon } from "@heroicons/react/24/solid"; -import { api } from "~/trpc/react"; +import { useAtom, useAtomValue } from "jotai"; +import { useRouter } from "next/navigation"; +import { cartItemsAtom, removeItemAtom } from "~/store/cartAtom"; interface ShoppingCartProps { closeCart: () => void; } -interface CartItem { - id: string; - product: { - name: string; - price: number; - }; - quantity: number; -} - export default function ShoppingCart({ closeCart }: ShoppingCartProps) { - const cartId = "1"; // Assume you have the logic to get the cartId - - const utils = api.useUtils(); - - const { mutate: removeItem } = api.shoppingCart.removeItem.useMutation({ - onSuccess: async () => { - await utils.shoppingCart.getItems.invalidate(); - }, - }); + const router = useRouter(); + const items = useAtomValue(cartItemsAtom); + const [, removeItem] = useAtom(removeItemAtom); const handleRemoveItem = (itemId: string) => { - removeItem({ itemId }); + removeItem(itemId); }; - const { data: cartItems, isLoading } = api.shoppingCart.getItems.useQuery({ - cartId, - }); + const handleCheckout = () => { + closeCart(); + router.push("/shopping-cart"); + }; + + const totalPrice = items.reduce( + (total, item) => total + item.price * item.quantity, + 0, + ); return (
@@ -43,40 +36,28 @@ export default function ShoppingCart({ closeCart }: ShoppingCartProps) {
- {isLoading ? ( -
Loading...
- ) : ( - <> -
- {cartItems?.map((item: CartItem) => ( -
-

{item.product.name}

-

${item.product.price}

- -
- ))} -
-
-

Total

-

- $ - {cartItems?.reduce( - (total: number, item: CartItem) => - total + item.product.price * item.quantity, - 0, - )} -

+
+ {items.map((item) => ( +
+

{item.name}

+

${item.price}

+
- - - )} + ))} +
+
+

Total

+

${totalPrice}

+
+
); } diff --git a/apps/web/src/app/_components/layout/Header.tsx b/apps/web/src/app/_components/layout/Header.tsx index 9c2c80d..0980461 100644 --- a/apps/web/src/app/_components/layout/Header.tsx +++ b/apps/web/src/app/_components/layout/Header.tsx @@ -1,16 +1,23 @@ "use client"; import PageHeader from "@repo/ui/pageHeader"; +import { useAtomValue } from "jotai"; import { signOut } from "next-auth/react"; import { useRouter } from "next/navigation"; +import { cartItemsAtom } from "~/store/cartAtom"; interface HeaderProps { address: string | undefined; disconnect: () => void; + showCart?: boolean; } -function Header({ address, disconnect }: HeaderProps) { +function Header({ address, disconnect, showCart }: HeaderProps) { const router = useRouter(); + const items = useAtomValue(cartItemsAtom); + const cartItemsCount = showCart + ? items.reduce((total, item) => total + item.quantity, 0) + : undefined; const handleLogout = async () => { await signOut(); @@ -23,6 +30,8 @@ function Header({ address, disconnect }: HeaderProps) { title="CofiBlocks" userAddress={address} onLogout={handleLogout} + showCart={showCart} + cartItemsCount={cartItemsCount} /> ); } diff --git a/apps/web/src/app/marketplace/page.tsx b/apps/web/src/app/marketplace/page.tsx index ef4c915..5953fae 100755 --- a/apps/web/src/app/marketplace/page.tsx +++ b/apps/web/src/app/marketplace/page.tsx @@ -39,7 +39,7 @@ export default function Home() { return (
-
+
{query.length <= 0 && ( diff --git a/apps/web/src/app/product/[id]/page.tsx b/apps/web/src/app/product/[id]/page.tsx index 12ca56c..41db457 100644 --- a/apps/web/src/app/product/[id]/page.tsx +++ b/apps/web/src/app/product/[id]/page.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import ProductDetails from "~/app/_components/features/ProductDetails"; interface Product { + id: number; image: string; name: string; region: string; @@ -58,6 +59,7 @@ function ProductPage() { const parsedMetadata = JSON.parse(data.nftMetadata) as NftMetadata; const product: Product = { + id: Number(id), image: parsedMetadata.imageUrl, name: data.name, region: data.region, @@ -66,7 +68,7 @@ function ProductPage() { bagsAvailable: data.bagsAvailable ?? 10, price: data.price, description: parsedMetadata.description, - type: "SoldOut", + type: "Buyer", process: data.process ?? "Natural", }; diff --git a/apps/web/src/app/shopping-cart/page.tsx b/apps/web/src/app/shopping-cart/page.tsx new file mode 100644 index 0000000..76b8a24 --- /dev/null +++ b/apps/web/src/app/shopping-cart/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { ArrowLeftIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { useAtom, useAtomValue } from "jotai"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { cartItemsAtom, removeItemAtom } from "~/store/cartAtom"; +import type { CartItem } from "~/store/cartAtom"; + +interface DeleteModalProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +function DeleteConfirmationModal({ + isOpen, + onConfirm, + onCancel, +}: DeleteModalProps) { + if (!isOpen) return null; + + return ( +
+
+
+

+ Do you want to remove? +

+
+ + +
+
+ ); +} + +export default function ShoppingCart() { + const router = useRouter(); + const items = useAtomValue(cartItemsAtom); + const [, removeItem] = useAtom(removeItemAtom); + const [itemToDelete, setItemToDelete] = useState(null); + + const handleRemove = (item: CartItem) => { + setItemToDelete(item); + }; + + const confirmDelete = () => { + if (itemToDelete) { + removeItem(itemToDelete.id); + setItemToDelete(null); + } + }; + + const cancelDelete = () => { + setItemToDelete(null); + }; + + const totalPrice = items.reduce( + (total, item) => total + item.price * item.quantity, + 0, + ); + + return ( +
+
+ +

My cart

+
+ +
+ {items.length === 0 ? ( +
+ Your cart is empty +
+ ) : ( + items.map((item) => ( +
+
+ {item.name} +
+

{item.name}

+

+ quantity: {item.quantity} +

+
+
+
+ + {item.price * item.quantity} USD + + +
+
+ )) + )} +
+ + {items.length > 0 && ( + <> +
+
+ TOTAL + + {totalPrice} USD + +
+
+
+ +
+ + )} + + +
+ ); +} diff --git a/apps/web/src/app/user/favorites/page.tsx b/apps/web/src/app/user/favorites/page.tsx index fc13042..d8937dc 100644 --- a/apps/web/src/app/user/favorites/page.tsx +++ b/apps/web/src/app/user/favorites/page.tsx @@ -1,9 +1,10 @@ "use client"; import { ProductCard } from "@repo/ui/productCard"; +import { useAtom, useAtomValue } from "jotai"; import React from "react"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; -import { api } from "~/trpc/react"; +import { addItemAtom, cartItemsAtom } from "~/store/cartAtom"; const userFavoriteProducts = [ { @@ -28,19 +29,17 @@ export default function Favorites() { // and avoid code duplication const [addedProduct, setAddedProduct] = React.useState(null); - const utils = api.useUtils(); - - const { mutate: addItem } = api.shoppingCart.addItem.useMutation({ - onSuccess: async () => { - await utils.shoppingCart.getItems.invalidate(); - setAddedProduct(null); - }, - }); + const items = useAtomValue(cartItemsAtom); + const [, addItem] = useAtom(addItemAtom); const handleAddToCart = (productId: number) => { - const cartId = 1; - - addItem({ cartId: cartId.toString(), productId, quantity: 1 }); + addItem({ + id: productId.toString(), + name: "Product Name", + quantity: 1, + price: 10.0, + imageUrl: "/default-image.webp", + }); setAddedProduct(productId); }; diff --git a/apps/web/src/app/user/my-sales/page.tsx b/apps/web/src/app/user/my-sales/page.tsx index 4ab3fe4..b6bece5 100644 --- a/apps/web/src/app/user/my-sales/page.tsx +++ b/apps/web/src/app/user/my-sales/page.tsx @@ -134,28 +134,28 @@ export default function MySales() { activeFilters.statusDelivered, ]; - const matchesStatus = - !activeStatusFilters.some(Boolean) ?? - (activeFilters.statusPaid && - item.status === SalesStatusEnum.Paid) ?? - (activeFilters.statusPrepared && - item.status === SalesStatusEnum.Prepared) ?? - (activeFilters.statusShipped && - item.status === SalesStatusEnum.Shipped) ?? - (activeFilters.statusDelivered && - item.status === SalesStatusEnum.Delivered); + const matchesStatus = !activeStatusFilters.some(Boolean) + ? true + : (activeFilters.statusPaid && + item.status === SalesStatusEnum.Paid) ?? + (activeFilters.statusPrepared && + item.status === SalesStatusEnum.Prepared) ?? + (activeFilters.statusShipped && + item.status === SalesStatusEnum.Shipped) ?? + (activeFilters.statusDelivered && + item.status === SalesStatusEnum.Delivered); const activeDeliveryFilters = [ activeFilters.deliveryAddress, activeFilters.deliveryMeetup, ]; - const matchesDelivery = - !activeDeliveryFilters.some(Boolean) ?? - (activeFilters.deliveryAddress && - item.delivery === DeliveryMethodEnum.Address) ?? - (activeFilters.deliveryMeetup && - item.delivery === DeliveryMethodEnum.Meetup); + const matchesDelivery = !activeDeliveryFilters.some(Boolean) + ? true + : (activeFilters.deliveryAddress && + item.delivery === DeliveryMethodEnum.Address) ?? + (activeFilters.deliveryMeetup && + item.delivery === DeliveryMethodEnum.Meetup); return matchesSearch && matchesStatus && matchesDelivery; }), diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 507551c..d0144f7 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -1,5 +1,4 @@ import { productRouter } from "~/server/api/routers/product"; -import { shoppingCartRouter } from "~/server/api/routers/shoppingCart"; import { userRouter } from "~/server/api/routers/user"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; @@ -9,7 +8,6 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; * All routers added in /api/routers should be manually added here. */ export const appRouter = createTRPCRouter({ - shoppingCart: shoppingCartRouter, product: productRouter, user: userRouter, }); diff --git a/apps/web/src/server/api/routers/shoppingCart.ts b/apps/web/src/server/api/routers/shoppingCart.ts deleted file mode 100644 index 9ce3512..0000000 --- a/apps/web/src/server/api/routers/shoppingCart.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { z } from "zod"; -import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; - -export const shoppingCartRouter = createTRPCRouter({ - addItem: publicProcedure - .input( - z.object({ - cartId: z.string(), - productId: z.number(), - quantity: z.number().min(1), - }), - ) - .mutation(async ({ ctx, input }) => { - const { cartId, productId, quantity } = input; - - console.log("input", input); - - const product = await ctx.db.product.findUnique({ - where: { id: productId }, - }); - if (!product) { - throw new Error("Product not found"); - } - return ctx.db.shoppingCartItem.create({ - data: { - shoppingCartId: cartId, - productId, - quantity, - }, - }); - }), - - getItems: publicProcedure - .input(z.object({ cartId: z.string() })) - .query(({ ctx, input }) => { - return ctx.db.shoppingCartItem.findMany({ - where: { shoppingCartId: input.cartId }, - include: { product: true }, - }); - }), - - removeItem: publicProcedure - .input(z.object({ itemId: z.string() })) - .mutation(async ({ ctx, input }) => { - return ctx.db.shoppingCartItem.delete({ - where: { id: input.itemId }, - }); - }), - - createOrder: publicProcedure - .input( - z.object({ - userId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const { userId } = input; - const cartItems = await ctx.db.shoppingCartItem.findMany({ - where: { shoppingCart: { userId } }, - include: { product: true }, - }); - if (cartItems.length === 0) { - throw new Error("No items in cart to create an order."); - } - const total = cartItems.reduce( - (acc, item) => acc + item.product.price * item.quantity, - 0, - ); - const order = await ctx.db.order.create({ - data: { - userId, - total, - status: "PENDING", - items: { - create: cartItems.map((item) => ({ - productId: item.productId, - quantity: item.quantity, - price: item.product.price, - })), - }, - }, - }); - await ctx.db.shoppingCartItem.deleteMany({ - where: { - shoppingCartId: { in: cartItems.map((item) => item.shoppingCartId) }, - }, - }); - return order; - }), - - getOrders: publicProcedure - .input( - z.object({ - userId: z.string(), - }), - ) - .query(async ({ ctx, input }) => { - const { userId } = input; - return ctx.db.order.findMany({ - where: { userId }, - include: { items: true }, - }); - }), -}); diff --git a/apps/web/src/store/cartAtom.ts b/apps/web/src/store/cartAtom.ts new file mode 100644 index 0000000..22fd05c --- /dev/null +++ b/apps/web/src/store/cartAtom.ts @@ -0,0 +1,45 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +export interface CartItem { + id: string; + name: string; + quantity: number; + price: number; + imageUrl: string; +} + +export const cartItemsAtom = atomWithStorage( + "shopping-cart-storage", + [], +); + +export const addItemAtom = atom(null, (get, set, newItem: CartItem) => { + const items = get(cartItemsAtom); + const existingItem = items.find((item) => item.id === newItem.id); + + if (existingItem) { + set( + cartItemsAtom, + items.map((item) => + item.id === newItem.id + ? { ...item, quantity: item.quantity + 1 } + : item, + ), + ); + } else { + set(cartItemsAtom, [...items, newItem]); + } +}); + +export const removeItemAtom = atom(null, (get, set, id: string) => { + const items = get(cartItemsAtom); + set( + cartItemsAtom, + items.filter((item) => item.id !== id), + ); +}); + +export const clearCartAtom = atom(null, (_, set) => { + set(cartItemsAtom, []); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 7dc868f..0f151e0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,7 +22,10 @@ "./tooltip": "./src/tooltip.tsx", "./skeleton": "./src/skeleton.tsx", "./particle": "./src/particle.tsx", - "./nftCard": "./src/nftCard.tsx" + "./nftCard": "./src/nftCard.tsx", + "./chatWithSeller": "./src/chatWithSeller.tsx", + "./dataCard": "./src/dataCard.tsx", + "./infoCard": "./src/infoCard.tsx" }, "scripts": { "lint": "eslint . --max-warnings 0", diff --git a/packages/ui/src/pageHeader.tsx b/packages/ui/src/pageHeader.tsx index c914cc1..a105d5f 100644 --- a/packages/ui/src/pageHeader.tsx +++ b/packages/ui/src/pageHeader.tsx @@ -1,5 +1,6 @@ import { ArrowLeftIcon, ShoppingCartIcon } from "@heroicons/react/24/outline"; import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import type { KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; @@ -18,6 +19,8 @@ interface PageHeaderProps { onBackClick?: () => void; showBlockie?: boolean; rightActions?: React.ReactNode; + showCart?: boolean; + cartItemsCount?: number; } function PageHeader({ @@ -29,10 +32,13 @@ function PageHeader({ onBackClick, showBlockie = true, rightActions, + showCart = true, + cartItemsCount, }: PageHeaderProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); const { t } = useTranslation(); + const router = useRouter(); const toggleMenu = () => { setIsMenuOpen((prevState) => !prevState); @@ -114,10 +120,20 @@ function PageHeader({
{rightActions} - {!hideCart && ( -
+ {showCart && ( +
+ {cartItemsCount && cartItemsCount > 0 ? ( + + {cartItemsCount} + + ) : null} + )}
diff --git a/packages/ui/src/productCard.tsx b/packages/ui/src/productCard.tsx index 94eeeaa..9949eac 100644 --- a/packages/ui/src/productCard.tsx +++ b/packages/ui/src/productCard.tsx @@ -12,7 +12,8 @@ interface ProductCardProps { price: number; badgeText: string; onClick: () => void; - isAddingToShoppingCart: boolean; + onAddToCart?: () => void; + isAddingToShoppingCart?: boolean; } export function ProductCard({ @@ -23,7 +24,7 @@ export function ProductCard({ price, badgeText, onClick, - isAddingToShoppingCart, + onAddToCart, }: ProductCardProps) { return (
@@ -51,7 +52,6 @@ export function ProductCard({ variant="secondary" onClick={onClick} icon={} - disabled={isAddingToShoppingCart} />