Add gift registry page
Some checks failed
Build and Push Docker Images / changes (push) Successful in 4s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Failing after 52s

This commit is contained in:
2025-03-16 20:24:12 +01:00
parent 2f1fe8dd75
commit b55ef5387b
2 changed files with 520 additions and 3 deletions

View 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>
);
}

View File

@@ -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>
)}