diff --git a/frontend/src/app/buyer/fundraiser/[id]/cart/components/CartForm.tsx b/frontend/src/app/buyer/fundraiser/[id]/cart/components/CartForm.tsx index e726c895..d4bb428d 100644 --- a/frontend/src/app/buyer/fundraiser/[id]/cart/components/CartForm.tsx +++ b/frontend/src/app/buyer/fundraiser/[id]/cart/components/CartForm.tsx @@ -16,7 +16,7 @@ import { noAuthFetcher } from "@/lib/fetcher"; import Decimal from "decimal.js"; import Image from "next/image"; -export function CartForm({ code }: { code: string }) { +export function CartForm({ referrer }: { referrer: string }) { const router = useRouter(); const params = useParams(); const fundraiserId = params.id as string; @@ -59,7 +59,9 @@ export function CartForm({ code }: { code: string }) { const removals: string[] = []; cart.forEach((cartItem) => { - const availabilityItem = items.find((item) => item.id === cartItem.item.id); + const availabilityItem = items.find( + (item) => item.id === cartItem.item.id, + ); if (!availabilityItem || availabilityItem.offsale) { removeItem(fundraiserId, cartItem.item); @@ -117,13 +119,17 @@ export function CartForm({ code }: { code: string }) { const handleIncrement = (item: typeof CompleteItemSchema._type) => { const cartItem = cartWithImages.find((ci) => ci.item.id === item.id); if (cartItem) { - const availabilityItem = items?.find((availability) => availability.id === item.id); + const availabilityItem = items?.find( + (availability) => availability.id === item.id, + ); if ( availabilityItem?.available !== null && availabilityItem?.available !== undefined && cartItem.quantity + 1 > availabilityItem.available ) { - toast.error(`Only ${availabilityItem.available} available for ${item.name}`); + toast.error( + `Only ${availabilityItem.available} available for ${item.name}`, + ); return; } updateQuantity(fundraiserId, item, cartItem.quantity + 1); @@ -142,8 +148,8 @@ export function CartForm({ code }: { code: string }) { }; const handleCheckout = async () => { - const nextCheckoutPath = code - ? `/buyer/fundraiser/${fundraiserId}/checkout?code=${code}` + const nextCheckoutPath = referrer + ? `/buyer/fundraiser/${fundraiserId}/checkout?referrer=${referrer}` : `/buyer/fundraiser/${fundraiserId}/checkout`; // Check if user is already authenticated diff --git a/frontend/src/app/buyer/fundraiser/[id]/cart/page.tsx b/frontend/src/app/buyer/fundraiser/[id]/cart/page.tsx index 01dac133..67ef3929 100644 --- a/frontend/src/app/buyer/fundraiser/[id]/cart/page.tsx +++ b/frontend/src/app/buyer/fundraiser/[id]/cart/page.tsx @@ -7,17 +7,17 @@ export default async function CartPage({ searchParams, }: { params: Promise<{ id: string }>; - searchParams: Promise<{ code?: string }>; + searchParams: Promise<{ referrer?: string }>; }) { await connection(); const supabase = await createClient(); const id = (await params).id; - const { code } = await searchParams; + const { referrer } = await searchParams; const nextPath = - typeof code === "string" && code.length > 0 - ? `/buyer/fundraiser/${id}/cart?code=${encodeURIComponent(code)}` + typeof referrer === "string" && referrer.length > 0 + ? `/buyer/fundraiser/${id}/cart?referrer=${encodeURIComponent(referrer)}` : `/buyer/fundraiser/${id}/cart`; // protect page (must use supabase.auth.getUser() according to docs) @@ -40,7 +40,7 @@ export default async function CartPage({ return (
- +
); } diff --git a/frontend/src/app/buyer/fundraiser/[id]/checkout/components/CheckoutForm.tsx b/frontend/src/app/buyer/fundraiser/[id]/checkout/components/CheckoutForm.tsx index 19a6e8d2..f7fd85c4 100644 --- a/frontend/src/app/buyer/fundraiser/[id]/checkout/components/CheckoutForm.tsx +++ b/frontend/src/app/buyer/fundraiser/[id]/checkout/components/CheckoutForm.tsx @@ -53,21 +53,19 @@ import { } from "@/components/ui/sheet"; import Image from "next/image"; import { ReferrersModal } from "./ReferrersModal"; -import { - formatCapacityIssueMessage, - getCapacityIssues, -} from "@/lib/capacity"; +import { formatCapacityIssueMessage, getCapacityIssues } from "@/lib/capacity"; +import { cn } from "@/lib/utils"; export function CheckoutForm({ token, fundraiser, userProfile, - code, + referrer, }: { token: string; fundraiser: z.infer; userProfile: z.infer; - code: string; + referrer: string; }) { const router = useRouter(); const isMobile = useIsMobile(); @@ -82,12 +80,11 @@ export function CheckoutForm({ isLoading: isAvailabilityLoading, mutate: refreshAvailability, } = useItemsAvailability(fundraiser.id); - const initialReferralId = fundraiser.referrals.some((r) => r.id === code) - ? code + const initialReferralId = fundraiser.referrals.some((r) => r.id === referrer) + ? referrer : "none"; - const [selectedReferralId, setSelectedReferralId] = useState( - initialReferralId, - ); + const [selectedReferralId, setSelectedReferralId] = + useState(initialReferralId); const [paymentMethod, setPaymentMethod] = useState<"VENMO" | "OTHER">( "VENMO", ); @@ -113,8 +110,9 @@ export function CheckoutForm({ const selectedReferralName = selectedReferralId && selectedReferralId !== "none" ? fundraiser.referrals.find((r) => r.id === selectedReferralId)?.referrer - .name || "No Referral" - : "No Referral"; + .name || "Refer a club member" + : "Refer a club member"; + const selectedReferral = selectedReferralName != "Refer a club member"; const orderTotal = cartWithImages .reduce( @@ -208,7 +206,9 @@ export function CheckoutForm({ availabilityItem?.available !== undefined && newQuantity > availabilityItem.available ) { - toast.error(`Only ${availabilityItem.available} available for ${item.name}`); + toast.error( + `Only ${availabilityItem.available} available for ${item.name}`, + ); return; } } @@ -285,7 +285,12 @@ export function CheckoutForm({ {/* Referral */} @@ -735,7 +754,12 @@ export function CheckoutForm({ {/* Referral Button */} diff --git a/frontend/src/app/buyer/fundraiser/[id]/checkout/page.tsx b/frontend/src/app/buyer/fundraiser/[id]/checkout/page.tsx index c0232cf2..78805ac7 100644 --- a/frontend/src/app/buyer/fundraiser/[id]/checkout/page.tsx +++ b/frontend/src/app/buyer/fundraiser/[id]/checkout/page.tsx @@ -10,18 +10,18 @@ export default async function CheckoutPage({ searchParams, }: { params: Promise<{ id: string }>; - searchParams: Promise<{ code?: string }>; + searchParams: Promise<{ referrer?: string }>; }) { await connection(); const supabase = await createClient(); const id = (await params).id; - const { code } = await searchParams; + const { referrer } = await searchParams; const nextPath = - typeof code === "string" && code.length > 0 - ? `/buyer/fundraiser/${id}/checkout?code=${encodeURIComponent(code)}` + typeof referrer === "string" && referrer.length > 0 + ? `/buyer/fundraiser/${id}/checkout?referrer=${encodeURIComponent(referrer)}` : `/buyer/fundraiser/${id}/checkout`; // protect page (must use supabase.auth.getUser() according to docs) @@ -60,7 +60,7 @@ export default async function CheckoutPage({ fundraiser={fundraiser} token={session.access_token} userProfile={userProfile} - code={code ? code : ""} + referrer={referrer ? referrer : ""} /> ); diff --git a/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserCartSidebar.tsx b/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserCartSidebar.tsx index 8df494e1..093fec53 100644 --- a/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserCartSidebar.tsx +++ b/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserCartSidebar.tsx @@ -44,10 +44,10 @@ export function FundraiserCartSidebar({ // will auto-start Google sign-in and return to the checkout page const nextPath = isMobile ? referralId - ? `/buyer/fundraiser/${fundraiserId}/cart?code=${referralId}` + ? `/buyer/fundraiser/${fundraiserId}/cart?referrer=${referralId}` : `/buyer/fundraiser/${fundraiserId}/cart` : referralId - ? `/buyer/fundraiser/${fundraiserId}/checkout?code=${referralId}` + ? `/buyer/fundraiser/${fundraiserId}/checkout?referrer=${referralId}` : `/buyer/fundraiser/${fundraiserId}/checkout`; router.push(`/login?next=${encodeURIComponent(nextPath)}`); diff --git a/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserItemsPanel.tsx b/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserItemsPanel.tsx index c240e452..e201c233 100644 --- a/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserItemsPanel.tsx +++ b/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserItemsPanel.tsx @@ -12,17 +12,21 @@ import { toast } from "sonner"; export function FundraiserItemsPanel({ isPast, fundraiserId, + fundraiserName, items, }: { isPast: boolean; fundraiserId: string; + fundraiserName: string; items: z.infer[]; }) { const { addItem, removeItem, updateQuantity } = useCartStore(); // fixes nextjs hydration issue: https://github.com/pmndrs/zustand/issues/938#issuecomment-1481801942 const cart = useStore(useCartStore, (state) => state.carts[fundraiserId]); - const handleIncrement = (item: z.infer) => { + const handleIncrement = ( + item: z.infer, + ) => { const cartItem = cart?.find((cartItem) => cartItem.item.id === item.id); const currentQty = cartItem?.quantity ?? 0; @@ -34,11 +38,13 @@ export function FundraiserItemsPanel({ if (cartItem) { updateQuantity(fundraiserId, item, currentQty + 1); } else { - addItem(fundraiserId, item, 1); + addItem(fundraiserId, item, 1, fundraiserName); } }; - const handleDecrement = (item: z.infer) => { + const handleDecrement = ( + item: z.infer, + ) => { const cartItem = cart?.find((cartItem) => cartItem.item.id === item.id); if (cartItem) { if (cartItem.quantity > 1) { @@ -49,7 +55,10 @@ export function FundraiserItemsPanel({ } }; - const handleSetQuantity = (item: z.infer, quantity: number) => { + const handleSetQuantity = ( + item: z.infer, + quantity: number, + ) => { if (quantity <= 0) { removeItem(fundraiserId, item); return; @@ -64,7 +73,7 @@ export function FundraiserItemsPanel({ if (cartItem) { updateQuantity(fundraiserId, item, quantity); } else { - addItem(fundraiserId, item, quantity); + addItem(fundraiserId, item, quantity, fundraiserName); } }; @@ -78,8 +87,8 @@ export function FundraiserItemsPanel({
{items.map((item) => { const amount = - cart?.find((cartItem) => cartItem.item.id === item.id)?.quantity || - 0; + cart?.find((cartItem) => cartItem.item.id === item.id) + ?.quantity || 0; const isOutOfStock = item.available !== null && item.available <= 0; return ( @@ -91,7 +100,9 @@ export function FundraiserItemsPanel({ amount={amount} increment={() => handleIncrement(item)} decrement={() => handleDecrement(item)} - setCartQuantity={(quantity) => handleSetQuantity(item, quantity)} + setCartQuantity={(quantity) => + handleSetQuantity(item, quantity) + } available={item.available} isOutOfStock={isOutOfStock} isPast={isPast} diff --git a/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserReferralCard.tsx b/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserReferralCard.tsx index 04732f3c..4a9fc9d7 100644 --- a/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserReferralCard.tsx +++ b/frontend/src/app/buyer/fundraiser/[id]/components/FundraiserReferralCard.tsx @@ -42,7 +42,7 @@ export function FundraiserReferralCard({ const [href, setHref] = useState("#"); useEffect(() => { - setHref(`${window.location.origin}${pathname}?code=${referralId}`); + setHref(`${window.location.origin}${pathname}?referrer=${referralId}`); }, [pathname, referralId]); const [link, setLink] = useState(""); @@ -69,7 +69,7 @@ export function FundraiserReferralCard({ useEffect(() => { if (referralId) { - setLink(`${window.location.origin}${pathname}?code=${referralId}`); + setLink(`${window.location.origin}${pathname}?referrer=${referralId}`); } }, [pathname, referralId]); @@ -156,7 +156,7 @@ function ReferralModal({
- fundraiser/{link.match(/code=([^&]+)/)?.[1]} + fundraiser/{link.match(/referrer=([^&]+)/)?.[1]}
- {codeValue == "" && user && session?.access_token && ( + {referrerValue == "" && user && session?.access_token && ( )} - {codeValue != "" && ( + {referrerValue != "" && (
@@ -144,14 +149,22 @@ export default async function FundraiserPage({
- +
- {event.location}, {" "} - to + {event.location},{" "} + {" "} + to{" "} +
@@ -177,13 +190,14 @@ export default async function FundraiserPage({
diff --git a/frontend/src/app/seller/fundraiser/[id]/components/FundraiserHeader.tsx b/frontend/src/app/seller/fundraiser/[id]/components/FundraiserHeader.tsx index bb829963..f27d9149 100644 --- a/frontend/src/app/seller/fundraiser/[id]/components/FundraiserHeader.tsx +++ b/frontend/src/app/seller/fundraiser/[id]/components/FundraiserHeader.tsx @@ -5,6 +5,7 @@ import { CompleteFundraiserSchema, CompleteItemSchema, PickupEventSchema, + ReferralSchema, } from "common"; import { Calendar, MapPin } from "lucide-react"; import { format } from "date-fns"; @@ -22,10 +23,12 @@ export function FundraiserHeader({ token, fundraiser, fundraiserItems, + referrals, }: { token: string; fundraiser: z.infer; fundraiserItems: z.infer[]; + referrals: z.infer[]; }) { const [isSubmitting, setIsSubmitting] = useState(false); const [openEdit, setOpenEdit] = useState(false); @@ -37,7 +40,7 @@ export function FundraiserHeader({ }; // Sort pickup events by start time and group by day const sortedEvents = [...fundraiser.pickupEvents].sort( - (a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime() + (a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(), ); // Group events by their date (using formatted date string as key for grouping) @@ -61,7 +64,7 @@ export function FundraiserHeader({ toast.success("Fundraiser published successfully"); } catch (error) { toast.error( - `Failed to publish fundraiser: ${error instanceof Error ? error.message : "Unknown error"}` + `Failed to publish fundraiser: ${error instanceof Error ? error.message : "Unknown error"}`, ); setIsSubmitting(false); } @@ -80,6 +83,7 @@ export function FundraiserHeader({ @@ -104,7 +108,7 @@ export function FundraiserHeader({ Edit setOpenReferral(true)} />
@@ -150,7 +154,7 @@ export function FundraiserHeader({ - ) + ), )} diff --git a/frontend/src/app/seller/fundraiser/[id]/components/ReferralApprovalModal.tsx b/frontend/src/app/seller/fundraiser/[id]/components/ReferralApprovalModal.tsx index 251e6c9e..dd741901 100644 --- a/frontend/src/app/seller/fundraiser/[id]/components/ReferralApprovalModal.tsx +++ b/frontend/src/app/seller/fundraiser/[id]/components/ReferralApprovalModal.tsx @@ -8,8 +8,12 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; -import { CompleteFundraiserSchema, ReferralSchema } from "common"; -import { useState } from "react"; +import { + CompleteFundraiserSchema, + CompleteOrderSchema, + ReferralSchema, +} from "common"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { mutationFetch } from "@/lib/fetcher"; @@ -17,7 +21,7 @@ import { mutationFetch } from "@/lib/fetcher"; const approveReferrer = async ( fundraiserId: string, referralId: string, - token: string + token: string, ) => { try { await mutationFetch( @@ -26,14 +30,16 @@ const approveReferrer = async ( ); toast.success("Referrer approved!"); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to approve referrer"); + toast.error( + err instanceof Error ? err.message : "Failed to approve referrer", + ); } }; const deleteReferrer = async ( fundraiserId: string, referralId: string, - token: string + token: string, ) => { try { await mutationFetch( @@ -42,24 +48,33 @@ const deleteReferrer = async ( ); toast.success("Referrer deleted!"); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to delete referrer"); + toast.error( + err instanceof Error ? err.message : "Failed to delete referrer", + ); } }; export function ReferralApprovalModal({ fundraiser, token, + referrals, open, setOpen, }: { fundraiser: z.infer; token: string; + referrals: z.infer[]; open: boolean; setOpen: (open: boolean) => void; }) { const [unapprovedReferrers, setUnapprovedReferrers] = useState< z.infer[] - >(fundraiser.referrals.filter((ref) => !ref.approved)); + >(referrals.filter((ref) => !ref.approved)); + + useEffect(() => { + setUnapprovedReferrers(referrals.filter((ref) => !ref.approved)); + }, [referrals]); + const [loadingId, setLoadingId] = useState(null); return ( @@ -92,10 +107,10 @@ export function ReferralApprovalModal({ await approveReferrer( fundraiser.id, referral.id, - token + token, ); setUnapprovedReferrers((prev) => - prev.filter((ref) => ref.id !== referral.id) + prev.filter((ref) => ref.id !== referral.id), ); setLoadingId(null); }} @@ -112,10 +127,10 @@ export function ReferralApprovalModal({ await deleteReferrer( fundraiser.id, referral.id, - token + token, ); setUnapprovedReferrers((prev) => - prev.filter((ref) => ref.id !== referral.id) + prev.filter((ref) => ref.id !== referral.id), ); setLoadingId(null); }} @@ -142,13 +157,18 @@ export function ReferralApprovalModal({ } export function ReferralButton({ - fundraiser, + referrals, onClick, }: { - fundraiser: z.infer; + referrals: z.infer[]; onClick: () => void; }) { - const pending = fundraiser.referrals.filter((ref) => !ref.approved).length; + const [pending, setPending] = useState( + referrals.filter((ref) => !ref.approved).length, + ); + useEffect(() => { + setPending(referrals.filter((ref) => !ref.approved).length); + }, [referrals]); return (
@@ -162,6 +182,7 @@ export function ReferralButton({ {pending > 0 && ( diff --git a/frontend/src/app/seller/fundraiser/[id]/page.tsx b/frontend/src/app/seller/fundraiser/[id]/page.tsx index f26d4216..644bf1a0 100644 --- a/frontend/src/app/seller/fundraiser/[id]/page.tsx +++ b/frontend/src/app/seller/fundraiser/[id]/page.tsx @@ -14,6 +14,7 @@ import { RealtimeAnalyticsWrapper } from "@/app/seller/fundraiser/[id]/analytics import { FundraiserHeader } from "./components/FundraiserHeader"; import { serverFetch } from "@/lib/fetcher"; import { isPast } from "date-fns"; +import { ReferralsTableWrapper } from "./referrals/components/ReferralsTableWrapper"; interface FundraiserAnalytics { total_revenue: number; @@ -86,11 +87,18 @@ export default async function FundraiserAnalyticsPage({ CURaise - -
@@ -136,6 +144,8 @@ export default async function FundraiserAnalyticsPage({ fundraiserId={fundraiserId} orders={orders} token={session.access_token} + Component={ReferralsTableWrapper} + channelSuffix="table" /> diff --git a/frontend/src/app/seller/fundraiser/[id]/referrals/components/RealtimeReferralsWrapper.tsx b/frontend/src/app/seller/fundraiser/[id]/referrals/components/RealtimeReferralsWrapper.tsx index 731a44e4..b4e693dd 100644 --- a/frontend/src/app/seller/fundraiser/[id]/referrals/components/RealtimeReferralsWrapper.tsx +++ b/frontend/src/app/seller/fundraiser/[id]/referrals/components/RealtimeReferralsWrapper.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react"; import { createClient } from "@/utils/supabase/client"; -import { ReferralsTableWrapper } from "./ReferralsTableWrapper"; import { CompleteFundraiserSchema } from "common/schemas/fundraiser"; import { z } from "zod"; import { CompleteOrderSchema } from "common/schemas/order"; @@ -13,15 +12,21 @@ type Order = z.infer; interface RealtimeReferralsWrapperProps { initialReferrals: Referral[]; fundraiserId: string; - orders: Order[]; + orders?: Order[]; token: string; + Component: React.ComponentType; + componentProps?: Record; + channelSuffix?: string; } export function RealtimeReferralsWrapper({ initialReferrals, fundraiserId, - orders, + orders = [], token, + Component, + componentProps = {}, + channelSuffix = "default", }: RealtimeReferralsWrapperProps) { const [referrals, setReferrals] = useState(initialReferrals); const supabase = useMemo(() => createClient(), []); @@ -35,7 +40,7 @@ export function RealtimeReferralsWrapper({ headers: { Authorization: `Bearer ${token}`, }, - } + }, ); const result = await response.json(); @@ -45,7 +50,10 @@ export function RealtimeReferralsWrapper({ if (validatedData.success) { setReferrals(validatedData.data.referrals); } else { - console.error("Invalid fundraiser data format:", validatedData.error); + console.error( + "Invalid fundraiser data format:", + validatedData.error, + ); } } else { console.error("Failed to refetch referrals:", result.message); @@ -70,7 +78,7 @@ export function RealtimeReferralsWrapper({ if (isCancelled) return; channel = supabase - .channel(`referrals-${fundraiserId}`) + .channel(`referrals-${fundraiserId}-${channelSuffix}`) .on( "postgres_changes", { @@ -78,9 +86,9 @@ export function RealtimeReferralsWrapper({ schema: "public", table: "referrals", }, - () => { + (payload) => { refetchReferrals(); - } + }, ) .subscribe(); }; @@ -95,5 +103,7 @@ export function RealtimeReferralsWrapper({ }; }, [fundraiserId, token, supabase]); - return ; + return ( + + ); } diff --git a/frontend/src/app/seller/fundraiser/[id]/referrals/components/ReferralsTableWrapper.tsx b/frontend/src/app/seller/fundraiser/[id]/referrals/components/ReferralsTableWrapper.tsx index b7934fa1..847a0662 100644 --- a/frontend/src/app/seller/fundraiser/[id]/referrals/components/ReferralsTableWrapper.tsx +++ b/frontend/src/app/seller/fundraiser/[id]/referrals/components/ReferralsTableWrapper.tsx @@ -2,8 +2,8 @@ import { ReferralsTable } from "./ReferralsTable"; import { - getReferralsColumns, - ReferralWithQuantities, + getReferralsColumns, + ReferralWithQuantities, } from "./ReferralsTableColumns"; import { ReferralSchema } from "common/schemas/fundraiser"; import { CompleteOrderSchema } from "common/schemas/order"; @@ -13,32 +13,32 @@ type Referral = z.infer; type Order = z.infer; interface ReferralsTableWrapperProps { - referrals: Referral[]; - orders: Order[]; + referrals: Referral[]; + orders: Order[]; } export function ReferralsTableWrapper({ - referrals, - orders, + referrals, + orders, }: ReferralsTableWrapperProps) { - // Calculate order count for each referral - const referralsWithQuantities: ReferralWithQuantities[] = referrals.map( - (referral) => { - // Count how many orders have this referral ID and are confirmed (paid or picked up) - const orderCount = orders.filter( - (order) => - order.referral?.id === referral.id && - (order.paymentStatus === "CONFIRMED" || order.pickedUp) - ).length; + // Calculate order count for each referral + const referralsWithQuantities: ReferralWithQuantities[] = referrals.map( + (referral) => { + // Count how many orders have this referral ID and are confirmed (paid or picked up) + const orderCount = orders.filter( + (order) => + order.referral?.id === referral.id && + (order.paymentStatus === "CONFIRMED" || order.pickedUp), + ).length; - return { - ...referral, - orderCount, - }; - } - ); + return { + ...referral, + orderCount, + }; + }, + ); - const columns = getReferralsColumns(); + const columns = getReferralsColumns(); - return ; + return ; } diff --git a/frontend/src/components/custom/CartDropdown.tsx b/frontend/src/components/custom/CartDropdown.tsx new file mode 100644 index 00000000..1e93d68f --- /dev/null +++ b/frontend/src/components/custom/CartDropdown.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Plus, ShoppingCart, Trash, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { useCartStore } from "@/lib/store/useCartStore"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { useEffect } from "react"; +import useStore from "@/lib/store/useStore"; +import { CompleteItemSchema } from "common"; + +export function CartDropdown() { + const router = useRouter(); + const isMobile = useIsMobile(); + const cleanStaleCarts = useCartStore((state) => state.cleanStaleCarts); + const carts = useStore(useCartStore, (state) => state.carts) ?? {}; + const removeItem = useCartStore((state) => state.removeItem); + const updateQuantity = useCartStore((state) => state.updateQuantity); + + // Flatten all carts into per-fundraiser summaries, skip empty ones + const fundraiserCarts = Object.entries(carts).filter( + ([, items]) => items.length > 0, + ); + + const totalQuantity = fundraiserCarts.reduce( + (sum, [, items]) => + sum + items.reduce((s, cartItem) => s + cartItem.quantity, 0), + 0, + ); + + const handleCheckout = (fundraiserId: string) => { + // Mirror FundraiserCartSidebar logic — go to cart on mobile, checkout on desktop + const nextPath = isMobile + ? `/buyer/fundraiser/${fundraiserId}/cart` + : `/buyer/fundraiser/${fundraiserId}/checkout`; + + router.push(`/login?next=${encodeURIComponent(nextPath)}`); + }; + + useEffect(() => { + const unsub = useCartStore.persist.onFinishHydration(() => { + const fundraiserIds = Object.keys(useCartStore.getState().carts); + if (fundraiserIds.length === 0) return; + + const clean = async () => { + const validIds = await Promise.all( + fundraiserIds.map(async (id) => { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/fundraiser/${id}/public`, + ); + if (!res.ok) return null; + const data = await res.json(); + const allPast = data.pickupEvents?.every( + (e: { endsAt: string }) => new Date(e.endsAt) < new Date(), + ); + return allPast ? null : id; + } catch { + return null; + } + }), + ); + cleanStaleCarts(validIds.filter(Boolean) as string[]); + }; + + void clean(); + }); + return () => unsub(); + }, []); + + return ( + + + + + + e.preventDefault()} + > +
+

Your Cart

+
+ + {fundraiserCarts.length === 0 ? ( +
+ Your cart is empty. +
+ ) : ( +
+ {fundraiserCarts.map(([fundraiserId, items], index) => { + const subtotal = items.reduce( + (sum, ci) => sum + Number(ci.item.price) * ci.quantity, + 0, + ); + + return ( +
+ {index > 0 && } +
+ {/* Fundraiser name */} + + + {/* Items */} + {items.map((cartItem) => ( +
+
+ + {cartItem.item.name} + + + ${Number(cartItem.item.price).toFixed(2)} ×{" "} + {cartItem.quantity} + +
+
+ + + $ + {( + Number(cartItem.item.price) * cartItem.quantity + ).toFixed(2)} + + +
+
+ ))} + + {/* Subtotal + checkout */} +
+ + Subtotal:{" "} + + ${subtotal.toFixed(2)} + + + +
+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/custom/Navbar.tsx b/frontend/src/components/custom/Navbar.tsx index eb799a27..ac609e6d 100644 --- a/frontend/src/components/custom/Navbar.tsx +++ b/frontend/src/components/custom/Navbar.tsx @@ -1,11 +1,11 @@ "use client"; import { - NavigationMenu, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuList, - navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + navigationMenuTriggerStyle, } from "@/components/ui/navigation-menu"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -16,74 +16,75 @@ import useStore from "@/lib/store/useStore"; import { useCartStore } from "@/lib/store/useCartStore"; import { SearchBar } from "./SearchBar"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuPortal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuPortal, } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { signOut } from "@/lib/auth-actions"; import { useState } from "react"; import Image from "next/image"; import TutorialModal from "./TutorialModal"; +import { CartDropdown } from "./CartDropdown"; export default function Navbar() { - const pathname = usePathname(); - const router = useRouter(); - const searchParams = useSearchParams(); - const [tutorialOpen, setTutorialOpen] = useState(false); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [tutorialOpen, setTutorialOpen] = useState(false); - // Determine user role based on pathname - const isBuyer = pathname.startsWith("/buyer"); - const isSeller = pathname.startsWith("/seller"); - const userRole = isBuyer ? "buyer" : isSeller ? "seller" : "buyer"; + // Determine user role based on pathname + const isBuyer = pathname.startsWith("/buyer"); + const isSeller = pathname.startsWith("/seller"); + const userRole = isBuyer ? "buyer" : isSeller ? "seller" : "buyer"; - // Show search bar logic - const showSearchBar = pathname.includes("/buyer/browse"); + // Show search bar logic + const showSearchBar = pathname.includes("/buyer/browse"); - // Shopping cart logic (buyer only) - const showCart = - pathname.includes("/buyer/fundraiser/") && !pathname.includes("/checkout"); + // Shopping cart logic (buyer only) + const showCart = + pathname.includes("/buyer/fundraiser/") && !pathname.includes("/checkout"); - const getFundraiserId = () => { - if ( - pathname.includes("/buyer/fundraiser/") && - !pathname.includes("/checkout") - ) { - const segments = pathname.split("/"); - return segments[segments.length - 1]; - } - return undefined; - }; + const getFundraiserId = () => { + if ( + pathname.includes("/buyer/fundraiser/") && + !pathname.includes("/checkout") + ) { + const segments = pathname.split("/"); + return segments[segments.length - 1]; + } + return undefined; + }; - const fundraiserId = getFundraiserId(); - const cart = - useStore(useCartStore, (state) => state.carts[fundraiserId ?? ""]) || []; + const fundraiserId = getFundraiserId(); + const cart = + useStore(useCartStore, (state) => state.carts[fundraiserId ?? ""]) || []; - const totalQuantity = cart.reduce( - (total, cartItem) => total + cartItem.quantity, - 0, - ); + const totalQuantity = cart.reduce( + (total, cartItem) => total + cartItem.quantity, + 0, + ); - // Search handlers - const handleSearchChange = (query: string) => { - const params = new URLSearchParams(searchParams.toString()); - if (query) { - params.set("search", query); - } else { - params.delete("search"); - } - router.push(`${pathname}?${params.toString()}`); - }; + // Search handlers + const handleSearchChange = (query: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (query) { + params.set("search", query); + } else { + params.delete("search"); + } + router.push(`${pathname}?${params.toString()}`); + }; - // Hide top navbar on mobile for fundraiser pages - const isFundraiserPage = - pathname.includes("/buyer/fundraiser/") && !pathname.includes("/checkout"); - const hideTopNavbarOnMobile = isFundraiserPage; + // Hide top navbar on mobile for fundraiser pages + const isFundraiserPage = + pathname.includes("/buyer/fundraiser/") && !pathname.includes("/checkout"); + const hideTopNavbarOnMobile = isFundraiserPage; return ( <> @@ -115,126 +116,137 @@ export default function Navbar() { )} - {/* Mobile Search Bar - Top - Full width */} - {showSearchBar && ( -
- -
- )} + {/* Mobile Search Bar - Top - Full width */} + {showSearchBar && ( +
+ +
+ )} - {/* Desktop Search Bar - Centered with responsive width */} - {showSearchBar && ( -
- -
- )} + {/* Desktop Search Bar - Centered with responsive width */} + {showSearchBar && ( +
+ +
+ )} - {/* Desktop Navigation - With even spacing */} -
- {showSearchBar ? ( - <> - {/* Dropdown menu for small/medium screens only */} -
- - - - - - - - Browse - - - - - Orders - - - setTutorialOpen(true)}> - - Tutorial - - - Account - - - - - Organizations - - - - - Settings - - - signOut()}> - Sign out - - - - - - -
- {/* Full navigation for XL screens and up */} -
- - - <> - - - Browse - - - - - Orders - - - - - - - -
- - ) : ( -
- - - <> - - - Browse - - - - - Orders - - - {/* {showCart && fundraiserId && ( + {/* Desktop Navigation - With even spacing */} +
+ {showSearchBar ? ( + <> + {/* Dropdown menu for small/medium screens only */} +
+ + + + + + + + Browse + + + + + Orders + + + setTutorialOpen(true)} + > + Tutorial + + + Account + + + + + Organizations + + + + + Settings + + + signOut()} + > + Sign out + + + + + + + + + +
+ {/* Full navigation for XL screens and up */} +
+ + + <> + + + Browse + + + + + Orders + + + + + + + + +
+ + ) : ( +
+ + + <> + + + Browse + + + + + Orders + + + {/* {showCart && fundraiserId && ( Cart{" "} @@ -253,57 +265,73 @@ export default function Navbar() { )} */} - - - - - -
- )} -
-
- - {/* Mobile Bottom Navigation */} -
+ + + {/* Mobile Bottom Navigation */} + + + +
+ +
+ +
+ +
+ + - - - ); + + + ); } diff --git a/frontend/src/lib/store/useCartStore.ts b/frontend/src/lib/store/useCartStore.ts index 5d35c163..b1f78dd5 100644 --- a/frontend/src/lib/store/useCartStore.ts +++ b/frontend/src/lib/store/useCartStore.ts @@ -6,6 +6,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; export const CartItem = z.object({ item: CompleteItemSchema, quantity: z.number().int().nonnegative(), + fundraiserName: z.string(), }); export type CartItem = z.infer; @@ -18,23 +19,25 @@ interface CartState { addItem: ( fundraiserId: string, item: z.infer, - quantity: number + quantity: number, + fundraiserName: string, ) => void; removeItem: ( fundraiserId: string, - item: z.infer + item: z.infer, ) => void; updateQuantity: ( fundraiserId: string, item: z.infer, - quantity: number + quantity: number, ) => void; clearCart: (fundraiserId: string) => void; clearAllCarts: () => void; getCartItems: (fundraiserId: string) => CartItem[]; prepareOrderItems: ( - fundraiserId: string + fundraiserId: string, ) => { itemId: string; quantity: number }[]; + cleanStaleCarts: (validFundraiserIds: string[]) => void; } export const useCartStore = create()( @@ -42,14 +45,14 @@ export const useCartStore = create()( (set, get) => ({ carts: {}, - addItem: (fundraiserId, item, quantity) => { + addItem: (fundraiserId, item, quantity, fundraiserName) => { set((state) => { // Get the current cart for this fundraiser const currentCart = state.carts[fundraiserId] || []; // Check if item already exists const existingItemIndex = currentCart.findIndex( - (cartItem) => cartItem.item.id === item.id + (cartItem) => cartItem.item.id === item.id, ); if (existingItemIndex >= 0) { @@ -58,6 +61,7 @@ export const useCartStore = create()( updatedCart[existingItemIndex] = { ...updatedCart[existingItemIndex], quantity: updatedCart[existingItemIndex].quantity + quantity, + fundraiserName: fundraiserName, }; return { @@ -71,7 +75,10 @@ export const useCartStore = create()( return { carts: { ...state.carts, - [fundraiserId]: [...currentCart, { item, quantity }], + [fundraiserId]: [ + ...currentCart, + { item, quantity, fundraiserName }, + ], }, }; } @@ -84,7 +91,7 @@ export const useCartStore = create()( // Filter out the item const updatedCart = currentCart.filter( - (cartItem) => cartItem.item.id !== item.id + (cartItem) => cartItem.item.id !== item.id, ); return { @@ -149,10 +156,21 @@ export const useCartStore = create()( quantity, })); }, + + cleanStaleCarts: (validFundraiserIds: string[]) => { + set((state) => { + const cleaned = Object.fromEntries( + Object.entries(state.carts).filter(([id]) => + validFundraiserIds.includes(id), + ), + ); + return { carts: cleaned }; + }); + }, }), { name: "fundraiser-cart-storage", storage: createJSONStorage(() => localStorage), - } - ) + }, + ), );