From fef9342158e64160d34af6f323a96264e066a771 Mon Sep 17 00:00:00 2001 From: Alfredo Bonilla Date: Mon, 17 Feb 2025 21:34:38 -0600 Subject: [PATCH] fix: restore cart functionality --- CHANGELOG.md | 9 + apps/web/public/locales/en/common.json | 33 +++- .../app/_components/features/CartContent.tsx | 81 ++++++++ .../_components/features/ProfileOptions.tsx | 174 ++++++++---------- .../features/checkout/OrderReview.tsx | 68 ++++++- .../web/src/app/_components/layout/Header.tsx | 7 - apps/web/src/app/checkout/page.tsx | 2 +- apps/web/src/app/shopping-cart/page.tsx | 44 ++++- .../web/src/app/user/register-coffee/page.tsx | 54 +++++- apps/web/src/providers/StarknetConfig.tsx | 31 ++++ apps/web/src/server/api/routers/order.ts | 102 ++++++++++ apps/web/src/server/auth.ts | 21 ++- apps/web/src/stories/Modal.stories.tsx | 10 +- apps/web/src/utils/formatPrice.ts | 19 ++ packages/ui/src/cartSidebar.tsx | 43 +++++ packages/ui/src/sidebar.tsx | 94 ++++++++++ 16 files changed, 648 insertions(+), 144 deletions(-) create mode 100644 apps/web/src/app/_components/features/CartContent.tsx create mode 100644 apps/web/src/providers/StarknetConfig.tsx create mode 100644 apps/web/src/server/api/routers/order.ts create mode 100644 apps/web/src/utils/formatPrice.ts create mode 100644 packages/ui/src/cartSidebar.tsx create mode 100644 packages/ui/src/sidebar.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 389db22..e7767d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,19 +16,28 @@ - Database seeding script for initial data population - Starknet provider for blockchain connectivity - TRPC setup for type-safe API routes +- Implemented Starknet payment processing in checkout flow +- Added loading states and error handling for payment transactions +- Integrated cart management with blockchain transactions +- Added i18n translations for checkout and payment flows ### Changed - Updated project structure to use Turborepo for monorepo management - Configured custom themes for DaisyUI +- Enhanced OrderReview component with Starknet payment functionality +- Improved error handling and user feedback during payment process ### Fixed - Resolved potential issues with environment variable validation +- Fixed IPFS image loading in shopping cart +- Fixed cart counter synchronization with server state ### Removed - Removed unused boilerplate code from initial setup ### Security - Implemented secure practices for handling wallet addresses and user data +- Added transaction validation for Starknet payments ### Development - Added scripts for database management and development server startup diff --git a/apps/web/public/locales/en/common.json b/apps/web/public/locales/en/common.json index a43b0d3..4722d33 100644 --- a/apps/web/public/locales/en/common.json +++ b/apps/web/public/locales/en/common.json @@ -245,10 +245,7 @@ "seller1_fullname": "Seller 1", "seller2_fullname": "Seller 2", "delivery_method_label": "Delivery method", - "delivery_method": { - "address": "Address", - "meetup": "Meetup" - }, + "delivery_method": "Delivery Method", "quantity_with_unit": "{{count}} {{unit}}", "total_balance_receivable": "Total Balance Receivable", "recieve": "Receive", @@ -279,5 +276,31 @@ "en": "English", "es": "Spanish", "pt": "Portuguese" - } + }, + + "send_to_my_home": "Send to my home", + "plus_20_usd": "+20 USD", + "pick_up_at_meetup": "Pick up at meetup", + "free": "Free", + "check_meetup_calendar": "Check meetup calendar", + "im_in": "I'm in", + "im_in_gam": "I'm in GAM", + "im_not_in_gam": "I'm not in GAM", + "next": "Next", + + "proceed_to_payment": "Proceed to Payment", + "processing_payment": "Processing Payment...", + "review_your_order": "Review Your Order", + "quantity": "Quantity", + "product_price": "Product Price", + "delivery_address": "Delivery Address", + "my_home": "My Home", + "delivery_price": "Delivery Price", + "total_price": "Total Price", + "change_currency": "Change Currency", + "congrats": "Congratulations!", + "order_confirmation_message": "Your order has been confirmed and is being processed.", + "track_in_my_orders": "Track in My Orders", + + "marketplace": "Marketplace" } diff --git a/apps/web/src/app/_components/features/CartContent.tsx b/apps/web/src/app/_components/features/CartContent.tsx new file mode 100644 index 0000000..ed077e6 --- /dev/null +++ b/apps/web/src/app/_components/features/CartContent.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { TrashIcon } from "@heroicons/react/24/outline"; +import { Text } from "@repo/ui/typography"; +import Image from "next/image"; +import { useTranslation } from "react-i18next"; +import { api } from "~/trpc/react"; + +export default function CartContent() { + const { t } = useTranslation(); + const { data: cart, refetch: refetchCart } = api.cart.getUserCart.useQuery(); + const { mutate: removeFromCart } = api.cart.removeFromCart.useMutation({ + onSuccess: () => { + void refetchCart(); + }, + }); + + const handleRemove = (cartItemId: string) => { + removeFromCart({ cartItemId }); + }; + + const getImageUrl = (nftMetadata: unknown): string => { + if (typeof nftMetadata !== "string") return "/images/default.webp"; + try { + const metadata = JSON.parse(nftMetadata) as { imageUrl: string }; + return metadata.imageUrl; + } catch { + return "/images/default.webp"; + } + }; + + if (!cart?.items || cart.items.length === 0) { + return ( +
+ {t("cart_empty_message")} +
+ ); + } + + return ( +
+ {cart.items.map((item) => ( +
+
+ {item.product.name} +
+ + {t(item.product.name)} + + + {t("quantity_label")}: {item.quantity} + +
+
+
+ + {item.product.price * item.quantity} USD + + +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/app/_components/features/ProfileOptions.tsx b/apps/web/src/app/_components/features/ProfileOptions.tsx index 3e55344..6636bfd 100644 --- a/apps/web/src/app/_components/features/ProfileOptions.tsx +++ b/apps/web/src/app/_components/features/ProfileOptions.tsx @@ -16,7 +16,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import LogoutModal from "~/app/_components/features/LogoutModal"; import { UserWalletsModal } from "~/app/_components/features/UserWalletsModal"; -import { api } from "~/trpc/react"; interface ProfileOptionsProps { address?: string; @@ -33,11 +32,6 @@ type ProfileOption = { export function ProfileOptions({ address: _ }: ProfileOptionsProps) { const { t } = useTranslation(); - const { data: user, isLoading } = api.user.getMe.useQuery(undefined, { - staleTime: 30_000, // Keep data fresh for 30 seconds - refetchOnWindowFocus: false, - }); - const [isLogoutModalOpen, setIsLogoutModalOpen] = useState(false); const [isWalletModalOpen, setIsWalletModalOpen] = useState(false); @@ -57,102 +51,88 @@ export function ProfileOptions({ address: _ }: ProfileOptionsProps) { setIsWalletModalOpen(false); }; - const getOptionsForRole = (userRole?: Role): ProfileOption[] => { - const commonOptions: ProfileOption[] = [ - { icon: UserIcon, label: t("edit_profile"), href: "/user/edit-profile" }, - { - icon: HeartIcon, - label: t("favorite_products"), - href: "/user/favorites", - }, - { - icon: CubeIcon, - label: t("my_collectibles"), - href: "/user/collectibles", - }, - { - icon: ShoppingCartIcon, - label: t("my_orders"), - href: "/user/my-orders", - }, - { icon: WalletIcon, label: t("wallet"), onClick: openWalletModal }, - { - icon: AdjustmentsHorizontalIcon, - label: t("settings"), - href: "/user/settings", - }, - { - icon: NoSymbolIcon, - label: t("log_out"), - customClass: "text-error-default", - iconColor: "text-error-default", - onClick: openLogoutModal, - }, - ]; + // Common options that are always shown + const commonOptions: ProfileOption[] = [ + { icon: UserIcon, label: t("edit_profile"), href: "/user/edit-profile" }, + { + icon: HeartIcon, + label: t("favorite_products"), + href: "/user/favorites", + }, + { + icon: CubeIcon, + label: t("my_collectibles"), + href: "/user/collectibles", + }, + { + icon: ShoppingCartIcon, + label: t("my_orders"), + href: "/user/my-orders", + }, + { icon: WalletIcon, label: t("wallet"), onClick: openWalletModal }, + { + icon: AdjustmentsHorizontalIcon, + label: t("settings"), + href: "/user/settings", + }, + { + icon: NoSymbolIcon, + label: t("log_out"), + customClass: "text-error-default", + iconColor: "text-error-default", + onClick: openLogoutModal, + }, + ]; - if (userRole === "COFFEE_PRODUCER") { - return [ - ...commonOptions.slice(0, 4), - { icon: TicketIcon, label: t("my_coffee"), href: "/user/my-coffee" }, - { icon: TruckIcon, label: t("my_sales"), href: "/user/my-sales" }, - { - icon: CurrencyDollarIcon, - label: t("my_claims"), - href: "/user/my-claims", - }, - ...commonOptions.slice(4), - ]; - } + // Producer-specific options that are loaded after user data + const producerOptions: ProfileOption[] = [ + { icon: TicketIcon, label: t("my_coffee"), href: "/user/my-coffee" }, + { icon: TruckIcon, label: t("my_sales"), href: "/user/my-sales" }, + { + icon: CurrencyDollarIcon, + label: t("my_claims"), + href: "/user/my-claims", + }, + ]; - return commonOptions; - }; + const renderOption = (option: ProfileOption) => ( +
+ {option.href ? ( + + + {option.label} + + ) : option.onClick ? ( + + ) : null} +
+
+ ); - const profileOptions = getOptionsForRole(user?.role); + return ( +
+ {/* Always render common options first */} + {commonOptions.slice(0, 4).map(renderOption)} - if (isLoading) { - return ( -
-
- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} -
-
- ); - } + {/* Show all producer options for now */} + {producerOptions.map(renderOption)} + + {/* Always render remaining common options */} + {commonOptions.slice(4).map(renderOption)} - return ( -
- {profileOptions.map((option) => ( -
- {option.href ? ( - - - {option.label} - - ) : option.onClick ? ( - - ) : null} -
-
- ))}
diff --git a/apps/web/src/app/_components/features/checkout/OrderReview.tsx b/apps/web/src/app/_components/features/checkout/OrderReview.tsx index 7702028..d255d4f 100644 --- a/apps/web/src/app/_components/features/checkout/OrderReview.tsx +++ b/apps/web/src/app/_components/features/checkout/OrderReview.tsx @@ -3,11 +3,18 @@ import { ArrowDownIcon } from "@heroicons/react/24/outline"; import Button from "@repo/ui/button"; import { Separator } from "@repo/ui/separator"; -import { useAtomValue } from "jotai"; +import { useAccount, useProvider } from "@starknet-react/core"; +import { useAtomValue, useSetAtom } from "jotai"; import Image from "next/image"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { cartItemsAtom } from "~/store/cartAtom"; +import { + ContractsInterface, + useCofiCollectionContract, + useMarketplaceContract, + useStarkContract, +} from "~/services/contractsInterface"; +import { cartItemsAtom, clearCartAtom } from "~/store/cartAtom"; import type { CartItem } from "~/store/cartAtom"; import Confirmation from "./Confirmation"; import { CurrencySelector } from "./CurrencySelector"; @@ -36,9 +43,22 @@ export default function OrderReview({ }: OrderReviewProps) { const { t } = useTranslation(); const cartItems = useAtomValue(cartItemsAtom); + const clearCart = useSetAtom(clearCartAtom); const [isCurrencySelectorOpen, setIsCurrencySelectorOpen] = useState(false); const [selectedCurrency, setSelectedCurrency] = useState("USD"); const [showConfirmation, setShowConfirmation] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + + const account = useAccount(); + const { provider } = useProvider(); + const contract = new ContractsInterface( + account, + useCofiCollectionContract(), + useMarketplaceContract(), + useStarkContract(), + provider, + ); const productPrice = cartItems.reduce( (total, item) => total + item.price * item.quantity, @@ -50,13 +70,37 @@ export default function OrderReview({ setSelectedCurrency(currency); setIsCurrencySelectorOpen(false); onCurrencySelect(currency); - // Here you would typically also convert the prices to the new currency - // For this example, we'll just update the display currency }; - const handleProceedToPayment = () => { - setShowConfirmation(true); - onNext(); + const handleProceedToPayment = async () => { + try { + setIsProcessing(true); + setError(null); + + const token_ids = cartItems.map((item) => item.tokenId); + const token_amounts = cartItems.map((item) => item.quantity); + + // Execute the purchase transaction + const tx_hash = await contract.buy_product( + token_ids, + token_amounts, + totalPrice, + ); + + // Clear the cart after successful purchase + clearCart(); + + // Show confirmation + setShowConfirmation(true); + onNext(); + } catch (err) { + console.error("Payment error:", err); + setError( + err instanceof Error ? err.message : "Failed to process payment", + ); + } finally { + setIsProcessing(false); + } }; if (showConfirmation || isConfirmed) { @@ -135,9 +179,16 @@ export default function OrderReview({
+ {error && ( +
+ {error} +
+ )} + @@ -145,8 +196,9 @@ export default function OrderReview({
diff --git a/apps/web/src/app/_components/layout/Header.tsx b/apps/web/src/app/_components/layout/Header.tsx index 1e1942b..e7499b0 100644 --- a/apps/web/src/app/_components/layout/Header.tsx +++ b/apps/web/src/app/_components/layout/Header.tsx @@ -30,13 +30,6 @@ function Header({ ? items.reduce((total, item) => total + item.quantity, 0) : undefined; - // Prefetch user data when the component mounts - useEffect(() => { - if (address) { - void utils.user.getMe.prefetch(); - } - }, [address, utils.user.getMe]); - const handleLogout = async () => { await signOut(); disconnect(); diff --git a/apps/web/src/app/checkout/page.tsx b/apps/web/src/app/checkout/page.tsx index 70e0bcd..df63acc 100644 --- a/apps/web/src/app/checkout/page.tsx +++ b/apps/web/src/app/checkout/page.tsx @@ -64,7 +64,7 @@ export default function CheckoutPage() {
- {t("my_cart")} + {t("shopping_cart_title")}
{state.checkoutStep === "delivery" && ( diff --git a/apps/web/src/app/shopping-cart/page.tsx b/apps/web/src/app/shopping-cart/page.tsx index 3f592af..0c1699d 100644 --- a/apps/web/src/app/shopping-cart/page.tsx +++ b/apps/web/src/app/shopping-cart/page.tsx @@ -3,10 +3,12 @@ import { ArrowLeftIcon, TrashIcon } from "@heroicons/react/24/outline"; import { useAccount } from "@starknet-react/core"; import { useProvider } from "@starknet-react/core"; +import { useSetAtom } from "jotai"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { type CartItem, cartItemsAtom } from "~/store/cartAtom"; import { api } from "~/trpc/react"; import { ContractsError, @@ -75,6 +77,7 @@ export default function ShoppingCart() { const { t } = useTranslation(); const [itemToDelete, setItemToDelete] = useState(null); const { provider } = useProvider(); + const setCartItems = useSetAtom(cartItemsAtom); const contract = new ContractsInterface( useAccount(), useCofiCollectionContract(), @@ -85,12 +88,33 @@ export default function ShoppingCart() { // Get cart data from server const { data: cart, refetch: refetchCart } = api.cart.getUserCart.useQuery(); + + // Sync server cart data with local atom + useEffect(() => { + if (cart?.items) { + const cartItems: CartItem[] = cart.items.map((item) => ({ + id: item.id, + tokenId: item.product.tokenId, + name: item.product.name, + quantity: item.quantity, + price: item.product.price, + imageUrl: getImageUrl(item.product.nftMetadata), + })); + setCartItems(cartItems); + } + }, [cart?.items, setCartItems]); + const { mutate: removeFromCart } = api.cart.removeFromCart.useMutation({ onSuccess: () => { void refetchCart(); }, }); - const { mutate: clearCart } = api.cart.clearCart.useMutation(); + const { mutate: clearCart } = api.cart.clearCart.useMutation({ + onSuccess: () => { + setCartItems([]); + void refetchCart(); + }, + }); const handleRemove = (cartItemId: string) => { setItemToDelete(cartItemId); @@ -146,7 +170,10 @@ export default function ShoppingCart() { if (typeof nftMetadata !== "string") return "/images/default.webp"; try { const metadata = JSON.parse(nftMetadata) as NftMetadata; - return metadata.imageUrl; + // Format the image URL to include the IPFS gateway if it's an IPFS hash + return metadata.imageUrl.startsWith("Qm") + ? `${process.env.NEXT_PUBLIC_GATEWAY_URL}/ipfs/${metadata.imageUrl}` + : metadata.imageUrl; } catch { return "/images/default.webp"; } @@ -154,7 +181,7 @@ export default function ShoppingCart() { const hasItems = Boolean(cart?.items && cart.items.length > 0); - const handleBuy = () => { + const handleCheckout = () => { router.push("/checkout"); }; @@ -163,13 +190,13 @@ export default function ShoppingCart() {
-

{t("shopping_cart_title")}

+

{t("marketplace")}

@@ -233,8 +260,7 @@ export default function ShoppingCart() {
-
diff --git a/apps/web/src/providers/StarknetConfig.tsx b/apps/web/src/providers/StarknetConfig.tsx new file mode 100644 index 0000000..4a9ace8 --- /dev/null +++ b/apps/web/src/providers/StarknetConfig.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { InjectedConnector } from "@starknet-react/core"; +import { createContext, useContext } from "react"; + +interface StarknetConfigContextType { + availableConnectors: InjectedConnector[]; +} + +const StarknetConfigContext = createContext({ + availableConnectors: [], +}); + +export function useStarknetConfig() { + return useContext(StarknetConfigContext); +} + +export function StarknetConfigProvider({ + children, +}: { children: React.ReactNode }) { + const connectors = [ + new InjectedConnector({ options: { id: "braavos" } }), + new InjectedConnector({ options: { id: "argentX" } }), + ]; + + return ( + + {children} + + ); +} diff --git a/apps/web/src/server/api/routers/order.ts b/apps/web/src/server/api/routers/order.ts new file mode 100644 index 0000000..850a3d2 --- /dev/null +++ b/apps/web/src/server/api/routers/order.ts @@ -0,0 +1,102 @@ +import { OrderStatus } from "@prisma/client"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; + +export const orderRouter = createTRPCRouter({ + // Create a new order + createOrder: protectedProcedure + .input( + z.object({ + cartId: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + // 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, + }, + }, + }, + }); + + if (!cart) { + throw new Error("Cart not found"); + } + + // 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, + total, + status: OrderStatus.COMPLETED, + items: { + create: cart.items.map((item) => ({ + productId: item.product.id, + quantity: item.quantity, + price: item.product.price, + })), + }, + }, + include: { + items: true, + }, + }); + + // Clear the cart + await tx.shoppingCartItem.deleteMany({ + where: { shoppingCartId: cart.id }, + }); + + return order; + }); + }), + + // Get user's orders + getUserOrders: protectedProcedure.query(async ({ ctx }) => { + return ctx.db.order.findMany({ + where: { userId: ctx.session.user.id }, + include: { + items: { + include: { + product: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + }), + + // Get a specific order + getOrder: protectedProcedure + .input(z.object({ orderId: z.string() })) + .query(async ({ ctx, input }) => { + const order = await ctx.db.order.findUnique({ + where: { id: input.orderId }, + include: { + items: { + include: { + product: true, + }, + }, + }, + }); + + if (!order || order.userId !== ctx.session.user.id) { + throw new Error("Order not found"); + } + + return order; + }), +}); diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index e5fc35c..5ea0066 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -97,13 +97,17 @@ export const authOptions: NextAuthOptions = { return token; }, session: ({ session, token }) => { + console.log("Session callback - Token:", token); + console.log("Session callback - Current session:", session); + if (token) { - console.log("Setting session with token data:", { - userId: token.userId ?? token.sub, - role: token.role, - }); - session.user.id = token.userId ?? token.sub ?? ""; - session.user.role = token.role ?? "COFFEE_BUYER"; + session.user = { + ...session.user, + id: token.userId ?? token.sub ?? "", + role: token.role ?? "COFFEE_BUYER", + }; + + console.log("Updated session:", session); } return session; }, @@ -137,9 +141,10 @@ export const authOptions: NextAuthOptions = { secret: process.env.NEXTAUTH_SECRET, session: { strategy: "jwt", - maxAge: 2592000, - updateAge: 86400, + maxAge: 2592000, // 30 days + updateAge: 86400, // 24 hours }, + debug: process.env.NODE_ENV === "development", }; export const getServerAuthSession = () => getServerSession(authOptions); diff --git a/apps/web/src/stories/Modal.stories.tsx b/apps/web/src/stories/Modal.stories.tsx index bd7d974..9529225 100644 --- a/apps/web/src/stories/Modal.stories.tsx +++ b/apps/web/src/stories/Modal.stories.tsx @@ -45,7 +45,9 @@ const Template: StoryFn = (args) => ( }} buttons={buttons} {...args} - /> + > +

Modal content goes here

+ ); //The default modal configuration with basic buttons. @@ -54,6 +56,8 @@ Default.args = { isOpen: true, onClose: () => alert("Modal closed"), buttons, + children:

This is a default modal with basic buttons.

, + title: "Default Modal", }; //This modal includes buttons with icons (e.g., accept and decline buttons). @@ -61,6 +65,8 @@ export const Icons = Template.bind({}); Icons.args = { isOpen: true, onClose: () => alert("Modal closed"), + title: "Modal with Icons", + children:

This modal demonstrates the use of buttons with icons.

, buttons: [ { label: "Accept", onClick: () => alert("Accept clicked") }, { @@ -76,5 +82,7 @@ export const NoButtons = Template.bind({}); NoButtons.args = { isOpen: true, onClose: () => alert("Modal closed"), + title: "Information Modal", + children:

This is an informational modal without any action buttons.

, buttons: [], }; diff --git a/apps/web/src/utils/formatPrice.ts b/apps/web/src/utils/formatPrice.ts new file mode 100644 index 0000000..db0fb1c --- /dev/null +++ b/apps/web/src/utils/formatPrice.ts @@ -0,0 +1,19 @@ +/** + * Formats a price value into a currency string + * @param price - The price value to format + * @param currency - The currency code (default: 'USD') + * @param locale - The locale to use for formatting (default: 'en-US') + * @returns A formatted price string + */ +export function formatPrice( + price: number, + currency = "USD", + locale = "en-US", +): string { + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(price); +} diff --git a/packages/ui/src/cartSidebar.tsx b/packages/ui/src/cartSidebar.tsx new file mode 100644 index 0000000..4394d89 --- /dev/null +++ b/packages/ui/src/cartSidebar.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Button from "./button"; +import { Sidebar } from "./sidebar"; +import { Text } from "./typography"; + +interface CartSidebarProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + title: string; + totalPrice?: number; + onCheckout?: () => void; + checkoutLabel?: string; +} + +export function CartSidebar({ + isOpen, + onClose, + children, + title, + totalPrice, + onCheckout, + checkoutLabel = "Checkout", +}: CartSidebarProps) { + const footer = totalPrice !== undefined && onCheckout && ( +
+
+ Total + ${totalPrice.toFixed(2)} USD +
+ +
+ ); + + return ( + + {children} + + ); +} diff --git a/packages/ui/src/sidebar.tsx b/packages/ui/src/sidebar.tsx new file mode 100644 index 0000000..4bdc3a6 --- /dev/null +++ b/packages/ui/src/sidebar.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useRef } from "react"; +import IconButton from "./iconButton"; +import { Text } from "./typography"; + +interface SidebarProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + trigger?: React.ReactNode; + footer?: React.ReactNode; +} + +export function Sidebar({ + isOpen, + onClose, + title, + children, + trigger, + footer, +}: SidebarProps) { + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, onClose]); + + return ( +
+ {trigger} + + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Menu */} + +
+ + {title} + + } + onClick={onClose} + variant="primary" + size="lg" + className="relative z-[70] p-2 hover:bg-gray-100 rounded-full transition-colors !bg-transparent !text-content-body-default !border-0" + aria-label="Close sidebar" + /> +
+
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+ + )} +
+
+ ); +}