Compare commits

..

18 Commits

Author SHA1 Message Date
Felipe Cardoso
678e1db0a3 Update max additional guests logic and refine guest counts display
All checks were successful
Build and Push Docker Images / changes (push) Successful in 5s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
Set default max additional guests to 10 and renamed related labels for clarity. Improved guest count calculation by adding total confirmed guests and refining additional guest computations. Updated the UI to reflect these changes concisely and consistently.
2025-03-20 18:33:02 +01:00
Felipe Cardoso
a2c3f16dc7 Update guests-list to use RSVP data for dietary and notes
All checks were successful
Build and Push Docker Images / changes (push) Successful in 11s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Successful in 1m49s
Replaced `guest.dietary_restrictions` and `guest.notes` with `guest.rsvp.dietary_requirements` and `guest.rsvp.response_message`. This ensures the data reflects RSVP-specific fields and improves code consistency with the RSVP model.
2025-03-19 20:49:29 +01:00
Felipe Cardoso
44e6b2a6dc Refactor guest schema and add RSVP field to GuestBase.
Reorganized imports for better readability and compliance with standards. Added an optional `rsvp` field to the `GuestBase` model to include RSVP details. This enhances the schema's flexibility and supports additional guest-related data.
2025-03-19 20:47:12 +01:00
Felipe Cardoso
42508af610 Add optional RSVP field to guest schema
This commit introduces an optional `rsvp` field to the guest-related types and schemas. The field is nullable and references the `RSVPSchema`, allowing better handling of RSVP data in the application.
2025-03-19 20:47:07 +01:00
Felipe Cardoso
2a1f13a5f0 Add functionality to display gift reservations per guest
All checks were successful
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) Successful in 1m10s
Implemented fetching and grouping of guests' gift reservations for events. Added a popover UI to display reservation details, including guest names and quantities, with a loading state for pending data. This enhances the gift dashboard's interactivity and usability.
2025-03-19 19:56:23 +01:00
Felipe Cardoso
c81e27c602 Add event-specific guest gift reservations endpoint
Introduced a function `get_event_guest_gift_reservations` in the CRUD layer to fetch gift reservations filtered by event ID. Updated the API endpoint to optionally accept an `event_id` query parameter for retrieving reservations specific to an event.
2025-03-19 19:56:10 +01:00
Felipe Cardoso
9fe5e60907 Rename gift purchases API to reservations and update types
Refactor types and API references to replace "gift purchases" with "gift reservations" for improved clarity. Updates include type definitions, query keys, options, and endpoint URLs, with the addition of an optional event ID filter. This ensures better alignment with the intended functionality.
2025-03-19 19:55:56 +01:00
Felipe Cardoso
62ce98c80e Add support for fetching all guest gift reservations
All checks were successful
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) Successful in 1m12s
Introduce new types, query functions, and SDK methods to enable retrieving all guest gift purchases via the `/api/v1/events/gifts/purchases/guest/all` endpoint. This addition integrates with React Query and ensures type safety throughout the client.
2025-03-19 10:17:43 +01:00
Felipe Cardoso
fe3f2b0894 Add API endpoint to retrieve all guest gift reservations
Introduced a new route to fetch all guest gift reservations, ensuring API compatibility with GiftPurchase objects. Refactored CRUD functions to optimize data fetching and avoid N+1 queries. Added validation to restrict access to non-public event-related reservations.
2025-03-19 10:16:55 +01:00
Felipe Cardoso
79f08a1208 Add quantity column to GuestGifts table and update references
Introduced a `quantity` field to track the number of reserved gifts in the many-to-many GuestGifts table. Updated all related CRUD operations, models, and imports to reflect the added column. Replaced `guest_gifts` with `GuestGifts` for consistency in naming conventions.
2025-03-19 09:56:30 +01:00
Felipe Cardoso
2c73ee4d7e Add purchase link and reservation details to gift list
Enhanced the gift list by showing a clickable purchase link icon and added conditional reservation details for reserved or received items using a popover. Simplified and cleaned up related code for better readability and maintainability.
2025-03-19 09:43:58 +01:00
Felipe Cardoso
392dd6f0d2 Add columns for dietary restrictions and notes to guest list
Enhanced the guest list table by adding new columns for dietary restrictions and notes. These columns display content using interactive popovers when data is available, improving data accessibility and user experience. Updated existing table structure to accommodate these enhancements.
2025-03-19 09:07:51 +01:00
Felipe Cardoso
1f1192fb62 Trigger data refetch on mount for events and themes pages
Added `useEffect` hooks to automatically refetch guests data in the Event Detail Page and themes data in the Event Themes Page upon component mount. This ensures the displayed data is always up-to-date when the user navigates to these pages.
2025-03-19 09:02:57 +01:00
Felipe Cardoso
31c6ae3f5c Add refetch methods to event context and integrate in layout
Introduce `refetchUpcomingEvents`, `refetchPublicEvents`, and `refetchUserEvents` to the event context for improved data management. These methods are now invoked in the dashboard layout to ensure events data is refreshed upon user authentication.
2025-03-19 09:00:26 +01:00
Felipe Cardoso
678c55a1e2 Integrate RSVP context and enhance form responsiveness
Added RSVP context to manage current guest and RSVP data, ensuring dynamic updates based on state changes. Adjusted styling to improve button responsiveness and removed redundant borders for a cleaner UI. These updates enhance functionality and provide a more seamless user experience.
2025-03-19 08:40:47 +01:00
Felipe Cardoso
d61e518697 Add event_id and guest_id to RSVPSchema and RSVPSchemaCreate
event_id and guest_id fields were added to both RSVPSchema and RSVPSchemaCreate to ensure these properties are included in the API schema. This change also updates the required fields list, improving completeness and validation for RSVP-related actions.
2025-03-19 08:40:37 +01:00
Felipe Cardoso
cd22418786 Add event_id and guest_id fields to RSVPSchemaBase
All checks were successful
Build and Push Docker Images / changes (push) Successful in 4s
Build and Push Docker Images / build-backend (push) Successful in 47s
Build and Push Docker Images / build-frontend (push) Has been skipped
This update introduces event_id and guest_id fields to the RSVPSchemaBase model, ensuring better tracking of RSVP details. These additions enhance data association and model usability.
2025-03-19 08:38:22 +01:00
Felipe Cardoso
472a0b7834 Add delete functionality for gifts in the dashboard
Introduced `handleDeleteGift` to delete a gift and refetch items after deletion. Connected the delete action to the dropdown menu, enabling users to remove gifts from the UI seamlessly. This enhances gift management capabilities on the dashboard.
2025-03-19 08:23:26 +01:00
19 changed files with 544 additions and 130 deletions

View File

@@ -317,6 +317,7 @@ def upgrade() -> None:
sa.Column('gift_id', sa.UUID(), nullable=False),
sa.Column('reserved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('notes', sa.String(), nullable=True),
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['gift_id'], ['gift_items.id'], ondelete="CASCADE"),
sa.ForeignKeyConstraint(['guest_id'], ['guests.id'], ondelete="CASCADE"),
sa.PrimaryKeyConstraint('guest_id', 'gift_id')

View File

@@ -1,7 +1,7 @@
from typing import List, Optional, Dict, Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session
from app.api.dependencies.auth import get_current_active_user, get_current_user, get_optional_current_user
@@ -1048,3 +1048,27 @@ def read_gift_purchases_by_guest(
raise HTTPException(status_code=403, detail="Not enough permissions")
return gift_purchase_crud.get_gift_reservations_by_guest(db, guest_id=guest_id)
@router.get(
"/reservations/guests",
response_model=List[GiftPurchase],
operation_id="read_guests_gift_reservations",
)
def read_all_guest_gift_reservations(
*,
db: Session = Depends(get_db),
event_id: Optional[str] = Query(None, description="Optional event ID to filter reservations by event"),
) -> Any:
"""
Retrieve all guest gift reservations, optionally filtered by event ID.
"""
if event_id:
event_id = UUID(event_id)
reservations = gift_purchase_crud.get_event_guest_gift_reservations(db=db, event_id=event_id)
else:
reservations = gift_purchase_crud.get_all_guest_gift_reservations(db=db)
if not reservations:
reservations = []
return reservations

View File

@@ -2,15 +2,13 @@ from datetime import datetime, timezone
from typing import List, Optional, Dict, Any, Union
from uuid import UUID
from sqlalchemy import asc, desc
from sqlalchemy.orm import Session
from sqlalchemy import asc, desc, select
from sqlalchemy.orm import Session, joinedload
from app.crud.base import CRUDBase
from app.models import gift as gift_models
from app.models.gift import GiftItem, GiftCategory, GiftPurchase, GiftStatus, EventGiftCategory
from app.models.guest import Guest
from app.models.event import Event
from app.schemas import gifts as gift_schemas
from app.models.gift import GiftItem, GiftCategory, GiftPurchase, GiftStatus, EventGiftCategory
from app.models.guest import Guest, GuestGifts
from app.schemas.gifts import (
GiftItemCreate, GiftItemUpdate,
GiftCategoryCreate, GiftCategoryUpdate,
@@ -94,13 +92,14 @@ class CRUDGiftItem(CRUDBase[GiftItem, GiftItemCreate, GiftItemUpdate]):
gift: GiftItem = self.get(db, gift_id)
if gift and gift.quantity_received < gift.quantity_requested:
# Add to the association table using the SQLAlchemy Core Table directly
from app.models.guest import guest_gifts
from app.models.guest import GuestGifts
stmt = guest_gifts.insert().values(
stmt = GuestGifts.insert().values(
gift_id=gift_id,
guest_id=guest_id,
reserved_at=datetime.now(timezone.utc),
notes=notes
notes=notes,
quantity=quantity
)
db.execute(stmt)
@@ -151,6 +150,7 @@ class CRUDGiftItem(CRUDBase[GiftItem, GiftItemCreate, GiftItemUpdate]):
return gift # Always return the gift object, even if no changes were made
class CRUDEventGiftCategory:
def create(self, db: Session, *, obj_in: EventGiftCategoryCreate) -> EventGiftCategory:
"""Create a new event-category association"""
@@ -335,13 +335,10 @@ class CRUDGiftPurchase(CRUDBase[GiftPurchase, GiftPurchaseCreate, GiftPurchaseUp
result = []
for gift in guest.gifts:
# Access the association data through the relationship metadata
from sqlalchemy import select
from app.models.guest import guest_gifts
# Get the reservation data
stmt = select(guest_gifts).where(
(guest_gifts.c.guest_id == guest_id) &
(guest_gifts.c.gift_id == gift.id)
stmt = select(GuestGifts).where(
(GuestGifts.c.guest_id == guest_id) &
(GuestGifts.c.gift_id == gift.id)
)
reservation = db.execute(stmt).first()
@@ -359,6 +356,72 @@ class CRUDGiftPurchase(CRUDBase[GiftPurchase, GiftPurchaseCreate, GiftPurchaseUp
return result
def get_all_guest_gift_reservations(self, db: Session) -> List[GiftPurchase]:
"""Retrieve all guest gift reservations and convert them into GiftPurchase-like objects for API compatibility."""
stmt = select(Guest).options(joinedload(Guest.gifts))
guests = db.scalars(stmt).all()
result = []
for guest in guests:
# Fetch all gifts and their reservation details per guest at once to avoid N+1 issues
for gift in guest.gifts:
reservation_stmt = select(GuestGifts).where(
(GuestGifts.c.guest_id == guest.id) &
(GuestGifts.c.gift_id == gift.id)
)
reservation = db.execute(reservation_stmt).first()
if reservation:
purchase = GiftPurchase(
id=UUID('00000000-0000-0000-0000-000000000000'), # Placeholder UUID
gift_id=gift.id,
guest_id=guest.id,
quantity=1, # Default
purchased_at=reservation.reserved_at,
notes=reservation.notes
)
result.append(purchase)
return result
def get_event_guest_gift_reservations(self, db: Session, event_id: UUID | str) -> List[GiftPurchase]:
"""Retrieve all gift reservations for guests belonging to a specific event."""
event_id = event_id if isinstance(event_id, UUID) else UUID(event_id)
stmt = (
select(Guest)
.where(Guest.event_id == event_id)
.options(joinedload(Guest.gifts))
)
# Correct: Call unique() on the RESULT, not on the stmt
guests = db.execute(stmt).unique().scalars().all()
results = []
for guest in guests:
for gift in guest.gifts:
reservation_stmt = select(GuestGifts).where(
(GuestGifts.c.guest_id == guest.id) &
(GuestGifts.c.gift_id == gift.id)
)
reservation = db.execute(reservation_stmt).first()
if reservation:
purchase = GiftPurchase(
id=UUID('00000000-0000-0000-0000-000000000000'),
gift_id=gift.id,
guest_id=guest.id,
quantity=1,
purchased_at=reservation.reserved_at,
notes=reservation.notes
)
results.append(purchase)
return results
# Create CRUD instances
gift_item_crud = CRUDGiftItem(GiftItem)

View File

@@ -16,7 +16,7 @@ from .event_theme import EventTheme
from .event_media import EventMedia, MediaType, MediaPurpose
# Import guest and RSVP models
from .guest import Guest, GuestStatus, guest_gifts
from .guest import Guest, GuestStatus, GuestGifts
from .rsvp import RSVP, RSVPStatus
# Import gift-related models

View File

@@ -82,11 +82,12 @@ class Guest(Base, UUIDMixin, TimestampMixin):
# Association table for guest gifts (many-to-many relationship)
guest_gifts = Table(
GuestGifts = Table(
'guest_gifts',
Base.metadata,
Column('guest_id', UUID(as_uuid=True), ForeignKey('guests.id'), primary_key=True),
Column('gift_id', UUID(as_uuid=True), ForeignKey('gift_items.id'), primary_key=True),
Column('reserved_at', DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
Column('notes', String),
Column('quantity', Integer, default=1),
)

View File

@@ -1,11 +1,10 @@
import uuid
from pydantic import BaseModel, EmailStr, ConfigDict
from datetime import datetime
from typing import Optional, Any, Dict
from app.models.guest import GuestStatus
from uuid import UUID
from pydantic import BaseModel, EmailStr, ConfigDict
from app.models.guest import GuestStatus
from app.schemas.rsvp import RSVPSchema, RSVPStatus
@@ -50,6 +49,8 @@ class GuestRead(GuestBase):
is_blocked: bool
model_config = ConfigDict(from_attributes=True)
invitation_code: str
rsvp: Optional[RSVPSchema] = None
class GuestWithRSVPResponse(BaseModel):
"""
@@ -62,6 +63,7 @@ class GuestWithRSVPResponse(BaseModel):
class Config:
from_attributes = True
def map_rsvp_status_to_guest_status(rsvp_status: RSVPStatus) -> GuestStatus:
if rsvp_status == RSVPStatus.ATTENDING:
return GuestStatus.CONFIRMED
@@ -70,4 +72,4 @@ def map_rsvp_status_to_guest_status(rsvp_status: RSVPStatus) -> GuestStatus:
elif rsvp_status == RSVPStatus.MAYBE:
return GuestStatus.PENDING
else:
return GuestStatus.INVITED
return GuestStatus.INVITED

View File

@@ -12,6 +12,8 @@ class RSVPStatus(str, Enum):
class RSVPSchemaBase(BaseModel):
event_id: UUID
guest_id: UUID
status: RSVPStatus = Field(...)
number_of_guests: int = Field(default=1, ge=1)
response_message: str | None = None

View File

@@ -17,6 +17,7 @@ import Link from "next/link";
import { Loader2Icon, PaletteIcon } from "lucide-react";
import { useEventThemes } from "@/context/event-theme-context";
import { getServerFileUrl } from "@/lib/utils";
import { useEffect } from "react";
export default function EventThemesPage() {
// const { data: themes, isLoading } = useQuery({
@@ -24,8 +25,11 @@ export default function EventThemesPage() {
// queryFn: () => listEventThemes().then(res => res.data),
// });
const { themes, isLoadingThemes } = useEventThemes();
const { themes, refetchThemes, isLoadingThemes } = useEventThemes();
useEffect(() => {
refetchThemes();
}, []);
return (
<div className="max-w-7xl mx-auto py-6 px-4">
<div className="flex items-center justify-between mb-8">

View File

@@ -6,16 +6,15 @@ import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
ChevronRight,
Loader2,
AlertTriangle,
ChevronRight,
Edit,
ExternalLink,
Loader2,
MoreHorizontal,
Plus,
Search,
Filter,
Edit,
Trash,
MoreHorizontal,
Settings,
} from "lucide-react";
import { useEvents } from "@/context/event-context";
import { useGifts } from "@/context/gift-context";
@@ -45,9 +44,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GiftStatus, GiftPriority } from "@/client/types.gen";
import { GiftPriority, GiftPurchase, GiftStatus } from "@/client/types.gen";
import { CategoryModal } from "@/components/gifts/category-modal";
import { GiftModal } from "@/components/gifts/gift-modal";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useGuests } from "@/context/guest-context";
export default function GiftRegistryPage() {
const { slug } = useParams<{ slug: string }>();
@@ -57,13 +62,17 @@ export default function GiftRegistryPage() {
items,
isLoadingCategories,
isLoadingItems,
purchases,
error,
refetchCategories,
refetchItems,
currentEventId,
setCurrentEventId,
deleteItem,
fetchGuestsGiftPurchases,
} = useGifts();
const { guests } = useGuests();
// State for modals
const [isAddGiftModalOpen, setIsAddGiftModalOpen] = useState(false);
const [isEditGiftModalOpen, setIsEditGiftModalOpen] = useState(false);
@@ -78,6 +87,39 @@ export default function GiftRegistryPage() {
const [searchQuery, setSearchQuery] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [reservations, setReservations] = useState<
Record<string, GiftPurchase[]>
>({});
const [loadingReservations, setLoadingReservations] = useState<boolean>(true);
useEffect(() => {
const loadReservations = async () => {
if (currentEventId) {
setLoadingReservations(true);
try {
const data = await fetchGuestsGiftPurchases(currentEventId);
if (data) {
const groupedReservations = data.reduce(
(acc, purchase) => {
const giftId = purchase.gift_id;
if (!acc[giftId]) acc[giftId] = [];
acc[giftId].push(purchase);
return acc;
},
{} as Record<string, GiftPurchase[]>,
);
setReservations(groupedReservations);
}
} catch (err) {
console.error("Unable to fetch reservations:", err);
} finally {
setLoadingReservations(false);
}
}
};
loadReservations();
}, [currentEventId, fetchGuestsGiftPurchases]);
// Filter items based on search query and filters
const filteredItems = items
@@ -188,6 +230,11 @@ export default function GiftRegistryPage() {
setIsAddGiftModalOpen(true);
};
const handleDeleteGift = async (id: string) => {
await deleteItem(id);
await refetchItems(undefined, event?.id);
};
const handleEditGift = (giftId: string) => {
setSelectedGiftId(giftId);
setIsEditGiftModalOpen(true);
@@ -381,19 +428,94 @@ export default function GiftRegistryPage() {
const category = categories?.find(
(c) => c.id === item.category_id,
);
const canShowReservations = item.status
? (
[
GiftStatus.RESERVED,
GiftStatus.RECEIVED,
] as GiftStatus[]
).includes(item.status)
: false;
return (
<React.Fragment key={item.id}>
<TableRow>
<TableCell>
{category?.name || "Uncategorized"}
</TableCell>
<TableCell className="font-medium">
<TableCell className="flex items-center gap-2 font-medium">
{/* Purchase URL clickable icon */}
{item.purchase_url ? (
<Link
href={item.purchase_url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="w-4 h-4 text-blue-500 hover:text-blue-600" />
</Link>
) : (
<ExternalLink className="w-4 h-4 text-gray-300 cursor-not-allowed" />
)}
{item.name}
</TableCell>
<TableCell>{item.quantity_requested || 1}</TableCell>
<TableCell>{item.formatted_price || "-"}</TableCell>
<TableCell>{getPriorityBadge(item.priority)}</TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell>
{canShowReservations ? (
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer">
{getStatusBadge(item.status)}
</div>
</PopoverTrigger>
<PopoverContent className="text-sm w-[220px]">
{loadingReservations ? (
<div className="flex items-center justify-center p-2">
<Loader2
className="animate-spin"
size={16}
/>
<span className="ml-2">
Loading reservations...
</span>
</div>
) : reservations[item.id] &&
reservations[item.id].length > 0 ? (
<ul className="list-disc pl-4 py-2">
{reservations[item.id].map(
(purchase, index) => {
const guest = guests?.find(
(g) => g.id === purchase.guest_id,
);
return (
<li key={`${purchase.id}_${index}`}>
<strong>
{guest?.full_name ||
purchase.guest_id}
</strong>
: {purchase.quantity}
</li>
);
},
)}
</ul>
) : (
<p className="p-2 text-gray-500">
No reservations available.
</p>
)}
</PopoverContent>
</Popover>
) : (
getStatusBadge(item.status)
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -412,75 +534,28 @@ export default function GiftRegistryPage() {
<Edit className="h-4 w-4 mr-2" /> Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<DropdownMenuItem
className="text-red-600"
onClick={() => handleDeleteGift(item.id)}
>
<Trash className="h-4 w-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{/* Separate row for description */}
{/*{item.description && (*/}
<TableRow>
<TableCell
colSpan={7}
className="py-1 px-4 border-t-0 bg-gray-50 dark:bg-gray-800/50 text-sm text-gray-500 dark:text-gray-400"
className="py-1 px-4 bg-gray-50 dark:bg-gray-800/50 text-sm text-gray-500 dark:text-gray-400 h-8"
>
<div className="flex flex-col gap-1">
{/* Description - show if available */}
{item.description && <p>{item.description}</p>}
{/* Status-specific information */}
<div className="flex items-center gap-2">
{item.status === GiftStatus.AVAILABLE && (
<>
{item.store_name && (
<span>Store: {item.store_name}</span>
)}
{item.purchase_url && (
<>
{item.store_name && <span></span>}
<a
href={item.purchase_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Shop Link
</a>
</>
)}
{!item.store_name &&
!item.purchase_url &&
item.brand && (
<span>Brand: {item.brand}</span>
)}
{!item.store_name &&
!item.purchase_url &&
!item.brand && (
<span className={"italic"}>
No purchase information available
</span>
)}
</>
)}
{item.status === GiftStatus.RESERVED && (
<span>Reserved</span>
)}
{item.status === GiftStatus.PURCHASED && (
<span>Purchased</span>
)}
{item.status === GiftStatus.RECEIVED && (
<span>Received</span>
)}
</div>
</div>
{item.description}
</TableCell>
</TableRow>
{/*)}*/}
</React.Fragment>
);
})

View File

@@ -27,11 +27,13 @@ import {
import { useEventThemes } from "@/context/event-theme-context";
import { getServerFileUrl } from "@/lib/utils";
import GuestsList from "@/components/guests/guests-list";
import { useGuests } from "@/context/guest-context";
export default function EventDetailPage() {
const { slug } = useParams<{ slug: string }>();
const { event, fetchEventBySlug, isLoadingEvent, eventError } = useEvents();
const { themes } = useEventThemes();
const { refetchGuests } = useGuests();
const currentTheme =
event?.theme_id && themes
? themes.find((theme) => theme.id === event.theme_id)
@@ -44,6 +46,10 @@ export default function EventDetailPage() {
fetchEventBySlug(slug);
}, [slug, fetchEventBySlug]);
useEffect(() => {
refetchGuests();
}, []);
if (isLoadingEvent) {
return (
<div className="flex items-center justify-center min-h-[50vh]">

View File

@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
import Navbar from "@/components/layout/navbar";
import Breadcrumbs from "@/components/layout/breadcrumb";
import { useEvents } from "@/context/event-context";
export default function MainLayout({
children,
@@ -12,12 +13,18 @@ export default function MainLayout({
children: React.ReactNode;
}) {
const { isAuthenticated, isLoading } = useAuth();
const { refetchUpcomingEvents, refetchPublicEvents, refetchUserEvents } =
useEvents();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/login");
} else {
refetchUpcomingEvents();
refetchPublicEvents();
refetchUserEvents();
}
}, [isAuthenticated, isLoading, router]);

View File

@@ -48,6 +48,7 @@ import {
readGiftPurchase,
readGiftPurchasesByGift,
readGiftPurchasesByGuest,
readGuestsGiftReservations,
createEvent,
getUserEvents,
getUpcomingEvents,
@@ -165,6 +166,7 @@ import type {
ReadGiftPurchaseData,
ReadGiftPurchasesByGiftData,
ReadGiftPurchasesByGuestData,
ReadGuestsGiftReservationsData,
CreateEventData,
CreateEventError,
CreateEventResponse,
@@ -1421,6 +1423,27 @@ export const readGiftPurchasesByGuestOptions = (
});
};
export const readGuestsGiftReservationsQueryKey = (
options?: Options<ReadGuestsGiftReservationsData>,
) => createQueryKey("readGuestsGiftReservations", options);
export const readGuestsGiftReservationsOptions = (
options?: Options<ReadGuestsGiftReservationsData>,
) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await readGuestsGiftReservations({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: readGuestsGiftReservationsQueryKey(options),
});
};
export const createEventQueryKey = (options: Options<CreateEventData>) =>
createQueryKey("createEvent", options);

View File

@@ -2427,6 +2427,16 @@ export const GuestReadSchema = {
type: "string",
title: "Invitation Code",
},
rsvp: {
anyOf: [
{
$ref: "#/components/schemas/RSVPSchema",
},
{
type: "null",
},
],
},
},
type: "object",
required: [
@@ -2715,6 +2725,16 @@ export const PresignedUrlResponseSchema = {
export const RSVPSchemaSchema = {
properties: {
event_id: {
type: "string",
format: "uuid",
title: "Event Id",
},
guest_id: {
type: "string",
format: "uuid",
title: "Guest Id",
},
status: {
$ref: "#/components/schemas/RSVPStatus",
},
@@ -2771,12 +2791,30 @@ export const RSVPSchemaSchema = {
},
},
type: "object",
required: ["status", "id", "response_date", "created_at", "updated_at"],
required: [
"event_id",
"guest_id",
"status",
"id",
"response_date",
"created_at",
"updated_at",
],
title: "RSVPSchema",
} as const;
export const RSVPSchemaCreateSchema = {
properties: {
event_id: {
type: "string",
format: "uuid",
title: "Event Id",
},
guest_id: {
type: "string",
format: "uuid",
title: "Guest Id",
},
status: {
$ref: "#/components/schemas/RSVPStatus",
},
@@ -2811,19 +2849,9 @@ export const RSVPSchemaCreateSchema = {
additional_info: {
title: "Additional Info",
},
event_id: {
type: "string",
format: "uuid",
title: "Event Id",
},
guest_id: {
type: "string",
format: "uuid",
title: "Guest Id",
},
},
type: "object",
required: ["status", "event_id", "guest_id"],
required: ["event_id", "guest_id", "status"],
title: "RSVPSchemaCreate",
} as const;

View File

@@ -142,6 +142,9 @@ import type {
ReadGiftPurchasesByGuestData,
ReadGiftPurchasesByGuestResponse,
ReadGiftPurchasesByGuestError,
ReadGuestsGiftReservationsData,
ReadGuestsGiftReservationsResponse,
ReadGuestsGiftReservationsError,
CreateEventData,
CreateEventResponse,
CreateEventError,
@@ -1153,6 +1156,25 @@ export const readGiftPurchasesByGuest = <ThrowOnError extends boolean = false>(
});
};
/**
* Read All Guest Gift Reservations
* Retrieve all guest gift reservations, optionally filtered by event ID.
*/
export const readGuestsGiftReservations = <
ThrowOnError extends boolean = false,
>(
options?: Options<ReadGuestsGiftReservationsData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
ReadGuestsGiftReservationsResponse,
ReadGuestsGiftReservationsError,
ThrowOnError
>({
url: "/api/v1/events/gifts/reservations/guests",
...options,
});
};
/**
* Create Event
* Create a new event.

View File

@@ -370,6 +370,7 @@ export type GuestRead = {
actual_additional_guests: number;
is_blocked: boolean;
invitation_code: string;
rsvp?: RsvpSchema | null;
};
export type GuestStatus =
@@ -467,6 +468,8 @@ export type PresignedUrlResponse = {
};
export type RsvpSchema = {
event_id: string;
guest_id: string;
status: RsvpStatus;
number_of_guests?: number;
response_message?: string | null;
@@ -479,13 +482,13 @@ export type RsvpSchema = {
};
export type RsvpSchemaCreate = {
event_id: string;
guest_id: string;
status: RsvpStatus;
number_of_guests?: number;
response_message?: string | null;
dietary_requirements?: string | null;
additional_info?: unknown;
event_id: string;
guest_id: string;
};
export type RsvpSchemaUpdate = {
@@ -1874,6 +1877,38 @@ export type ReadGiftPurchasesByGuestResponses = {
export type ReadGiftPurchasesByGuestResponse =
ReadGiftPurchasesByGuestResponses[keyof ReadGiftPurchasesByGuestResponses];
export type ReadGuestsGiftReservationsData = {
body?: never;
path?: never;
query?: {
/**
* Optional event ID to filter reservations by event
*/
event_id?: string | null;
};
url: "/api/v1/events/gifts/reservations/guests";
};
export type ReadGuestsGiftReservationsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ReadGuestsGiftReservationsError =
ReadGuestsGiftReservationsErrors[keyof ReadGuestsGiftReservationsErrors];
export type ReadGuestsGiftReservationsResponses = {
/**
* Successful Response
*/
200: Array<GiftPurchase>;
};
export type ReadGuestsGiftReservationsResponse =
ReadGuestsGiftReservationsResponses[keyof ReadGuestsGiftReservationsResponses];
export type CreateEventData = {
body: EventCreate;
path?: never;

View File

@@ -39,7 +39,6 @@ import {
import { useGuests } from "@/context/guest-context";
import {
EventResponse,
EventThemeResponse,
GuestCreate,
GuestRead,
GuestStatus,
@@ -66,6 +65,12 @@ import {
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/context/auth-context";
import { generateInviteLink } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { StickyNote, Utensils } from "lucide-react";
// Helper to generate a random invitation code
const generateInvitationCode = (fullName: string): string => {
@@ -96,7 +101,7 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
full_name: "",
email: "",
phone: "",
max_additional_guests: 0,
max_additional_guests: 10,
dietary_restrictions: "",
notes: "",
can_bring_guests: true,
@@ -288,7 +293,7 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
"Phone",
"Invitation Code",
"Status",
"Additional Guests",
"Max Additional Guests",
];
const csvContent = [
headers.join(","),
@@ -343,6 +348,12 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
}
}, [addGuestOpen, editGuestOpen, currentGuest]);
const confirmedGuestCount =
guests?.filter((g) => g.status === GuestStatus.CONFIRMED).length || 0;
const confirmedAdditionalGuestsCount =
guests?.reduce((acc, g) => acc + (g.actual_additional_guests || 0), 0) || 0;
const totalConfirmedGuestsCount =
confirmedGuestCount + confirmedAdditionalGuestsCount;
return (
<div className="space-y-4 w-full">
{error && (
@@ -409,14 +420,14 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="max_additional_guests" className="text-right">
Additional Guests
Max Add. Guests
</Label>
<Input
id="max_additional_guests"
name="max_additional_guests"
type="number"
min="0"
value={formData.max_additional_guests || 0}
value={formData.max_additional_guests || 10}
onChange={handleInputChange}
className="col-span-3"
/>
@@ -522,10 +533,13 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
<TableHead>Phone</TableHead>
<TableHead>Invitation Code</TableHead>
<TableHead>Status</TableHead>
<TableHead>Additional Guests</TableHead>
<TableHead>Add. Guests</TableHead>
<TableHead>Diet Restr.</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingGuests ? (
<TableRow>
@@ -567,6 +581,57 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
</TableCell>
<TableCell>{getStatusBadge(guest.status)}</TableCell>
<TableCell>{guest.actual_additional_guests || 0}</TableCell>
{/* Dietary Restrictions Column */}
<TableCell>
{guest.rsvp?.dietary_requirements &&
guest.rsvp.dietary_requirements.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<Utensils className="h-4 w-4 text-green-600 cursor-pointer" />
</Button>
</PopoverTrigger>
<PopoverContent className="max-w-xs">
<p className="text-sm">
{guest.rsvp.dietary_requirements}
</p>
</PopoverContent>
</Popover>
) : (
"-"
)}
</TableCell>
{/* Notes Column */}
<TableCell>
{guest.rsvp?.response_message &&
guest.rsvp.response_message.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<StickyNote className="h-4 w-4 text-blue-600 cursor-pointer" />
</Button>
</PopoverTrigger>
<PopoverContent className="max-w-xs">
<p className="text-sm">
{guest.rsvp.response_message}
</p>
</PopoverContent>
</Popover>
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -626,14 +691,9 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
Showing {filteredGuests.length} of {guests?.length || 0} guests
</div>
<div>
Total Confirmed:{" "}
{guests?.filter((g) => g.status === GuestStatus.CONFIRMED).length ||
0}{" "}
| Total Additional Guests:{" "}
{guests?.reduce(
(acc, g) => acc + (g.max_additional_guests || 0),
0,
) || 0}
Guests Confirmed: {confirmedGuestCount} | Additional Guests:{" "}
{confirmedAdditionalGuestsCount} | Total Guests:{" "}
{totalConfirmedGuestsCount}
</div>
</div>
@@ -687,7 +747,7 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
htmlFor="edit_max_additional_guests"
className="text-right"
>
Additional Guests
Max Add. Guests
</Label>
<Input
id="edit_max_additional_guests"

View File

@@ -19,6 +19,7 @@ import { useSearchParams, useParams } from "next/navigation";
import { Loader2, Plus, Minus } from "lucide-react";
import { motion } from "framer-motion";
import { useTheme } from "next-themes";
import { useRSVPs } from "@/context/rsvp-context";
interface RSVPProps {
eventId: string;
@@ -32,6 +33,8 @@ export const RSVP: React.FC<RSVPProps> = ({ eventId, onRSVPSuccess }) => {
findGuestByInvitationCode,
submitGuestRsvp,
} = useGuests();
const { rsvps } = useRSVPs();
const searchParams = useSearchParams();
const { slug } = useParams<{ slug: string }>();
const { event, fetchEventBySlug, isLoadingEvent } = useEvents();
@@ -45,6 +48,9 @@ export const RSVP: React.FC<RSVPProps> = ({ eventId, onRSVPSuccess }) => {
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<boolean>(false);
const [currentGuest, setCurrentGuest] = useState<any | null>(null);
const [currentRSVP, setCurrentRSVP] = useState<any | null>(null);
const { setTheme } = useTheme();
useEffect(() => {
@@ -58,6 +64,21 @@ export const RSVP: React.FC<RSVPProps> = ({ eventId, onRSVPSuccess }) => {
}
}, [slug, fetchEventBySlug]);
useEffect(() => {
if (rsvps && currentGuest) {
setCurrentRSVP(rsvps.find((rsvp) => rsvp.guest_id === currentGuest?.id));
}
}, [rsvps, currentGuest]);
useEffect(() => {
if (currentRSVP) {
setStatus(currentRSVP.status);
setNumberOfGuests(currentRSVP.number_of_guests);
setResponseMessage(currentRSVP.response_message);
setDietaryRequirements(currentRSVP.dietary_requirements);
}
}, [currentRSVP]);
// Find the theme for this event
const eventTheme =
event && themes?.find((theme) => theme.id === event.theme_id);
@@ -114,7 +135,7 @@ export const RSVP: React.FC<RSVPProps> = ({ eventId, onRSVPSuccess }) => {
// Find the guest with matching invitation code
if (guests) {
const matchingGuest = findGuestByInvitationCode(invitationCode);
setCurrentGuest(matchingGuest);
if (matchingGuest) {
console.log("matchingGuest ", matchingGuest);
setGuestId(matchingGuest.id);
@@ -248,13 +269,12 @@ export const RSVP: React.FC<RSVPProps> = ({ eventId, onRSVPSuccess }) => {
if (success) {
return (
<motion.div
className="w-full rounded-lg border shadow-sm p-8 text-center"
className="w-full rounded-lg shadow-sm p-8 text-center"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
style={{
backgroundColor: colors.background,
borderColor: colors.primary,
color: colors.text,
}}
>
@@ -298,13 +318,12 @@ export const RSVP: React.FC<RSVPProps> = ({ eventId, onRSVPSuccess }) => {
return (
<motion.div
className="w-full rounded-lg border shadow-sm"
className="w-full rounded-lg shadow-sm"
initial="hidden"
animate="visible"
variants={formVariants}
style={{
backgroundColor: colors.background,
borderColor: colors.backgroundDark,
color: colors.text,
}}
>
@@ -466,12 +485,15 @@ export const RSVP: React.FC<RSVPProps> = ({ eventId, onRSVPSuccess }) => {
<motion.div className="flex justify-end mt-6" variants={itemVariants}>
<Button
type="submit"
className="w-full md:w-auto"
className="w-full md:w-auto text-white transition-colors duration-300"
disabled={isProcessing}
style={{
backgroundColor: colors.accent,
color: "white",
}}
style={{ backgroundColor: colors.accent }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = colors.accent2)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = colors.accent)
}
>
{isProcessing ? (
<>

View File

@@ -32,6 +32,11 @@ interface EventsContextState {
userEvents: PaginatedResponseEventResponse | undefined;
upcomingEvents: PaginatedResponseEventResponse | undefined;
publicEvents: PaginatedResponseEventResponse | undefined;
refetchUpcomingEvents: () => Promise<any>;
refetchPublicEvents: () => Promise<any>;
refetchUserEvents: () => Promise<any>;
isLoadingUserEvents: boolean;
isLoadingUpcomingEvents: boolean;
isLoadingPublicEvents: boolean;
@@ -67,6 +72,11 @@ const defaultEventsState: EventsContextState = {
userEvents: undefined,
upcomingEvents: undefined,
publicEvents: undefined,
refetchUpcomingEvents: async () => undefined,
refetchPublicEvents: async () => undefined,
refetchUserEvents: async () => undefined,
isLoadingUserEvents: false,
isLoadingUpcomingEvents: false,
isLoadingPublicEvents: false,
@@ -148,6 +158,7 @@ export const EventsProvider: React.FC<EventsProviderProps> = ({ children }) => {
data: userEvents,
isLoading: isLoadingUserEvents,
error: userEventsError,
refetch: refetchUserEvents,
} = useQuery({
...getUserEventsOptions(paginationParams),
});
@@ -157,6 +168,7 @@ export const EventsProvider: React.FC<EventsProviderProps> = ({ children }) => {
data: upcomingEvents,
isLoading: isLoadingUpcomingEvents,
error: upcomingEventsError,
refetch: refetchUpcomingEvents,
} = useQuery({
...getUpcomingEventsOptions(paginationParams),
});
@@ -166,6 +178,7 @@ export const EventsProvider: React.FC<EventsProviderProps> = ({ children }) => {
data: publicEvents,
isLoading: isLoadingPublicEvents,
error: publicEventsError,
refetch: refetchPublicEvents,
} = useQuery({
...getPublicEventsOptions(paginationParams),
});
@@ -255,6 +268,11 @@ export const EventsProvider: React.FC<EventsProviderProps> = ({ children }) => {
userEvents,
upcomingEvents,
publicEvents,
refetchPublicEvents,
refetchUpcomingEvents,
refetchUserEvents,
isLoadingUserEvents,
isLoadingUpcomingEvents,
isLoadingPublicEvents,

View File

@@ -24,6 +24,7 @@ import {
readGiftPurchase,
readGiftPurchasesByGift,
readGiftPurchasesByGuest,
readGuestsGiftReservations,
} from "@/client/sdk.gen";
import {
GiftCategory,
@@ -48,6 +49,10 @@ interface GiftContextState {
refetchCategories: (eventId: string) => Promise<any>;
fetchCategoryById: (id: string, eventId?: string) => void;
fetchGuestsGiftPurchases: (
eventId: string,
) => Promise<GiftPurchase[] | undefined>;
createCategory: (
data: GiftCategoryCreate,
) => Promise<GiftCategory | undefined>;
@@ -147,6 +152,8 @@ const defaultGiftContextState: GiftContextState = {
},
fetchCategoryById: () => {},
fetchGuestsGiftPurchases: async () => undefined,
createCategory: async () => {
throw new Error("GiftContext not initialized");
},
@@ -310,6 +317,20 @@ export const GiftProvider: React.FC<GiftProviderProps> = ({ children }) => {
}
};
const fetchGuestsGiftPurchases = async (
eventId: string,
): Promise<GiftPurchase[] | undefined> => {
try {
const result = await readGuestsGiftReservations({
query: { event_id: eventId },
});
return result.data;
} catch (error) {
console.error("Error fetching guests' gift purchases:", error);
throw error;
}
};
// Create Category Mutation
const createCategoryMutation = useMutation({
mutationFn: (data: GiftCategoryCreate) =>
@@ -771,7 +792,7 @@ export const GiftProvider: React.FC<GiftProviderProps> = ({ children }) => {
isLoadingCategories,
isLoadingCategory,
refetchCategories,
fetchGuestsGiftPurchases,
fetchCategoryById,
createCategory: createCategoryMutation.mutateAsync,
updateCategory: (id, data, eventId) =>