Add gift registry page
This commit is contained in:
512
frontend/src/app/(public)/invite/[slug]/gifts/page.tsx
Normal file
512
frontend/src/app/(public)/invite/[slug]/gifts/page.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useGifts } from "@/context/gift-context";
|
||||
import { useGuests } from "@/context/guest-context";
|
||||
import { GiftItem, GiftPurchase } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertCircle, ExternalLink, Gift, Loader2 } from "lucide-react";
|
||||
|
||||
export default function GiftRegistryPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const invitationCode = searchParams.get("code");
|
||||
|
||||
// States
|
||||
const [activeTab, setActiveTab] = useState("available");
|
||||
const [isReserving, setIsReserving] = useState(false);
|
||||
const [selectedGift, setSelectedGift] = useState<GiftItem | null>(null);
|
||||
const [quantity, setQuantity] = useState("1");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [availableGifts, setAvailableGifts] = useState<GiftItem[]>([]);
|
||||
const [reservedGifts, setReservedGifts] = useState<GiftItem[]>([]);
|
||||
const [guestPurchases, setGuestPurchases] = useState<GiftPurchase[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Context hooks
|
||||
const {
|
||||
items,
|
||||
isLoadingItems,
|
||||
refetchItems,
|
||||
reserveItem,
|
||||
cancelReservation,
|
||||
setCurrentEventId,
|
||||
fetchPurchasesByGuest,
|
||||
} = useGifts();
|
||||
|
||||
const { findGuestByInvitationCode, guests, isLoadingGuests } = useGuests();
|
||||
|
||||
// Find current guest based on invitation code
|
||||
const currentGuest = invitationCode
|
||||
? findGuestByInvitationCode(invitationCode)
|
||||
: undefined;
|
||||
|
||||
// Initialize when guest data is available
|
||||
useEffect(() => {
|
||||
if (isLoadingGuests || !invitationCode) return;
|
||||
|
||||
if (!currentGuest) {
|
||||
setErrorMessage(
|
||||
"Invalid invitation code. Please check your invitation link.",
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load event gifts
|
||||
const eventId = currentGuest.event_id;
|
||||
if (eventId) {
|
||||
setCurrentEventId(eventId);
|
||||
refetchItems(undefined, eventId)
|
||||
.catch((error) => {
|
||||
console.error("Error fetching gifts:", error);
|
||||
setErrorMessage(
|
||||
"Unable to load gift registry. Please try again later.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Fetch guest's purchases/reservations
|
||||
fetchPurchasesByGuest(currentGuest.id)
|
||||
.then((purchases) => {
|
||||
if (purchases) {
|
||||
setGuestPurchases(purchases);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching guest purchases:", error);
|
||||
});
|
||||
} else {
|
||||
setErrorMessage("No event found for this invitation.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
currentGuest,
|
||||
invitationCode,
|
||||
isLoadingGuests,
|
||||
refetchItems,
|
||||
setCurrentEventId,
|
||||
fetchPurchasesByGuest,
|
||||
]);
|
||||
|
||||
// Process gifts into available and reserved categories
|
||||
useEffect(() => {
|
||||
if (items && guestPurchases.length >= 0) {
|
||||
// Get IDs of gifts that this guest has reserved
|
||||
const reservedGiftIds = new Set(
|
||||
guestPurchases.map((purchase) => purchase.gift_id),
|
||||
);
|
||||
|
||||
// Available gifts (visible and not fully reserved)
|
||||
const available = items.filter(
|
||||
(item) =>
|
||||
item.is_visible &&
|
||||
!reservedGiftIds.has(item.id) &&
|
||||
(item.status === "available" ||
|
||||
(item.quantity_requested &&
|
||||
item.quantity_received &&
|
||||
item.quantity_received < item.quantity_requested)),
|
||||
);
|
||||
|
||||
// Reserved gifts (this guest has reserved them)
|
||||
const reserved = items.filter((item) => reservedGiftIds.has(item.id));
|
||||
|
||||
setAvailableGifts(available);
|
||||
setReservedGifts(reserved);
|
||||
}
|
||||
}, [items, guestPurchases]);
|
||||
|
||||
// Format priority for display
|
||||
const formatPriority = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "must_have":
|
||||
return {
|
||||
label: "Must Have",
|
||||
color: "bg-red-100 text-red-800 border-red-200",
|
||||
};
|
||||
case "high":
|
||||
return {
|
||||
label: "High",
|
||||
color: "bg-orange-100 text-orange-800 border-orange-200",
|
||||
};
|
||||
case "medium":
|
||||
return {
|
||||
label: "Medium",
|
||||
color: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
};
|
||||
case "low":
|
||||
return {
|
||||
label: "Low",
|
||||
color: "bg-green-100 text-green-800 border-green-200",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: priority,
|
||||
color: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Reserve gift handler
|
||||
const handleReserveClick = (gift: GiftItem) => {
|
||||
setSelectedGift(gift);
|
||||
setQuantity("1");
|
||||
setIsReserving(true);
|
||||
};
|
||||
|
||||
// Remove reservation handler
|
||||
const handleRemoveReservation = async (gift: GiftItem) => {
|
||||
if (!currentGuest) {
|
||||
setErrorMessage("Guest information not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cancelReservation(gift.id, currentGuest.id);
|
||||
|
||||
// Refresh data
|
||||
if (currentGuest.event_id) {
|
||||
await refetchItems(undefined, currentGuest.event_id);
|
||||
const updatedPurchases = await fetchPurchasesByGuest(currentGuest.id);
|
||||
if (updatedPurchases) {
|
||||
setGuestPurchases(updatedPurchases);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing reservation:", error);
|
||||
setErrorMessage("Unable to remove reservation. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm reservation handler
|
||||
const handleConfirmReservation = async () => {
|
||||
console.debug("Confirm reservation:", currentGuest);
|
||||
if (!selectedGift || !currentGuest) {
|
||||
console.error("Missing information ", { selectedGift, currentGuest });
|
||||
setErrorMessage("Required information missing.");
|
||||
return;
|
||||
}
|
||||
console.log("Reserving: ", { selectedGift, currentGuest });
|
||||
|
||||
try {
|
||||
await reserveItem(
|
||||
selectedGift.id,
|
||||
currentGuest.id,
|
||||
parseInt(quantity, 10),
|
||||
);
|
||||
setIsReserving(false);
|
||||
|
||||
// Refresh data
|
||||
if (currentGuest.event_id) {
|
||||
await refetchItems(undefined, currentGuest.event_id);
|
||||
const updatedPurchases = await fetchPurchasesByGuest(currentGuest.id);
|
||||
if (updatedPurchases) {
|
||||
setGuestPurchases(updatedPurchases);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reserving gift:", error);
|
||||
setErrorMessage("Unable to reserve gift. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
// Get reservation quantity for a gift
|
||||
const getReservationQuantity = (gift: GiftItem): number => {
|
||||
const purchase = guestPurchases.find((p) => p.gift_id === gift.id);
|
||||
return purchase?.quantity || 0;
|
||||
};
|
||||
|
||||
// Generate quantity options for the dialog
|
||||
const getQuantityOptions = (gift: GiftItem) => {
|
||||
if (!gift.quantity_requested) return [1]; // Default to 1 if no quantity specified
|
||||
|
||||
const remaining = gift.quantity_requested - (gift.quantity_received || 0);
|
||||
return Array.from(
|
||||
{ length: remaining > 0 ? remaining : 1 },
|
||||
(_, i) => i + 1,
|
||||
);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading || isLoadingGuests || isLoadingItems) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[300px] p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
|
||||
<p className="text-muted-foreground">Loading gift registry...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2 text-center">
|
||||
Gift Registry 🎁
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Choose a gift from the wishlist to help celebrate Emma's 1st birthday
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="available"
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="available" className="text-base">
|
||||
Available Gifts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="reserved" className="text-base">
|
||||
Your Reservations{" "}
|
||||
{reservedGifts.length > 0 && `(${reservedGifts.length})`}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="available">
|
||||
{availableGifts.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertTitle>No Available Gifts</AlertTitle>
|
||||
<AlertDescription>
|
||||
All gifts have been reserved or there are no gifts available at
|
||||
this time.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Link</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{availableGifts.map((gift) => {
|
||||
const priority = formatPriority(gift.priority || "medium");
|
||||
return (
|
||||
<TableRow key={gift.id}>
|
||||
<TableCell className="font-medium">
|
||||
{gift.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={priority.color}>
|
||||
{priority.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{gift.purchase_url ? (
|
||||
<a
|
||||
href={gift.purchase_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink size={14} className="mr-1" />
|
||||
View Store
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
None
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{gift.description || ""}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleReserveClick(gift)}
|
||||
>
|
||||
<Gift className="h-4 w-4 mr-1" />
|
||||
Reserve
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reserved">
|
||||
{reservedGifts.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertTitle>No Reservations</AlertTitle>
|
||||
<AlertDescription>
|
||||
You haven't reserved any gifts yet. Switch to the "Available
|
||||
Gifts" tab to make a selection.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Link</TableHead>
|
||||
<TableHead>Quantity</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reservedGifts.map((gift) => {
|
||||
const priority = formatPriority(gift.priority || "medium");
|
||||
const quantity = getReservationQuantity(gift);
|
||||
return (
|
||||
<TableRow key={gift.id}>
|
||||
<TableCell className="font-medium">
|
||||
{gift.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={priority.color}>
|
||||
{priority.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{gift.purchase_url ? (
|
||||
<a
|
||||
href={gift.purchase_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink size={14} className="mr-1" />
|
||||
View Store
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
None
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{quantity}</TableCell>
|
||||
<TableCell>{gift.description || ""}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveReservation(gift)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Reservation Dialog */}
|
||||
<Dialog open={isReserving} onOpenChange={setIsReserving}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reserve Gift</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedGift?.name} - {selectedGift?.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
How many would you like to reserve?
|
||||
</label>
|
||||
<Select value={quantity} onValueChange={setQuantity}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select quantity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedGift &&
|
||||
getQuantityOptions(selectedGift).map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedGift?.purchase_url && (
|
||||
<div className="mt-4 p-3 bg-muted rounded-md text-sm">
|
||||
<p className="font-medium mb-1">Where to buy:</p>
|
||||
<a
|
||||
href={selectedGift.purchase_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center hover:underline"
|
||||
>
|
||||
<ExternalLink size={14} className="mr-1" />
|
||||
Visit Store
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsReserving(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmReservation}>
|
||||
Confirm Reservation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import { Loader2 } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RSVPModal } from "@/components/rsvp/rsvp-modal";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { getServerFileUrl } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
|
||||
// Helper function to get server file URL (assuming it exists in your codebase)
|
||||
// If not, replace with your actual implementation
|
||||
@@ -20,6 +21,7 @@ const InvitationPage = () => {
|
||||
const [showRSVP, setShowRSVP] = useState(false);
|
||||
const { themes, isLoadingThemes } = useEventThemes();
|
||||
const guestId = "current-guest-id-placeholder";
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Fetch event data when slug is available
|
||||
useEffect(() => {
|
||||
@@ -160,6 +162,8 @@ const InvitationPage = () => {
|
||||
const foregroundImageUrl = getServerFileUrl(eventTheme?.foreground_image_url);
|
||||
const backgroundImageUrl = getServerFileUrl(eventTheme?.background_image_url);
|
||||
const threeAnimalsImgUrl = getAssetUrl("three-animals");
|
||||
const invitationCode = searchParams.get("code");
|
||||
const giftRegistryPageUrl = `/invite/${slug}/gifts?code=${invitationCode}`;
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen overflow-hidden pb-10 pt-0"
|
||||
@@ -395,6 +399,7 @@ const InvitationPage = () => {
|
||||
color: "white",
|
||||
}}
|
||||
onClick={() => setShowRSVP(true)}
|
||||
disabled={!invitationCode}
|
||||
>
|
||||
PRESENZA
|
||||
</Button>
|
||||
@@ -415,9 +420,9 @@ const InvitationPage = () => {
|
||||
backgroundColor: colors.secondary,
|
||||
color: "white",
|
||||
}}
|
||||
onClick={() => window.open(`/gifts/${slug}`, "_blank")}
|
||||
disabled={!invitationCode}
|
||||
>
|
||||
LISTA REGALI
|
||||
<Link href={giftRegistryPageUrl}>LISTA REGALI</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user