From e5eb70c64119132055391307038064a38e33c7c5 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 00:41:20 +0530 Subject: [PATCH 01/51] Refactor discount handling: replace DiscountParsed with DiscountRequest across components and implement discount CRUD actions in discountActions.ts --- .../_components/discounts/discount-card.tsx | 3 +- .../discounts/discount-code-form.tsx | 8 +- .../_components/discounts/discount-step.tsx | 6 +- .../discounts/full-discount-form-view.tsx | 6 +- .../discounts/review-discounts.tsx | 4 +- src/lib/actions/discountActions.ts | 89 +++++++++++++++++++ src/lib/validators/event.ts | 2 +- 7 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 src/lib/actions/discountActions.ts diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index f749584..3b09440 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -4,14 +4,13 @@ import {Card, CardContent, CardHeader} from "@/components/ui/card" import {Badge} from "@/components/ui/badge" import {Button} from "@/components/ui/button" import {Switch} from "@/components/ui/switch" -import {formatCurrency} from "@/lib/utils" import { Edit, Trash2, Copy, Eye, EyeOff, Percent, DollarSign, Gift, Calendar, Users, MoreHorizontal, } from "lucide-react" import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu" import {DiscountType} from "@/types/enums/discountType"; -import {CreateEventFormData, DiscountParsed} from "@/lib/validators/event"; +import {CreateEventFormData} from "@/lib/validators/event"; import {FieldArrayWithId} from "react-hook-form"; import {toast} from "sonner"; import {format} from "date-fns"; diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx index abce7f2..85d2050 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx @@ -12,7 +12,7 @@ import {Switch} from "@/components/ui/switch" import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" import {CalendarIcon, Percent, DollarSign, Gift} from "lucide-react" import { - DiscountParsed, + DiscountRequest, discountSchema, SessionParsed, TierParsed @@ -26,10 +26,10 @@ import {formatToDateTimeLocalString} from "@/lib/utils"; interface DiscountCodeFormProps { tiers: TierParsed[], sessions: SessionParsed[], - onSave: (discount: DiscountParsed) => void, + onSave: (discount: DiscountRequest) => void, isQuickCreate?: boolean, isEditing?: boolean, - initialData?: DiscountParsed, + initialData?: DiscountRequest, } type PercentageParams = { @@ -97,7 +97,7 @@ export function DiscountCodeForm({ return; } - onSave(validatedResult.data as DiscountParsed) + onSave(validatedResult.data as DiscountRequest) // ✅ UX IMPROVEMENT: Reset the local form for the next entry form.reset({ diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx index 18626b9..599003f 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx @@ -2,7 +2,7 @@ import { DiscountList } from "./discount-list"; import { useFieldArray, useFormContext } from "react-hook-form"; -import {CreateEventFormData, DiscountParsed, discountSchema} from "@/lib/validators/event"; +import {CreateEventFormData, DiscountRequest, discountSchema} from "@/lib/validators/event"; import {useEffect, useState} from "react"; import { FullDiscountFormView } from "./full-discount-form-view"; import { Button } from "@/components/ui/button"; @@ -37,13 +37,13 @@ export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { // --- Event Handlers --- - const handleAddDiscount = (discount: DiscountParsed) => { + const handleAddDiscount = (discount: DiscountRequest) => { append(discount); setView('list'); } // ✅ New handler for updating an existing discount - const handleUpdateDiscount = (index: number, discount: DiscountParsed) => { + const handleUpdateDiscount = (index: number, discount: DiscountRequest) => { update(index, discount); setView('list'); setEditingIndex(null); diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx index e3005a9..63d28b4 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx @@ -3,15 +3,15 @@ import { Button } from "@/components/ui/button" import { ChevronLeft } from "lucide-react" import { DiscountCodeForm } from "./discount-code-form" -import { DiscountParsed, SessionParsed, TierParsed} from "@/lib/validators/event"; +import { DiscountRequest, SessionParsed, TierParsed} from "@/lib/validators/event"; interface FullDiscountFormViewProps { tiers: TierParsed[], sessions: SessionParsed[], - onSave: (discount: DiscountParsed) => void, + onSave: (discount: DiscountRequest) => void, onBack: () => void, isEditing?: boolean, - initialData?: DiscountParsed, + initialData?: DiscountRequest, } export function FullDiscountFormView({ tiers, sessions, onSave, onBack, isEditing, initialData }: FullDiscountFormViewProps) { return ( diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx index b8c263d..3b014f3 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx @@ -1,14 +1,14 @@ 'use client'; import * as React from 'react'; -import {CreateEventFormData, DiscountParsed} from '@/lib/validators/event'; +import {CreateEventFormData, DiscountRequest} from '@/lib/validators/event'; import {FieldArrayWithId} from "react-hook-form"; import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; interface DiscountReviewProps { tiers: FieldArrayWithId[], sessions?: FieldArrayWithId[], - discounts?: DiscountParsed[], + discounts?: DiscountRequest[], } export const DiscountReview: React.FC = ({tiers, sessions, discounts}) => { diff --git a/src/lib/actions/discountActions.ts b/src/lib/actions/discountActions.ts new file mode 100644 index 0000000..a75ed79 --- /dev/null +++ b/src/lib/actions/discountActions.ts @@ -0,0 +1,89 @@ +import { apiFetch } from '@/lib/api'; +import { PaginatedResponse } from "@/types/paginatedResponse"; +import {DiscountRequest} from "@/lib/validators/event"; +import {DiscountDTO} from "@/types/event"; + +const API_BASE_PATH = '/event-seating/v1/events'; + +/** + * Creates a new discount for an event. + * + * @param eventId ID of the event to create the discount for + * @param discountData The discount data to create + * @returns The created discount details + */ +export const createDiscount = (eventId: string, discountData: DiscountRequest): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/discounts`, { + method: 'POST', + body: JSON.stringify(discountData), + }); +}; + +/** + * Fetches a list of discounts for an event. + * + * @param eventId ID of the event + * @param includePrivate Whether to include private discounts (default: false) + * @param page Page number (default: 0) + * @param size Page size (default: 10) + * @returns Paginated list of discounts + */ +export const getDiscounts = ( + eventId: string, + includePrivate: boolean = false, + page: number = 0, + size: number = 10 +): Promise> => { + const params = new URLSearchParams({ + includePrivate: includePrivate.toString(), + page: page.toString(), + size: size.toString(), + }); + + return apiFetch>( + `${API_BASE_PATH}/${eventId}/discounts?${params.toString()}` + ); +}; + +/** + * Fetches a specific discount for an event. + * + * @param eventId ID of the event + * @param discountId ID of the discount to fetch + * @returns The discount details + */ +export const getDiscount = (eventId: string, discountId: string): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/discounts/${discountId}`); +}; + +/** + * Updates an existing discount for an event. + * + * @param eventId ID of the event + * @param discountId ID of the discount to update + * @param discountData The updated discount data + * @returns The updated discount details + */ +export const updateDiscount = ( + eventId: string, + discountId: string, + discountData: DiscountRequest +): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/discounts/${discountId}`, { + method: 'PUT', + body: JSON.stringify(discountData), + }); +}; + +/** + * Deletes a discount for an event. + * + * @param eventId ID of the event + * @param discountId ID of the discount to delete + * @returns void as the endpoint returns no content + */ +export const deleteDiscount = (eventId: string, discountId: string): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/discounts/${discountId}`, { + method: 'DELETE', + }); +}; diff --git a/src/lib/validators/event.ts b/src/lib/validators/event.ts index 9200d1a..978c95f 100644 --- a/src/lib/validators/event.ts +++ b/src/lib/validators/event.ts @@ -329,7 +329,7 @@ export type Seat = z.infer; export type SessionSeatingMapRequest = z.infer; export type Row = z.infer; export type DiscountFormData = z.input; -export type DiscountParsed = z.infer; +export type DiscountRequest = z.infer; export type DiscountParameters = z.infer; From 81e8c3b6afded33a2e2c6ab6d0aeedd9f9510711 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 00:49:36 +0530 Subject: [PATCH 02/51] Refactor event-related types: replace parsed types with request types for discounts, sessions, and tiers across components --- src/app/manage/_components/review/ReviewSessions.tsx | 8 ++++---- src/app/manage/_components/review/SeatingInformation.tsx | 4 ++-- src/app/manage/_components/review/SeatingLayout.tsx | 4 ++-- .../event/_components/discounts/discount-card.tsx | 9 ++++----- .../event/_components/discounts/discount-code-form.tsx | 8 ++++---- .../event/_components/discounts/discount-list.tsx | 9 ++++----- .../_components/discounts/full-discount-form-view.tsx | 6 +++--- .../event/_components/discounts/review-discounts.tsx | 7 +++---- src/lib/actions/eventActions.ts | 4 ++-- src/lib/utils.ts | 6 +++--- src/lib/validators/event.ts | 6 +++--- 11 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/app/manage/_components/review/ReviewSessions.tsx b/src/app/manage/_components/review/ReviewSessions.tsx index 19a4584..4323e76 100644 --- a/src/app/manage/_components/review/ReviewSessions.tsx +++ b/src/app/manage/_components/review/ReviewSessions.tsx @@ -3,7 +3,7 @@ import {format, parseISO} from 'date-fns'; import {Calendar, Clock, LinkIcon, MapPin, Tag} from 'lucide-react'; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger,} from "@/components/ui/accordion"; import {Badge} from '@/components/ui/badge'; -import {SessionParsed, TierFormData} from '@/lib/validators/event'; +import {SessionRequest, TierFormData} from '@/lib/validators/event'; import dynamic from "next/dynamic"; import {SessionType} from "@/types/enums/sessionType"; @@ -13,7 +13,7 @@ const SeatingInformation = dynamic( ); interface ReviewSessionsProps { - sessions: SessionParsed[]; + sessions: SessionRequest[]; tiers: TierFormData[]; } @@ -38,7 +38,7 @@ export const ReviewSessions: React.FC = ({sessions, tiers}) }; interface SessionAccordionItemProps { - session: SessionParsed; + session: SessionRequest; tiers: TierFormData[]; index: number; } @@ -94,7 +94,7 @@ const SessionAccordionItem: React.FC = ({session, ind }; interface SessionDetailsProps { - session: SessionParsed; + session: SessionRequest; } const SessionDetails: React.FC = ({session}) => { diff --git a/src/app/manage/_components/review/SeatingInformation.tsx b/src/app/manage/_components/review/SeatingInformation.tsx index b52f97e..ae2a92b 100644 --- a/src/app/manage/_components/review/SeatingInformation.tsx +++ b/src/app/manage/_components/review/SeatingInformation.tsx @@ -1,6 +1,6 @@ "use client"; -import { SessionParsed, TierFormData} from "@/lib/validators/event"; +import { SessionRequest, TierFormData} from "@/lib/validators/event"; import * as React from "react"; import {MapContainer, TileLayer, Marker, Popup} from "react-leaflet"; import {Armchair, Users} from "lucide-react"; @@ -11,7 +11,7 @@ import L, {LatLngTuple} from "leaflet"; interface SeatingInformationProps { isOnline: boolean; - session: SessionParsed; + session: SessionRequest; tiers: TierFormData[]; } diff --git a/src/app/manage/_components/review/SeatingLayout.tsx b/src/app/manage/_components/review/SeatingLayout.tsx index a68d4e1..dc08283 100644 --- a/src/app/manage/_components/review/SeatingLayout.tsx +++ b/src/app/manage/_components/review/SeatingLayout.tsx @@ -2,12 +2,12 @@ import * as React from "react"; import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; import {Button} from "@/components/ui/button"; import {Badge} from "@/components/ui/badge"; -import {SessionParsed, TierFormData} from "@/lib/validators/event"; +import {SessionRequest, TierFormData} from "@/lib/validators/event"; import {getTierColor, getTierName} from "@/lib/utils"; import {SessionType} from "@/types/enums/sessionType"; interface SeatingLayoutProps { - session: SessionParsed; + session: SessionRequest; tiers: TierFormData[]; } diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index 3b09440..9c29031 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -10,18 +10,17 @@ import { } from "lucide-react" import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu" import {DiscountType} from "@/types/enums/discountType"; -import {CreateEventFormData} from "@/lib/validators/event"; -import {FieldArrayWithId} from "react-hook-form"; +import {DiscountRequest, SessionRequest, TierRequest} from "@/lib/validators/event"; import {toast} from "sonner"; import {format} from "date-fns"; import {getDiscountValue} from "@/lib/discountUtils"; // --- Component Props --- interface DiscountCardProps { - discount: FieldArrayWithId, + discount: DiscountRequest, index: number, - tiers: FieldArrayWithId[], - sessions?: FieldArrayWithId[], + tiers: TierRequest[], + sessions?: SessionRequest[], onDelete?: (index: number) => void, onToggleStatus?: (index: number) => void, onEdit?: (index: number) => void, diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx index 85d2050..eef36fd 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx @@ -14,8 +14,8 @@ import {CalendarIcon, Percent, DollarSign, Gift} from "lucide-react" import { DiscountRequest, discountSchema, - SessionParsed, - TierParsed + SessionRequest, + TierRequest } from "@/lib/validators/event" import {TierSelector} from "./tier-selector" import {SessionSelector} from "./session-selector" @@ -24,8 +24,8 @@ import {toast} from "sonner"; import {formatToDateTimeLocalString} from "@/lib/utils"; interface DiscountCodeFormProps { - tiers: TierParsed[], - sessions: SessionParsed[], + tiers: TierRequest[], + sessions: SessionRequest[], onSave: (discount: DiscountRequest) => void, isQuickCreate?: boolean, isEditing?: boolean, diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx index efd41a6..2646a8a 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx @@ -6,14 +6,13 @@ import {Input} from "@/components/ui/input" import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" import {Search, Filter} from "lucide-react" import {DiscountType} from "@/types/enums/discountType"; -import {CreateEventFormData} from "@/lib/validators/event"; -import {FieldArrayWithId} from "react-hook-form"; +import {DiscountRequest, SessionRequest, TierRequest} from "@/lib/validators/event"; import {DiscountCard} from "./discount-card"; // ✅ Import the new component interface DiscountListProps { - tiers: FieldArrayWithId[], - sessions?: FieldArrayWithId[], - discounts?: FieldArrayWithId[], + tiers: TierRequest[], + sessions?: SessionRequest[], + discounts?: DiscountRequest[], onDelete?: (index: number) => void, onToggleStatus?: (index: number) => void, onEdit?: (index: number) => void, diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx index 63d28b4..ff26b8a 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx @@ -3,11 +3,11 @@ import { Button } from "@/components/ui/button" import { ChevronLeft } from "lucide-react" import { DiscountCodeForm } from "./discount-code-form" -import { DiscountRequest, SessionParsed, TierParsed} from "@/lib/validators/event"; +import { DiscountRequest, SessionRequest, TierRequest} from "@/lib/validators/event"; interface FullDiscountFormViewProps { - tiers: TierParsed[], - sessions: SessionParsed[], + tiers: TierRequest[], + sessions: SessionRequest[], onSave: (discount: DiscountRequest) => void, onBack: () => void, isEditing?: boolean, diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx index 3b014f3..3885fe1 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx @@ -1,13 +1,12 @@ 'use client'; import * as React from 'react'; -import {CreateEventFormData, DiscountRequest} from '@/lib/validators/event'; -import {FieldArrayWithId} from "react-hook-form"; +import {DiscountRequest, SessionRequest, TierRequest} from '@/lib/validators/event'; import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; interface DiscountReviewProps { - tiers: FieldArrayWithId[], - sessions?: FieldArrayWithId[], + tiers: TierRequest[], + sessions?: SessionRequest[], discounts?: DiscountRequest[], } diff --git a/src/lib/actions/eventActions.ts b/src/lib/actions/eventActions.ts index 9c320be..a6069db 100644 --- a/src/lib/actions/eventActions.ts +++ b/src/lib/actions/eventActions.ts @@ -1,5 +1,5 @@ import {apiFetch} from '@/lib/api'; -import {CreateEventParsed} from '@/lib/validators/event'; +import {CreateEventRequest} from '@/lib/validators/event'; import {EventDetailDTO, EventStatus, EventSummaryDTO} from '@/lib/validators/event'; import {PaginatedResponse} from "@/types/paginatedResponse"; @@ -14,7 +14,7 @@ const API_BASE_PATH = '/event-seating/v1/events'; /** * Creates a new event. */ -export const createEvent = (eventData: CreateEventParsed, coverImages: File[]): Promise => { +export const createEvent = (eventData: CreateEventRequest, coverImages: File[]): Promise => { const formData = new FormData(); formData.append('request', JSON.stringify(eventData)); if (coverImages?.length > 0) { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c9c637a..4697e13 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,12 +1,12 @@ import {type ClassValue, clsx} from "clsx" import {twMerge} from "tailwind-merge" -import {SessionParsed, TierFormData} from "@/lib/validators/event"; +import {SessionRequest, TierFormData} from "@/lib/validators/event"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export const getTierColor = (tierId: string, session: SessionParsed, tiers: TierFormData[]): string => { +export const getTierColor = (tierId: string, session: SessionRequest, tiers: TierFormData[]): string => { if (tierId === 'unassigned') return '#d1d5db'; // gray-300 // We need to check if tiers exist in the session @@ -14,7 +14,7 @@ export const getTierColor = (tierId: string, session: SessionParsed, tiers: Tier return tier?.color || '#6b7280'; // gray-500 as fallback }; // Helper to get tier name -export const getTierName = (tierId: string, session: SessionParsed, tiers: TierFormData[]): string => { +export const getTierName = (tierId: string, session: SessionRequest, tiers: TierFormData[]): string => { if (tierId === 'unassigned') return 'Unassigned'; const tier = tiers.find(t => t.id === tierId); diff --git a/src/lib/validators/event.ts b/src/lib/validators/event.ts index 978c95f..0b9dc2d 100644 --- a/src/lib/validators/event.ts +++ b/src/lib/validators/event.ts @@ -315,14 +315,14 @@ export const finalCreateEventSchema = step5Schema; // --- Type Inference --- export type CreateEventFormData = z.input; -export type CreateEventParsed = z.infer; +export type CreateEventRequest = z.infer; export type SessionBasicData = z.infer; export type SessionWithVenueData = z.infer; export type SessionWithSeatingData = z.infer; export type SessionFormData = z.input; -export type SessionParsed = z.infer; +export type SessionRequest = z.infer; export type TierFormData = z.input; -export type TierParsed = z.infer; +export type TierRequest = z.infer; export type VenueDetails = z.infer; export type Block = z.infer; export type Seat = z.infer; From ca9b9167042d5c349095b010ab7fbd039ee8079b Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 00:59:25 +0530 Subject: [PATCH 03/51] Add EventProvider for managing event and organization data across components --- .../event/[eventId]/analytics/page.tsx | 180 +++++++++++------- .../event/[eventId]/layout.tsx | 67 +++---- .../event/[eventId]/page.tsx | 50 +---- .../event/[eventId]/sessions/page.tsx | 34 +++- src/providers/EventProvider.tsx | 83 ++++++++ 5 files changed, 266 insertions(+), 148 deletions(-) create mode 100644 src/providers/EventProvider.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/page.tsx index 35b3679..d0b0e06 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/page.tsx @@ -1,6 +1,6 @@ "use client" -import {useEffect, useState} from "react"; +import {useEffect, useState, useCallback} from "react"; import {EventAnalytics, SessionSummary} from "@/types/eventAnalytics"; import {getBatchedGaInsights} from "@/lib/actions/public/server/eventActions"; import {EventAnalyticsView} from "./_components/EventAnalyticsView"; @@ -9,85 +9,112 @@ import {DataTable} from "@/components/DataTable"; import {Skeleton} from "@/components/ui/skeleton"; import {getAllSessionsAnalytics, getEventAnalytics} from "@/lib/actions/public/analyticsActions"; import { AlertTriangle } from "lucide-react"; -import {useParams} from "next/navigation"; - +import { useEventContext } from "@/providers/EventProvider"; +import { Button } from "@/components/ui/button"; +import { RefreshCw } from "lucide-react"; export default function AnalyticsPage() { + const { event, isLoading: isEventLoading } = useEventContext(); const [analyticsData, setAnalyticsData] = useState(null); const [sessions, setSessions] = useState([]); - const [isEventLoading, setIsEventLoading] = useState(true); + const [isAnalyticsLoading, setIsAnalyticsLoading] = useState(true); const [isGaLoading, setIsGaLoading] = useState(true); const [error, setError] = useState(null); - const params = useParams(); - const eventId = params.eventId as string; - // Fetch core event data - useEffect(() => { - const fetchEventData = async () => { - try { - setIsEventLoading(true); - const [coreData, sessionsData] = await Promise.all([ - getEventAnalytics(eventId), - getAllSessionsAnalytics(eventId), - ]); - - setAnalyticsData(coreData); - setSessions(sessionsData); - } catch (err) { - setError("Failed to load event analytics data."); - console.error(err); - } finally { - setIsEventLoading(false); - } - }; + // Fetch analytics data (page-specific) + const fetchAnalyticsData = useCallback(async () => { + if (!event?.id) return; - fetchEventData(); - }, [eventId]); + try { + setIsAnalyticsLoading(true); + setError(null); - // Fetch Google Analytics data separately - useEffect(() => { - const fetchGaData = async () => { - try { - setIsGaLoading(true); - const gaResult = await getBatchedGaInsights(eventId); - - if (gaResult.success && gaResult.data) { - // Update analyticsData with GA insights if core data is already loaded - setAnalyticsData(prevData => { - if (!prevData) return null; - - // Calculate conversion rate - const totalViews = gaResult.data?.totalViews; - const conversionRate = totalViews && totalViews > 0 - ? (prevData.totalTicketsSold / totalViews) * 100 - : 0; - - return { - ...prevData, - pageViews: totalViews, - conversionRate, - viewsTimeSeries: gaResult.data?.viewsTimeSeries, - trafficSources: gaResult.data?.trafficSources, - audienceGeography: gaResult.data?.audienceGeography, - deviceBreakdown: gaResult.data?.deviceBreakdown - }; - }); - } - } catch (err) { - console.error("Failed to load Google Analytics data:", err); - // We don't set the main error state here since GA data is supplementary - } finally { - setIsGaLoading(false); + const [coreData, sessionsData] = await Promise.all([ + getEventAnalytics(event.id), + getAllSessionsAnalytics(event.id), + ]); + + setAnalyticsData(coreData); + setSessions(sessionsData); + } catch (err) { + setError("Failed to load event analytics data."); + console.error(err); + } finally { + setIsAnalyticsLoading(false); + } + }, [event?.id]); + + // Fetch Google Analytics data (page-specific) + const fetchGaData = useCallback(async () => { + if (!event?.id) return; + + try { + setIsGaLoading(true); + + const gaResult = await getBatchedGaInsights(event.id); + + if (gaResult.success && gaResult.data) { + // Update analyticsData with GA insights if core data is already loaded + setAnalyticsData(prevData => { + if (!prevData) return null; + + // Calculate conversion rate + const totalViews = gaResult.data?.totalViews; + const conversionRate = totalViews && totalViews > 0 + ? (prevData.totalTicketsSold / totalViews) * 100 + : 0; + + return { + ...prevData, + pageViews: totalViews, + conversionRate, + viewsTimeSeries: gaResult.data?.viewsTimeSeries, + trafficSources: gaResult.data?.trafficSources, + audienceGeography: gaResult.data?.audienceGeography, + deviceBreakdown: gaResult.data?.deviceBreakdown + }; + }); } - }; + } catch (err) { + console.error("Failed to load Google Analytics data:", err); + // We don't set the main error state here since GA data is supplementary + } finally { + setIsGaLoading(false); + } + }, [event?.id]); + // Function to refresh all analytics data + const refreshAllData = useCallback(() => { + fetchAnalyticsData(); fetchGaData(); - }, [eventId]); + }, [fetchAnalyticsData, fetchGaData]); + + // Initial load of analytics data when event is loaded + useEffect(() => { + if (event?.id) { + fetchAnalyticsData(); + fetchGaData(); + } + }, [event?.id, fetchAnalyticsData, fetchGaData]); + + if (isEventLoading) { + return
; + } + + if (!event) { + return ( +
+
+ +
+

Event not found

+
+ ); + } if (error) { return ( -
+
@@ -99,22 +126,33 @@ export default function AnalyticsPage() { ); } - // Show skeleton while core event data is loading - if (isEventLoading || !analyticsData) { + // Show skeleton while analytics data is loading + if (isAnalyticsLoading || !analyticsData) { return
; } return (
-
-

{analyticsData.eventTitle}

-

Overall Event Analytics Dashboard

+
+
+

{analyticsData.eventTitle}

+

Overall Event Analytics Dashboard

+
+
- +
); } diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/layout.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/layout.tsx index 59181e0..dbfa1fb 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/layout.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/layout.tsx @@ -9,6 +9,7 @@ import { CalendarCheck, Settings, } from "lucide-react"; +import { EventProvider } from "@/providers/EventProvider"; export default function EventDetailsLayout({ children, @@ -57,38 +58,40 @@ export default function EventDetailsLayout({ ]; return ( -
- - - {tabItems.map((tab) => { - const Icon = tab.icon; - return ( - - - {tab.name} - - ); - })} - - + +
+ + + {tabItems.map((tab) => { + const Icon = tab.icon; + return ( + + + {tab.name} + + ); + })} + + -
{children}
-
+
{children}
+
+ ); } diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/page.tsx index 9a845f1..04512ee 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/page.tsx @@ -1,54 +1,14 @@ "use client"; import * as React from "react"; -import {useState, useEffect} from "react"; -import {useParams} from "next/navigation"; -import {toast} from "sonner"; -import {getMyEventById} from "@/lib/actions/eventActions"; -import {EventDetailDTO} from "@/lib/validators/event"; import {Skeleton} from "@/components/ui/skeleton"; import {EventStatusTracker} from "../_components/EventStatusTracker"; import {EventPreview} from "@/app/manage/_components/review/EventPreview"; import {Separator} from "@/components/ui/separator"; -import {getMyOrganizationById} from "@/lib/actions/organizationActions"; -import {OrganizationResponse} from "@/types/oraganizations"; +import {useEventContext} from "@/providers/EventProvider"; export default function EventDetailsPage() { - const params = useParams(); - const eventId = params.eventId as string; - - const [event, setEvent] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [organization, setOrganization] = useState(null); - - // Load event data - useEffect(() => { - console.log("Fetching event data for ID:", eventId); - if (eventId) { - setIsLoading(true); - getMyEventById(eventId) - .then((eventData) => { - setEvent(eventData); - // After we have event data, fetch organization details - if (eventData && eventData.organizationId) { - return getMyOrganizationById(eventData.organizationId); - } - return null; - }) - .then((orgData) => { - if (orgData) { - setOrganization(orgData); - } - }) - .catch((error) => { - console.error("Error fetching data:", error); - toast.error(error.message || "Failed to load event details."); - }) - .finally(() => { - setIsLoading(false); - }); - } - }, [eventId]); // Only depend on eventId, not the event state itself + const {event, organization, isLoading, error} = useEventContext(); if (isLoading) { return ( @@ -59,9 +19,11 @@ export default function EventDetailsPage() { ); } - if (!organization || !event) { + if (error || !organization || !event) { return ( -
Event Not Found
+
+ {error || "Event Not Found"} +
); } diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx index 4c4cdbc..20b3cca 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx @@ -1,12 +1,44 @@ "use client"; import * as React from "react"; +import { useEventContext } from "@/providers/EventProvider"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AlertTriangle } from "lucide-react"; export default function EventSessionsPage() { + const { event, isLoading, error } = useEventContext(); + + if (isLoading) { + return ( +
+
+ + + +
+
+ ); + } + + if (error || !event) { + return ( +
+
+ +
+

+ {error || "Event not found"} +

+
+ ); + } + return (
-

Event Sessions

+

+ Event Sessions for {event.title} +

Manage your event sessions and scheduling here.

diff --git a/src/providers/EventProvider.tsx b/src/providers/EventProvider.tsx new file mode 100644 index 0000000..1fbabd1 --- /dev/null +++ b/src/providers/EventProvider.tsx @@ -0,0 +1,83 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import { getMyEventById } from "@/lib/actions/eventActions"; +import { getMyOrganizationById } from "@/lib/actions/organizationActions"; +import { EventDetailDTO } from "@/lib/validators/event"; +import { OrganizationResponse } from "@/types/oraganizations"; +import { toast } from "sonner"; + +interface EventContextProps { + event: EventDetailDTO | null; + organization: OrganizationResponse | null; + isLoading: boolean; + error: string | null; + refetchEventData: () => Promise; +} + +const EventContext = createContext(undefined); + +export const useEventContext = () => { + const context = useContext(EventContext); + + if (context === undefined) { + throw new Error("useEventContext must be used within an EventProvider"); + } + + return context; +}; + +interface EventProviderProps { + children: ReactNode; + eventId: string; +} + +export const EventProvider = ({ children, eventId }: EventProviderProps) => { + const [event, setEvent] = useState(null); + const [organization, setOrganization] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchEventData = useCallback(async () => { + if (!eventId) { + setError("No event ID provided"); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setError(null); + + const eventData = await getMyEventById(eventId); + setEvent(eventData); + + if (eventData && eventData.organizationId) { + const orgData = await getMyOrganizationById(eventData.organizationId); + setOrganization(orgData); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to load event details"; + setError(errorMessage); + toast.error(errorMessage); + console.error("Error fetching event data:", error); + } finally { + setIsLoading(false); + } + }, [eventId]); + + // Initial fetch + React.useEffect(() => { + fetchEventData(); + }, [fetchEventData]); + + const value = { + event, + organization, + isLoading, + error, + refetchEventData: fetchEventData + }; + + return {children}; +}; From 717122cf7b0736d98680cb861b42b21494ecfe83 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 01:04:50 +0530 Subject: [PATCH 04/51] Refactor EventProvider: integrate organization context and switch organizations based on event data --- src/providers/EventProvider.tsx | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/providers/EventProvider.tsx b/src/providers/EventProvider.tsx index 1fbabd1..4f729cd 100644 --- a/src/providers/EventProvider.tsx +++ b/src/providers/EventProvider.tsx @@ -2,10 +2,10 @@ import React, { createContext, useContext, useState, useCallback, ReactNode } from "react"; import { getMyEventById } from "@/lib/actions/eventActions"; -import { getMyOrganizationById } from "@/lib/actions/organizationActions"; import { EventDetailDTO } from "@/lib/validators/event"; import { OrganizationResponse } from "@/types/oraganizations"; import { toast } from "sonner"; +import { useOrganization } from "@/providers/OrganizationProvider"; interface EventContextProps { event: EventDetailDTO | null; @@ -34,9 +34,9 @@ interface EventProviderProps { export const EventProvider = ({ children, eventId }: EventProviderProps) => { const [event, setEvent] = useState(null); - const [organization, setOrganization] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const { organizations, organization: currentOrganization, switchOrganization } = useOrganization(); const fetchEventData = useCallback(async () => { if (!eventId) { @@ -52,9 +52,11 @@ export const EventProvider = ({ children, eventId }: EventProviderProps) => { const eventData = await getMyEventById(eventId); setEvent(eventData); - if (eventData && eventData.organizationId) { - const orgData = await getMyOrganizationById(eventData.organizationId); - setOrganization(orgData); + // If the event belongs to a different organization than the current one, + // switch to the event's organization + if (eventData && eventData.organizationId && + currentOrganization && eventData.organizationId !== currentOrganization.id) { + await switchOrganization(eventData.organizationId); } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Failed to load event details"; @@ -64,16 +66,22 @@ export const EventProvider = ({ children, eventId }: EventProviderProps) => { } finally { setIsLoading(false); } - }, [eventId]); + }, [eventId, currentOrganization, switchOrganization]); // Initial fetch React.useEffect(() => { fetchEventData(); }, [fetchEventData]); + // Find the correct organization for this event + const eventOrganization = React.useMemo(() => { + if (!event || !event.organizationId || !organizations) return null; + return organizations.find(org => org.id === event.organizationId) || null; + }, [event, organizations]); + const value = { event, - organization, + organization: eventOrganization, isLoading, error, refetchEventData: fetchEventData From fea71c89978e48a049f30bc7b50574e13d9de774 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 02:19:40 +0530 Subject: [PATCH 05/51] Pre implemeted discount crud --- .../event/[eventId]/discounts/page.tsx | 180 ++++++++++++++++++ .../event/[eventId]/layout.tsx | 154 ++++++++------- .../_components/discounts/discount-step.tsx | 4 +- src/lib/actions/discountActions.ts | 20 +- 4 files changed, 273 insertions(+), 85 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx new file mode 100644 index 0000000..593eae2 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import {useState, useEffect} from "react"; +import {useEventContext} from "@/providers/EventProvider"; +import {Skeleton} from "@/components/ui/skeleton"; +import {AlertTriangle, Plus, RefreshCcw} from "lucide-react"; +import {Button} from "@/components/ui/button"; +import {DiscountResponse, getDiscounts} from "@/lib/actions/discountActions"; +import {toast} from "sonner"; +import { + DiscountCodeForm +} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form"; +import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; + + +// Define possible interaction modes +type InteractionMode = "view" | "create" | "edit"; + +export default function DiscountManagementPage() { + const {event, isLoading: isEventLoading} = useEventContext(); + const [discounts, setDiscounts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [mode, setMode] = useState("view"); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [editingDiscountId, setEditingDiscountId] = useState(null); + const [currentDiscount, setCurrentDiscount] = useState(null); + const pageSize = 1000; // Large enough to fetch all + + const fetchDiscounts = async () => { + if (!event?.id) return; + + try { + setIsLoading(true); + setError(null); + + const response = await getDiscounts(event.id, true, 0, pageSize); + setDiscounts(response || []); + } catch (err) { + setError("Failed to load discounts. Please try again."); + console.error(err); + toast.error("Failed to load discounts."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (event?.id) { + fetchDiscounts(); + } + }, [event?.id]); + + const handleCreateDiscount = () => { + setCurrentDiscount(null); + setMode("create"); + setIsCreateDialogOpen(true); + }; + + const handleEditDiscount = (discountIndex: number) => { + const discountToEdit = discounts[discountIndex]; + setMode("edit"); + }; + + const handleCancelEdit = () => { + setMode("view"); + setEditingDiscountId(null); + setCurrentDiscount(null); + }; + + const handleDiscountCreated = () => { + setIsCreateDialogOpen(false); + fetchDiscounts(); + toast.success("Discount created successfully."); + }; + + const handleDiscountUpdated = () => { + setMode("view"); + setEditingDiscountId(null); + fetchDiscounts(); + toast.success("Discount updated successfully."); + }; + + const handleDiscountDeleted = (discountIndex: number) => { + fetchDiscounts(); + toast.success("Discount deleted successfully."); + }; + + if (isEventLoading) { + return ( +
+
+ + + +
+
+ ); + } + + if (!event) { + return ( +
+
+ +
+

+ Event not found +

+
+ ); + } + + return ( +
+
+
+
+

Discount Management

+

+ Create and manage discounts for {event.title} +

+
+
+ + +
+
+ + {mode === "create" && ( +
+

Create New Discount

+ +
+ )} + + {error ? ( +
+ {error} +
+ ) : isLoading ? ( + + ) : discounts.length === 0 && mode === "view" ? ( +
+

No discounts found

+ +
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/layout.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/layout.tsx index dbfa1fb..3f37760 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/layout.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/layout.tsx @@ -1,78 +1,86 @@ "use client"; import * as React from "react"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useParams, useRouter, usePathname } from "next/navigation"; +import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs"; +import {useParams, useRouter, usePathname} from "next/navigation"; import { - Home, - BarChart2, - CalendarCheck, - Settings, + Home, + BarChart2, + CalendarCheck, + Settings, Tag, } from "lucide-react"; -import { EventProvider } from "@/providers/EventProvider"; +import {EventProvider} from "@/providers/EventProvider"; export default function EventDetailsLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const params = useParams(); - const router = useRouter(); - const pathname = usePathname(); + const params = useParams(); + const router = useRouter(); + const pathname = usePathname(); - const eventId = params.eventId as string; - const organizationId = params.organization_id as string; - const basePath = `/manage/organization/${organizationId}/event/${eventId}`; + const eventId = params.eventId as string; + const organizationId = params.organization_id as string; + const basePath = `/manage/organization/${organizationId}/event/${eventId}`; - // Determine the active tab based on the current path - const getActiveTab = () => { - if (pathname.includes("/analytics")) return "analytics"; - if (pathname.includes("/sessions")) return "sessions"; - if (pathname.includes("/settings")) return "settings"; - return "overview"; - }; + // Determine the active tab based on the current path + const getActiveTab = () => { + if (pathname.includes("/analytics")) return "analytics"; + if (pathname.includes("/sessions")) return "sessions"; + if (pathname.includes("/settings")) return "settings"; + if (pathname.includes('/discounts')) return 'discounts'; + return "overview"; + }; - const handleTabChange = (value: string) => { - switch (value) { - case "overview": - router.push(basePath); - break; - case "analytics": - router.push(`${basePath}/analytics`); - break; - case "sessions": - router.push(`${basePath}/sessions`); - break; - case "settings": - router.push(`${basePath}/settings`); - break; - } - }; + const handleTabChange = (value: string) => { + switch (value) { + case "overview": + router.push(basePath); + break; + case "analytics": + router.push(`${basePath}/analytics`); + break; + case "sessions": + router.push(`${basePath}/sessions`); + break; + case "settings": + router.push(`${basePath}/settings`); + break; + case "discounts": + router.push(`${basePath}/discounts`); + break; + default: + router.push(basePath); + } + }; - // Tab definitions with icons - const tabItems = [ - { name: "Overview", value: "overview", icon: Home }, - { name: "Analytics", value: "analytics", icon: BarChart2 }, - { name: "Sessions", value: "sessions", icon: CalendarCheck }, - { name: "Settings", value: "settings", icon: Settings }, - ]; + // Tab definitions with icons + const tabItems = [ + {name: "Overview", value: "overview", icon: Home}, + {name: "Analytics", value: "analytics", icon: BarChart2}, + {name: "Sessions", value: "sessions", icon: CalendarCheck}, + {name: "Discounts", value: "discounts", icon: Tag}, + {name: "Settings", value: "settings", icon: Settings}, + ]; - return ( - -
- - - {tabItems.map((tab) => { - const Icon = tab.icon; - return ( - + + + {tabItems.map((tab) => { + const Icon = tab.icon; + return ( + - - {tab.name} - - ); - })} - - + > + + {tab.name} + + ); + })} + + -
{children}
-
-
- ); +
{children}
+
+ + ); } diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx index 599003f..91ed52d 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx @@ -13,7 +13,6 @@ interface DiscountStepProps { } export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { - // ✅ State to manage the view and which discount is being edited const [view, setView] = useState<'list' | 'create' | 'edit'>('list'); const [editingIndex, setEditingIndex] = useState(null); @@ -21,6 +20,7 @@ export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { const tiers = watch("tiers"); const sessions = watch("sessions"); + const discounts = watch("discounts"); // ✅ Destructure all necessary functions from useFieldArray const { fields: discountFields, append, remove, update } = useFieldArray({ @@ -106,7 +106,7 @@ export default function DiscountStep({onConfigModeChange}: DiscountStepProps) {
discountSchema.parse(d))} tiers={tiers} sessions={sessions} onDelete={handleDeleteDiscount} diff --git a/src/lib/actions/discountActions.ts b/src/lib/actions/discountActions.ts index a75ed79..16ca6af 100644 --- a/src/lib/actions/discountActions.ts +++ b/src/lib/actions/discountActions.ts @@ -1,10 +1,10 @@ import { apiFetch } from '@/lib/api'; -import { PaginatedResponse } from "@/types/paginatedResponse"; import {DiscountRequest} from "@/lib/validators/event"; -import {DiscountDTO} from "@/types/event"; const API_BASE_PATH = '/event-seating/v1/events'; +export type DiscountResponse = DiscountRequest & {} + /** * Creates a new discount for an event. * @@ -12,8 +12,8 @@ const API_BASE_PATH = '/event-seating/v1/events'; * @param discountData The discount data to create * @returns The created discount details */ -export const createDiscount = (eventId: string, discountData: DiscountRequest): Promise => { - return apiFetch(`${API_BASE_PATH}/${eventId}/discounts`, { +export const createDiscount = (eventId: string, discountData: DiscountRequest): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/discounts`, { method: 'POST', body: JSON.stringify(discountData), }); @@ -33,14 +33,14 @@ export const getDiscounts = ( includePrivate: boolean = false, page: number = 0, size: number = 10 -): Promise> => { +): Promise => { const params = new URLSearchParams({ includePrivate: includePrivate.toString(), page: page.toString(), size: size.toString(), }); - return apiFetch>( + return apiFetch( `${API_BASE_PATH}/${eventId}/discounts?${params.toString()}` ); }; @@ -52,8 +52,8 @@ export const getDiscounts = ( * @param discountId ID of the discount to fetch * @returns The discount details */ -export const getDiscount = (eventId: string, discountId: string): Promise => { - return apiFetch(`${API_BASE_PATH}/${eventId}/discounts/${discountId}`); +export const getDiscount = (eventId: string, discountId: string): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/discounts/${discountId}`); }; /** @@ -68,8 +68,8 @@ export const updateDiscount = ( eventId: string, discountId: string, discountData: DiscountRequest -): Promise => { - return apiFetch(`${API_BASE_PATH}/${eventId}/discounts/${discountId}`, { +): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/discounts/${discountId}`, { method: 'PUT', body: JSON.stringify(discountData), }); From 322406587700bc09698ba5da752d1d5003780e8c Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 13:22:58 +0530 Subject: [PATCH 06/51] Add DiscountFormDialog component for creating and editing discounts --- .../event/[eventId]/discounts/page.tsx | 120 ++++++++---------- .../discounts/discount-form-dialog.tsx | 75 +++++++++++ 2 files changed, 131 insertions(+), 64 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx index 593eae2..fcae822 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx @@ -5,41 +5,38 @@ import {useEventContext} from "@/providers/EventProvider"; import {Skeleton} from "@/components/ui/skeleton"; import {AlertTriangle, Plus, RefreshCcw} from "lucide-react"; import {Button} from "@/components/ui/button"; -import {DiscountResponse, getDiscounts} from "@/lib/actions/discountActions"; +import {DiscountResponse, getDiscounts, deleteDiscount} from "@/lib/actions/discountActions"; import {toast} from "sonner"; -import { - DiscountCodeForm -} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form"; import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; - - -// Define possible interaction modes -type InteractionMode = "view" | "create" | "edit"; +import { + DiscountFormDialog +} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog"; export default function DiscountManagementPage() { const {event, isLoading: isEventLoading} = useEventContext(); const [discounts, setDiscounts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [mode, setMode] = useState("view"); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [editingDiscountId, setEditingDiscountId] = useState(null); - const [currentDiscount, setCurrentDiscount] = useState(null); - const pageSize = 1000; // Large enough to fetch all + + const [dialogState, setDialogState] = useState<{ + mode: 'create' | 'edit' | null; + data?: DiscountResponse; + }>({mode: null}); + + const pageSize = 1000; const fetchDiscounts = async () => { if (!event?.id) return; - + setIsLoading(true); + setError(null); try { - setIsLoading(true); - setError(null); - const response = await getDiscounts(event.id, true, 0, pageSize); setDiscounts(response || []); } catch (err) { - setError("Failed to load discounts. Please try again."); + const message = "Failed to load discounts. Please try again."; + setError(message); console.error(err); - toast.error("Failed to load discounts."); + toast.error(message); } finally { setIsLoading(false); } @@ -51,39 +48,32 @@ export default function DiscountManagementPage() { } }, [event?.id]); - const handleCreateDiscount = () => { - setCurrentDiscount(null); - setMode("create"); - setIsCreateDialogOpen(true); - }; - - const handleEditDiscount = (discountIndex: number) => { - const discountToEdit = discounts[discountIndex]; - setMode("edit"); + const handleOpenCreateDialog = () => { + setDialogState({mode: 'create'}); }; - const handleCancelEdit = () => { - setMode("view"); - setEditingDiscountId(null); - setCurrentDiscount(null); + const handleOpenEditDialog = (discount: DiscountResponse) => { + setDialogState({mode: 'edit', data: discount}); }; - const handleDiscountCreated = () => { - setIsCreateDialogOpen(false); + const handleOnSuccess = () => { + setDialogState({mode: null}); fetchDiscounts(); - toast.success("Discount created successfully."); }; - const handleDiscountUpdated = () => { - setMode("view"); - setEditingDiscountId(null); - fetchDiscounts(); - toast.success("Discount updated successfully."); - }; + const handleDeleteDiscount = async (discountId: string) => { + if (!event?.id) return; + if (!confirm("Are you sure you want to delete this discount? This action cannot be undone.")) return; - const handleDiscountDeleted = (discountIndex: number) => { - fetchDiscounts(); - toast.success("Discount deleted successfully."); + const toastId = toast.loading("Deleting discount..."); + try { + await deleteDiscount(event.id, discountId); + toast.success("Discount deleted successfully.", {id: toastId}); + fetchDiscounts(); + } catch (err) { + toast.error("Failed to delete discount.", {id: toastId}); + console.error(err); + } }; if (isEventLoading) { @@ -126,55 +116,57 @@ export default function DiscountManagementPage() { variant="outline" size="sm" onClick={fetchDiscounts} - disabled={isLoading || mode === "edit"} + disabled={isLoading} > Refresh -
- {mode === "create" && ( -
-

Create New Discount

- -
- )} - {error ? (
{error}
) : isLoading ? ( - ) : discounts.length === 0 && mode === "view" ? ( + ) : discounts.length === 0 ? (

No discounts found

-
) : ( )}
+ + {dialogState.mode && ( + { + if (!isOpen) setDialogState({mode: null}); + }} + mode={dialogState.mode} + initialData={dialogState.data} + onSuccess={handleOnSuccess} + eventId={event.id} + tiers={event.tiers || []} + sessions={event.sessions || []} + /> + )} ); } diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog.tsx new file mode 100644 index 0000000..6569170 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from "@/components/ui/dialog"; +import { DiscountCodeForm } from "./discount-code-form"; +import { DiscountRequest, SessionRequest, TierRequest } from "@/lib/validators/event"; +import {createDiscount, DiscountResponse, updateDiscount} from "@/lib/actions/discountActions"; +import { toast } from "sonner"; + +interface DiscountFormDialogProps { + mode: 'create' | 'edit'; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; + initialData?: DiscountResponse; + eventId: string; + tiers: TierRequest[]; + sessions: SessionRequest[]; +} + +export const DiscountFormDialog = ({ + mode, + open, + onOpenChange, + onSuccess, + initialData, + eventId, + tiers, + sessions, + }: DiscountFormDialogProps) => { + + const handleSave = async (data: DiscountRequest) => { + const action = mode === 'create' + ? createDiscount(eventId, data) + : updateDiscount(eventId, initialData!.id, data); + + const toastId = toast.loading(`${mode === 'create' ? 'Creating' : 'Updating'} discount...`); + + try { + await action; + toast.success(`Discount ${mode === 'create' ? 'created' : 'updated'} successfully.`, { id: toastId }); + onSuccess(); + } catch (error) { + console.error(`Failed to ${mode} discount`, error); + toast.error(`Failed to ${mode} discount.`, { id: toastId }); + } + }; + + return ( + + + + {mode === 'create' ? 'Create New Discount' : 'Edit Discount'} + + Fill in the details for your discount code below. + + +
+ +
+
+
+ ); +}; \ No newline at end of file From 6e026c107af28dfdbdedfbdfc4442fdb2d746b52 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 13:55:58 +0530 Subject: [PATCH 07/51] Refactor Discount components to use IDs instead of indices for CRUD operations --- .../event/[eventId]/discounts/page.tsx | 382 ++++++++++-------- .../_components/discounts/discount-card.tsx | 18 +- .../_components/discounts/discount-list.tsx | 42 +- .../_components/discounts/discount-step.tsx | 47 +-- 4 files changed, 273 insertions(+), 216 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx index fcae822..7652999 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx @@ -1,172 +1,236 @@ -"use client"; - -import {useState, useEffect} from "react"; -import {useEventContext} from "@/providers/EventProvider"; -import {Skeleton} from "@/components/ui/skeleton"; -import {AlertTriangle, Plus, RefreshCcw} from "lucide-react"; -import {Button} from "@/components/ui/button"; -import {DiscountResponse, getDiscounts, deleteDiscount} from "@/lib/actions/discountActions"; -import {toast} from "sonner"; -import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; -import { - DiscountFormDialog -} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog"; - -export default function DiscountManagementPage() { - const {event, isLoading: isEventLoading} = useEventContext(); - const [discounts, setDiscounts] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - const [dialogState, setDialogState] = useState<{ - mode: 'create' | 'edit' | null; - data?: DiscountResponse; - }>({mode: null}); - - const pageSize = 1000; - - const fetchDiscounts = async () => { - if (!event?.id) return; - setIsLoading(true); - setError(null); - try { - const response = await getDiscounts(event.id, true, 0, pageSize); - setDiscounts(response || []); - } catch (err) { - const message = "Failed to load discounts. Please try again."; - setError(message); - console.error(err); - toast.error(message); - } finally { - setIsLoading(false); + "use client"; + + import {useState, useEffect, useCallback} from "react"; + import {useEventContext} from "@/providers/EventProvider"; + import {Skeleton} from "@/components/ui/skeleton"; + import {AlertTriangle, Plus, RefreshCcw} from "lucide-react"; + import {Button} from "@/components/ui/button"; + import {createDiscount, DiscountResponse, getDiscounts, deleteDiscount, updateDiscount} from "@/lib/actions/discountActions"; + import {toast} from "sonner"; + import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; + import {DiscountRequest} from "@/lib/validators/event"; + import { + FullDiscountFormView + } from "@/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view"; + + export default function DiscountManagementPage() { + const {event, isLoading: isEventLoading} = useEventContext(); + const [discounts, setDiscounts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [mode, setMode] = useState<'view' | 'create' | 'edit'>('view'); + const [editingDiscount, setEditingDiscount] = useState(null); + + const pageSize = 1000; + + const fetchDiscounts = useCallback(async () => { + if (!event?.id) return; + setIsLoading(true); + setError(null); + try { + const response = await getDiscounts(event.id, true, 0, pageSize); + setDiscounts(response || []); + } catch (err) { + const message = "Failed to load discounts. Please try again."; + setError(message); + console.error(err); + toast.error(message); + } finally { + setIsLoading(false); + } + }, [event?.id]); + + useEffect(() => { + if (event?.id) { + fetchDiscounts(); + } + }, [event?.id, fetchDiscounts]); + + // --- Handlers --- + + const handleOpenCreateForm = () => { + setEditingDiscount(null); + setMode('create'); + }; + + const handleOpenEditForm = (discount: DiscountResponse) => { + setEditingDiscount(discount); + setMode('edit'); + }; + + const handleCancelForm = () => { + setMode('view'); + setEditingDiscount(null); + }; + + + + const handleSaveDiscount = async (data: DiscountRequest) => { + const action = mode === 'create' + ? createDiscount(event!.id, data) + : updateDiscount(event!.id, editingDiscount!.id, data); + + const toastId = toast.loading(`${mode === 'create' ? 'Creating' : 'Updating'} discount...`); + try { + await action; + toast.success(`Discount ${mode === 'create' ? 'created' : 'updated'} successfully.`, {id: toastId}); + setMode('view'); // Return to list view on success + fetchDiscounts(); + } catch (error) { + console.error(`Failed to ${mode} discount`, error); + toast.error(`Failed to ${mode} discount.`, {id: toastId}); + } + }; + + const handleToggleStatus = async (discountId: string) => { + if (!event?.id) return; + + // 1. Save the original state in case we need to revert + const originalDiscounts = [...discounts]; + const discountIndex = originalDiscounts.findIndex(d => d.id === discountId); + if (discountIndex === -1) return; + + const discountToUpdate = originalDiscounts[discountIndex]; + const newStatus = !discountToUpdate.active; + + // 2. Optimistically update the UI immediately + const newDiscounts = [...originalDiscounts]; + newDiscounts[discountIndex] = { ...discountToUpdate, active: newStatus }; + setDiscounts(newDiscounts); + + // 3. Send the request to the backend + const toastId = toast.loading("Updating status..."); + try { + await updateDiscount(event.id, discountId, { ...discountToUpdate, active: newStatus }); + toast.success("Status updated.", { id: toastId }); + } catch (err) { + // 4. On failure, revert the state and show an error + toast.error("Failed to update status.", { id: toastId }); + setDiscounts(originalDiscounts); // Revert to the original state + console.error(err); + } } - }; - useEffect(() => { - if (event?.id) { - fetchDiscounts(); + const handleDeleteDiscount = async (discountId: string) => { + if (!event?.id) return; + if (!confirm("Are you sure you want to delete this discount? This action cannot be undone.")) return; + + // 1. Save original state and create the new state + const originalDiscounts = [...discounts]; + const newDiscounts = originalDiscounts.filter(d => d.id !== discountId); + + // 2. Optimistically update the UI + setDiscounts(newDiscounts); + + // 3. Send the request + const toastId = toast.loading("Deleting discount..."); + try { + await deleteDiscount(event.id, discountId); + toast.success("Discount deleted successfully.", { id: toastId }); + } catch (err) { + // 4. On failure, revert and show error + toast.error("Failed to delete discount.", { id: toastId }); + setDiscounts(originalDiscounts); // Revert! + console.error(err); + } + }; + + if (isEventLoading) { + return ( +
+
+ + + +
+
+ ); + } + + if (!event) { + return ( +
+
+ +
+

+ Event not found +

+
+ ); } - }, [event?.id]); - - const handleOpenCreateDialog = () => { - setDialogState({mode: 'create'}); - }; - - const handleOpenEditDialog = (discount: DiscountResponse) => { - setDialogState({mode: 'edit', data: discount}); - }; - - const handleOnSuccess = () => { - setDialogState({mode: null}); - fetchDiscounts(); - }; - - const handleDeleteDiscount = async (discountId: string) => { - if (!event?.id) return; - if (!confirm("Are you sure you want to delete this discount? This action cannot be undone.")) return; - - const toastId = toast.loading("Deleting discount..."); - try { - await deleteDiscount(event.id, discountId); - toast.success("Discount deleted successfully.", {id: toastId}); - fetchDiscounts(); - } catch (err) { - toast.error("Failed to delete discount.", {id: toastId}); - console.error(err); + + // ✅ CONDITIONAL RENDERING: Show the FullDiscountFormView wrapper + if (mode === 'create' || mode === 'edit') { + return ( +
+
+ +
+
+ ); } - }; - if (isEventLoading) { + // Default: The List View return (
- - - -
-
- ); - } +
+
+

Discount Management

+

+ Create and manage discounts for {event.title} +

+
+
+ + +
+
- if (!event) { - return ( -
-
- + {error ? ( +
+ {error} +
+ ) : isLoading ? ( + + ) : discounts.length === 0 ? ( +
+

No discounts found

+ +
+ ) : ( + + )}
-

- Event not found -

); } - - return ( -
-
-
-
-

Discount Management

-

- Create and manage discounts for {event.title} -

-
-
- - -
-
- - {error ? ( -
- {error} -
- ) : isLoading ? ( - - ) : discounts.length === 0 ? ( -
-

No discounts found

- -
- ) : ( - - )} -
- - {dialogState.mode && ( - { - if (!isOpen) setDialogState({mode: null}); - }} - mode={dialogState.mode} - initialData={dialogState.data} - onSuccess={handleOnSuccess} - eventId={event.id} - tiers={event.tiers || []} - sessions={event.sessions || []} - /> - )} -
- ); -} diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index 9c29031..f2f238d 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -18,12 +18,11 @@ import {getDiscountValue} from "@/lib/discountUtils"; // --- Component Props --- interface DiscountCardProps { discount: DiscountRequest, - index: number, tiers: TierRequest[], sessions?: SessionRequest[], - onDelete?: (index: number) => void, - onToggleStatus?: (index: number) => void, - onEdit?: (index: number) => void, + onDelete?: (id: string) => void, + onToggleStatus?: (id: string) => void, + onEdit?: (discount: DiscountRequest) => void, isReadOnly?: boolean, } @@ -43,7 +42,6 @@ const getDiscountIcon = (type: DiscountType) => { export function DiscountCard({ discount, - index, tiers, sessions, onDelete, @@ -63,7 +61,7 @@ export function DiscountCard({ navigator.clipboard.writeText(code).then(() => toast.success(`Code "${code}" copied!`)); } - // ✅ FIX: Pre-format dates to prevent 'unknown' type errors in JSX + // Pre-format dates to prevent 'unknown' type errors in JSX const formatDate = (dateString?: string | null) => { if (!dateString) return null; return format(new Date(dateString), 'MMM d, p'); @@ -72,7 +70,7 @@ export function DiscountCard({ const activeFromDate = formatDate(discount.activeFrom); const expiresAtDate = formatDate(discount.expiresAt); - // ✅ FIX: Use a safe fallback for arrays that might be undefined + // Use a safe fallback for arrays that might be undefined const applicableSessionIds = discount.applicableSessionIds || []; const sessionsCount = sessions?.length || 0; @@ -113,7 +111,7 @@ export function DiscountCard({ {!isReadOnly && onToggleStatus && ( onToggleStatus(index)} + onCheckedChange={() => onToggleStatus(discount.id)} /> )} - {!isReadOnly && onEdit && onDelete && ( - - - - - - onEdit(discount)}> - - Edit - - onDelete(discount.id)} - > - - Delete - - - - )} - - - -
-
-
-
- {discount.maxUsage && ( -
- - {discount.maxUsage} use limit -
- )} - {activeFromDate && ( -
- - Activates {activeFromDate} -
+
+
+

{discount.code}

+ + {discount.active ? "Active" : "Inactive"} + + {discount.public && ( + + + Public + )} - {expiresAtDate && ( -
- - Expires {expiresAtDate} -
+ {!discount.public && ( + + + Private + )}
-
-

- Applicable Tiers: {getTierNames(discount.applicableTierIds)} -

-

- {/* ✅ FIX: Use the safe fallback variables */} - Sessions: {sessionsCount > 0 && applicableSessionIds.length === sessionsCount ? "All Sessions" : `${applicableSessionIds.length} Selected`} -

+

+ {getDiscountValue(discount.parameters)} +

+
+
+ +
+ {!isReadOnly && onToggleStatus && ( + onToggleStatus(discount.id)} + /> + )} + + + {!isReadOnly && onEdit && onDelete && ( + + + + + + onEdit(discount)}> + + Edit + + onDelete(discount.id)} + > + + Delete + + + + )} +
+ + +
+
+
+
+ {discount.maxUsage && ( +
+ + {discount.maxUsage} use limit +
+ )} + {activeFromDate && ( +
+ + Activates {activeFromDate} +
+ )} + {expiresAtDate && ( +
+ + Expires {expiresAtDate} +
+ )} +
+ +
+

+ Applicable Tiers: {getTierNames(discount.applicableTierIds)} +

+

+ Sessions: {sessionsCount > 0 && applicableSessionIds.length === sessionsCount ? "All Sessions" : `${applicableSessionIds.length} Selected`} +

+
-
- - + + + + {/* Share Dialog */} + + ); } \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-share-dialog.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-share-dialog.tsx new file mode 100644 index 0000000..2978f98 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-share-dialog.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { SessionRequest, DiscountRequest } from "@/lib/validators/event"; +import { ShareComponent } from "@/components/ui/share/share-component"; +import { useEventContext } from "@/providers/EventProvider"; +import { format } from "date-fns"; + +interface DiscountShareDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + discount: DiscountRequest; + sessions?: SessionRequest[]; +} + +export function DiscountShareDialog({ + open, + onOpenChange, + discount, + sessions = [] +}: DiscountShareDialogProps) { + const { event } = useEventContext(); + const [selectedSessionId, setSelectedSessionId] = useState(null); + const [generatedUrl, setGeneratedUrl] = useState(null); + + // Filter sessions that are applicable to this discount + const applicableSessions = sessions.filter(session => + !discount.applicableSessionIds?.length || + discount.applicableSessionIds.includes(session.id) + ); + + // Generate the share URL once a session is selected + const generateShareUrl = (sessionId: string) => { + if (!event?.id) return ""; + + // Format: {domain}/events/{eventId}/{sessionId}?discount={discountCode} + const baseUrl = window.location.origin; + return `${baseUrl}/events/${event.id}/${sessionId}?discount=${discount.code}`; + }; + + const handleSessionSelect = (sessionId: string) => { + setSelectedSessionId(sessionId); + setGeneratedUrl(generateShareUrl(sessionId)); + }; + + const formatSessionDate = (startTime?: string | null, endTime?: string | null) => { + if (!startTime) return "No date"; + + const startDate = new Date(startTime); + const formattedStart = format(startDate, "MMM d, yyyy h:mm a"); + + if (!endTime) return formattedStart; + + const endDate = new Date(endTime); + // If same day, only show time for end + if (startDate.toDateString() === endDate.toDateString()) { + return `${formattedStart} - ${format(endDate, "h:mm a")}`; + } + + return `${formattedStart} - ${format(endDate, "MMM d, yyyy h:mm a")}`; + }; + + return ( + + + + Share Discount: {discount.code} + + Select which session you want to share this discount code for. + + + + {applicableSessions.length > 0 ? ( + <> +
+ + + {applicableSessions.map((session, index) => ( +
handleSessionSelect(session.id)} + > + +
+ + + {formatSessionDate(session.startTime, session.endTime)} + +
+
+ ))} +
+
+ + {generatedUrl && ( +
+ + +
+ )} + + ) : ( +
+

No applicable sessions found for this discount.

+ +
+ )} +
+
+ ); +} diff --git a/src/components/ui/share/share-component.tsx b/src/components/ui/share/share-component.tsx new file mode 100644 index 0000000..5741988 --- /dev/null +++ b/src/components/ui/share/share-component.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Copy, + Check, + Share2, + Mail, +} from "lucide-react"; +import { toast } from "sonner"; +import {SiFacebook, SiInstagram, SiWhatsapp, SiX} from "@icons-pack/react-simple-icons"; + +interface ShareComponentProps { + url: string; + title?: string; + text?: string; + className?: string; + onCopy?: () => void; +} + +export function ShareComponent({ + url, + title = "Check this out", + text = "I thought you might be interested in this", + className = "", + onCopy +}: ShareComponentProps) { + const [copied, setCopied] = useState(false); + + const copyToClipboard = () => { + navigator.clipboard.writeText(url).then(() => { + setCopied(true); + toast.success("Link copied to clipboard!"); + if (onCopy) onCopy(); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const shareUrl = encodeURIComponent(url); + const shareTitle = encodeURIComponent(title); + const shareText = encodeURIComponent(text); + + // Social media share links + const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${shareUrl}`; + const twitterUrl = `https://twitter.com/intent/tweet?url=${shareUrl}&text=${shareText}`; + const instagramUrl = `https://www.instagram.com/?url=${shareUrl}`; + const whatsappUrl = `https://wa.me/?text=${shareText}%20${shareUrl}`; + const mailtoUrl = `mailto:?subject=${shareTitle}&body=${shareText}%20${shareUrl}`; + + const openShareWindow = (url: string) => { + window.open(url, '_blank', 'width=600,height=400'); + }; + + // Use Web Share API if available + const handleNativeShare = async () => { + if (navigator.share) { + try { + await navigator.share({ + title, + text, + url + }); + toast.success("Shared successfully!"); + } catch (err) { + console.error("Error sharing:", err); + } + } else { + copyToClipboard(); + } + }; + + return ( +
+
+ + + +
+
+ + + +
+
+ + +
+
+ ); +} diff --git a/src/components/ui/simple-icon.tsx b/src/components/ui/simple-icon.tsx new file mode 100644 index 0000000..45fae03 --- /dev/null +++ b/src/components/ui/simple-icon.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import * as SimpleIcons from 'simple-icons-react'; + +interface SimpleIconProps { + name: keyof typeof SimpleIcons; + size?: number; + color?: string; + className?: string; +} + +export function SimpleIcon({ name, size = 24, color, className = '' }: SimpleIconProps) { + const IconComponent = SimpleIcons[name]; + + if (!IconComponent) { + console.error(`Icon "${name}" not found in simple-icons`); + return null; + } + + return ( + + ); +} From a117fd3e09f2809dfcd18c42aa32bea86af0d8c6 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 15:13:28 +0530 Subject: [PATCH 09/51] Enhance DiscountCard with confirmation dialog for deletion and improve UI structure --- .../event/[eventId]/discounts/page.tsx | 6 +-- .../_components/discounts/discount-card.tsx | 54 +++++++++++++++---- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx index 7652999..aba7b24 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx @@ -114,7 +114,6 @@ const handleDeleteDiscount = async (discountId: string) => { if (!event?.id) return; - if (!confirm("Are you sure you want to delete this discount? This action cannot be undone.")) return; // 1. Save original state and create the new state const originalDiscounts = [...discounts]; @@ -193,15 +192,14 @@
diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index b277f00..a74d749 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -14,8 +14,15 @@ import {DiscountRequest, SessionRequest, TierRequest} from "@/lib/validators/eve import {toast} from "sonner"; import {format} from "date-fns"; import {getDiscountValue} from "@/lib/discountUtils"; -import { useState } from "react"; -import { DiscountShareDialog } from "./discount-share-dialog"; +import {useState} from "react"; +import {DiscountShareDialog} from "./discount-share-dialog"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, + AlertDialogHeader, AlertDialogTitle, + AlertDialogTrigger +} from "@/components/ui/alert-dialog"; // --- Component Props --- interface DiscountCardProps { @@ -82,7 +89,8 @@ export function DiscountCard({
-
+
{getDiscountIcon(discount.parameters.type)}
@@ -122,9 +130,9 @@ export function DiscountCard({ onClick={() => copyToClipboard(discount.code)}> -
- + {/* Share Dialog */} Date: Sun, 5 Oct 2025 15:46:50 +0530 Subject: [PATCH 10/51] Add usage progress bar and improve discount details display in DiscountCard --- .../_components/discounts/discount-card.tsx | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index a74d749..5029a32 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -56,7 +56,7 @@ export function DiscountCard({ onDelete, onToggleStatus, onEdit, - isReadOnly + isReadOnly = false }: DiscountCardProps) { const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); @@ -84,6 +84,10 @@ export function DiscountCard({ const applicableSessionIds = discount.applicableSessionIds || []; const sessionsCount = sessions?.length || 0; + const usagePercentage = discount.maxUsage && discount.maxUsage > 0 + ? ((discount.currentUsage || 0) / discount.maxUsage) * 100 + : 0; + return ( <> @@ -168,7 +172,8 @@ export function DiscountCard({ Are you absolutely sure? - This action cannot be undone. This will permanently delete the discount code "{discount.code}". + This action cannot be undone. This will permanently delete the + discount code "{discount.code}". @@ -191,27 +196,47 @@ export function DiscountCard({
-
-
- {discount.maxUsage && ( -
- - {discount.maxUsage} use limit -
- )} - {activeFromDate && ( -
- - Activates {activeFromDate} -
- )} - {expiresAtDate && ( -
- - Expires {expiresAtDate} +
+ {discount.maxUsage != null ? ( +
+ + Usage Limit: +
+
+ + {discount.currentUsage || 0}/{discount.maxUsage} +
- )} -
+
+ ) : ( +
+ + Uses: + {discount.currentUsage || 0} +
+ )} + + + + {activeFromDate && expiresAtDate && ( +
+ {activeFromDate && ( +
+ + Activates {activeFromDate} +
+ )} + {expiresAtDate && ( +
+ + Expires {expiresAtDate} +
+ )} +
+ )}

From b1089fb0e24ab0635b210c5d03b4674665901763 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 19:54:26 +0530 Subject: [PATCH 11/51] Refactor EventProvider to manage discounts and improve discount fetching logic --- .../event/[eventId]/discounts/page.tsx | 69 ++----- src/lib/actions/discountActions.ts | 6 - src/providers/EventProvider.tsx | 182 +++++++++++------- 3 files changed, 127 insertions(+), 130 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx index aba7b24..4a9498a 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx @@ -1,11 +1,11 @@ "use client"; - import {useState, useEffect, useCallback} from "react"; + import {useState} from "react"; import {useEventContext} from "@/providers/EventProvider"; import {Skeleton} from "@/components/ui/skeleton"; import {AlertTriangle, Plus, RefreshCcw} from "lucide-react"; import {Button} from "@/components/ui/button"; - import {createDiscount, DiscountResponse, getDiscounts, deleteDiscount, updateDiscount} from "@/lib/actions/discountActions"; + import {createDiscount, DiscountResponse, deleteDiscount, updateDiscount} from "@/lib/actions/discountActions"; import {toast} from "sonner"; import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; import {DiscountRequest} from "@/lib/validators/event"; @@ -14,40 +14,11 @@ } from "@/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view"; export default function DiscountManagementPage() { - const {event, isLoading: isEventLoading} = useEventContext(); - const [discounts, setDiscounts] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const {event, isLoading, refetchDiscounts, error, setDiscounts} = useEventContext(); const [mode, setMode] = useState<'view' | 'create' | 'edit'>('view'); const [editingDiscount, setEditingDiscount] = useState(null); - const pageSize = 1000; - - const fetchDiscounts = useCallback(async () => { - if (!event?.id) return; - setIsLoading(true); - setError(null); - try { - const response = await getDiscounts(event.id, true, 0, pageSize); - setDiscounts(response || []); - } catch (err) { - const message = "Failed to load discounts. Please try again."; - setError(message); - console.error(err); - toast.error(message); - } finally { - setIsLoading(false); - } - }, [event?.id]); - - useEffect(() => { - if (event?.id) { - fetchDiscounts(); - } - }, [event?.id, fetchDiscounts]); - - // --- Handlers --- const handleOpenCreateForm = () => { setEditingDiscount(null); @@ -76,7 +47,7 @@ await action; toast.success(`Discount ${mode === 'create' ? 'created' : 'updated'} successfully.`, {id: toastId}); setMode('view'); // Return to list view on success - fetchDiscounts(); + await refetchDiscounts(); } catch (error) { console.error(`Failed to ${mode} discount`, error); toast.error(`Failed to ${mode} discount.`, {id: toastId}); @@ -84,39 +55,37 @@ }; const handleToggleStatus = async (discountId: string) => { - if (!event?.id) return; - - // 1. Save the original state in case we need to revert - const originalDiscounts = [...discounts]; - const discountIndex = originalDiscounts.findIndex(d => d.id === discountId); + if (!event) return; + const originalDiscounts = [...(event.discounts || [])]; + const discountIndex = originalDiscounts.findIndex((d) => d.id === discountId); if (discountIndex === -1) return; const discountToUpdate = originalDiscounts[discountIndex]; const newStatus = !discountToUpdate.active; - // 2. Optimistically update the UI immediately const newDiscounts = [...originalDiscounts]; newDiscounts[discountIndex] = { ...discountToUpdate, active: newStatus }; setDiscounts(newDiscounts); - // 3. Send the request to the backend const toastId = toast.loading("Updating status..."); try { - await updateDiscount(event.id, discountId, { ...discountToUpdate, active: newStatus }); + await updateDiscount(event.id, discountId, { + ...discountToUpdate, + active: newStatus + }); toast.success("Status updated.", { id: toastId }); } catch (err) { - // 4. On failure, revert the state and show an error toast.error("Failed to update status.", { id: toastId }); - setDiscounts(originalDiscounts); // Revert to the original state + setDiscounts(originalDiscounts); console.error(err); } - } + }; const handleDeleteDiscount = async (discountId: string) => { - if (!event?.id) return; + if (!event) return; // 1. Save original state and create the new state - const originalDiscounts = [...discounts]; + const originalDiscounts = [...(event.discounts || [])]; const newDiscounts = originalDiscounts.filter(d => d.id !== discountId); // 2. Optimistically update the UI @@ -135,7 +104,7 @@ } }; - if (isEventLoading) { + if (isLoading) { return (

@@ -192,7 +161,7 @@
) : isLoading ? ( - ) : discounts.length === 0 ? ( + ) : event && (!event.discounts || event.discounts.length === 0) ? (

No discounts found

) : ( => { const params = new URLSearchParams({ includePrivate: includePrivate.toString(), - page: page.toString(), - size: size.toString(), }); return apiFetch( diff --git a/src/providers/EventProvider.tsx b/src/providers/EventProvider.tsx index 4f729cd..82e97ea 100644 --- a/src/providers/EventProvider.tsx +++ b/src/providers/EventProvider.tsx @@ -1,91 +1,125 @@ "use client"; -import React, { createContext, useContext, useState, useCallback, ReactNode } from "react"; -import { getMyEventById } from "@/lib/actions/eventActions"; -import { EventDetailDTO } from "@/lib/validators/event"; -import { OrganizationResponse } from "@/types/oraganizations"; -import { toast } from "sonner"; -import { useOrganization } from "@/providers/OrganizationProvider"; +import React, {createContext, useContext, useState, useCallback, ReactNode, useEffect} from "react"; +import {getMyEventById} from "@/lib/actions/eventActions"; +import {EventDetailDTO} from "@/lib/validators/event"; +import {OrganizationResponse} from "@/types/oraganizations"; +import {toast} from "sonner"; +import {useOrganization} from "@/providers/OrganizationProvider"; +import {DiscountResponse, getDiscounts} from "@/lib/actions/discountActions"; interface EventContextProps { - event: EventDetailDTO | null; - organization: OrganizationResponse | null; - isLoading: boolean; - error: string | null; - refetchEventData: () => Promise; + event: EventDetailDTO | null; + organization: OrganizationResponse | null; + isLoading: boolean; + error: string | null; + refetchEventData: () => Promise; + refetchDiscounts: () => Promise; + setDiscounts: (discounts: DiscountResponse[]) => void; } const EventContext = createContext(undefined); export const useEventContext = () => { - const context = useContext(EventContext); - - if (context === undefined) { - throw new Error("useEventContext must be used within an EventProvider"); - } - - return context; + const context = useContext(EventContext); + + if (context === undefined) { + throw new Error("useEventContext must be used within an EventProvider"); + } + + return context; }; interface EventProviderProps { - children: ReactNode; - eventId: string; + children: ReactNode; + eventId: string; } -export const EventProvider = ({ children, eventId }: EventProviderProps) => { - const [event, setEvent] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const { organizations, organization: currentOrganization, switchOrganization } = useOrganization(); - - const fetchEventData = useCallback(async () => { - if (!eventId) { - setError("No event ID provided"); - setIsLoading(false); - return; - } +export const EventProvider = ({children, eventId}: EventProviderProps) => { + const [event, setEvent] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const {organizations, organization: currentOrganization, switchOrganization} = useOrganization(); + + const fetchEventData = useCallback(async () => { + if (!eventId) { + setError("No event ID provided"); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setError(null); + + const eventData = await getMyEventById(eventId); + setEvent(eventData); + + // If the event belongs to a different organization than the current one, + // switch to the event's organization + if (eventData && eventData.organizationId && + currentOrganization && eventData.organizationId !== currentOrganization.id) { + await switchOrganization(eventData.organizationId); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to load event details"; + setError(errorMessage); + toast.error(errorMessage); + console.error("Error fetching event data:", error); + } finally { + setIsLoading(false); + } + }, [eventId, currentOrganization, switchOrganization]); - try { - setIsLoading(true); - setError(null); - - const eventData = await getMyEventById(eventId); - setEvent(eventData); - - // If the event belongs to a different organization than the current one, - // switch to the event's organization - if (eventData && eventData.organizationId && - currentOrganization && eventData.organizationId !== currentOrganization.id) { - await switchOrganization(eventData.organizationId); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Failed to load event details"; - setError(errorMessage); - toast.error(errorMessage); - console.error("Error fetching event data:", error); - } finally { - setIsLoading(false); + // Initial fetch + useEffect(() => { + fetchEventData(); + }, [fetchEventData]); + + + const fetchDiscounts = useCallback(async () => { + if (!event?.id) return; + setIsLoading(true); + setError(null); + try { + const response = await getDiscounts(event.id, true); + setEvent(prev => prev ? {...prev, discounts: response || []} : prev); + } catch (err) { + const message = "Failed to load discounts. Please try again."; + setError(message); + console.error(err); + toast.error(message); + } finally { + setIsLoading(false); + } + }, [event?.id]); + + + const setDiscounts = (discounts: DiscountResponse[]) => { + setEvent(prev => prev ? {...prev, discounts} : prev); } - }, [eventId, currentOrganization, switchOrganization]); - - // Initial fetch - React.useEffect(() => { - fetchEventData(); - }, [fetchEventData]); - - // Find the correct organization for this event - const eventOrganization = React.useMemo(() => { - if (!event || !event.organizationId || !organizations) return null; - return organizations.find(org => org.id === event.organizationId) || null; - }, [event, organizations]); - - const value = { - event, - organization: eventOrganization, - isLoading, - error, - refetchEventData: fetchEventData - }; - - return {children}; + + useEffect(() => { + if (event?.id) { + fetchDiscounts(); + } + }, [event?.id, fetchDiscounts]); + + // Find the correct organization for this event + const eventOrganization = React.useMemo(() => { + if (!event || !event.organizationId || !organizations) return null; + return organizations.find(org => org.id === event.organizationId) || null; + }, [event, organizations]); + + const value = { + event, + organization: eventOrganization, + isLoading, + error, + refetchEventData: fetchEventData, + refetchDiscounts: fetchDiscounts, + setDiscounts + }; + + return {children}; }; From e45307d1afbc4d007cdb08130d428002dc1ae8f3 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Sun, 5 Oct 2025 23:43:45 +0530 Subject: [PATCH 12/51] Update event schemas to use UUIDs for IDs and adjust seat generation logic --- .../event/_components/OnlineConfigView.tsx | 4 ++-- .../event/_components/PhysicalConfigView.tsx | 6 +++--- src/lib/validators/event.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigView.tsx b/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigView.tsx index bcf4171..67bdab0 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigView.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigView.tsx @@ -25,7 +25,7 @@ export function OnlineConfigView({onSave}: { // Generate the seats programmatically const seats: Seat[] = Array.from({length: capacity}, (_, i) => ({ - id: `temp_seat_${i}`, + id: crypto.randomUUID(), label: `Slot ${i + 1}`, tierId: selectedTierId, status: 'AVAILABLE', @@ -35,7 +35,7 @@ export function OnlineConfigView({onSave}: { name: "Online Event Capacity", layout: { blocks: [{ - id: `temp_block_${Date.now()}`, + id: crypto.randomUUID(), name: "Online Attendees", type: 'standing_capacity', position: {x: 0, y: 0}, diff --git a/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx b/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx index e60fe51..c54df4e 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx @@ -95,10 +95,10 @@ export function PhysicalConfigView({onSave, initialConfig}: { newBlock.rows = Array.from({length: numRows}, (_, rowIndex) => { const newRow: Row = { - id: `temp_row_${block.id}_${rowIndex}`, + id: crypto.randomUUID(), label: `${getRowLabel(startRowIndex + rowIndex)}`, seats: Array.from({length: numColumns}, (_, colIndex) => ({ - id: `temp_seat_${block.id}_${rowIndex}_${colIndex}`, + id: crypto.randomUUID(), label: `${startCol + colIndex}${getRowLabel(startRowIndex + rowIndex)}`, status: 'AVAILABLE', })), @@ -108,7 +108,7 @@ export function PhysicalConfigView({onSave, initialConfig}: { } else if (block.type === 'standing_capacity' && block.capacity) { const capacity = block.capacity; newBlock.seats = Array.from({length: capacity}, (_, i) => ({ - id: `temp_seat_${block.id}_${i}`, + id: crypto.randomUUID(), label: `Slot ${i + 1}`, status: 'AVAILABLE', })); diff --git a/src/lib/validators/event.ts b/src/lib/validators/event.ts index 0b9dc2d..d1dd785 100644 --- a/src/lib/validators/event.ts +++ b/src/lib/validators/event.ts @@ -119,20 +119,20 @@ const venueDetailsSchema = z.object({ }); const seatSchema = z.object({ - id: z.string(), + id: z.uuid(), label: z.string(), tierId: z.string().optional(), status: z.enum(['AVAILABLE', 'RESERVED']).optional(), }); const rowSchema = z.object({ - id: z.string(), + id: z.uuid(), label: z.string(), seats: z.array(seatSchema), }); export const blockSchema = z.object({ - id: z.string(), + id: z.uuid(), name: z.string().min(1, "Block name is required."), type: z.enum(['seated_grid', 'standing_capacity', 'non_sellable']), position: positionSchema, From 26b57d2528c16352d4217e15b90bec03f9d498c4 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Mon, 6 Oct 2025 20:11:03 +0530 Subject: [PATCH 13/51] Implement session management features with SessionsManager and SessionsPageLink components --- .../event/[eventId]/sessions/page.tsx | 51 +-- .../_components/sessions/sessions-manager.tsx | 412 ++++++++++++++++++ .../sessions/sessions-page-link.tsx | 24 + src/lib/actions/sessionActions.ts | 141 ++++++ 4 files changed, 588 insertions(+), 40 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-page-link.tsx create mode 100644 src/lib/actions/sessionActions.ts diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx index 20b3cca..69fe808 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx @@ -1,48 +1,19 @@ "use client"; -import * as React from "react"; -import { useEventContext } from "@/providers/EventProvider"; -import { Skeleton } from "@/components/ui/skeleton"; -import { AlertTriangle } from "lucide-react"; +import { SessionsManager } from '@/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager'; +import { useState } from 'react'; -export default function EventSessionsPage() { - const { event, isLoading, error } = useEventContext(); - - if (isLoading) { - return ( -
-
- - - -
-
- ); - } - - if (error || !event) { - return ( -
-
- -
-

- {error || "Event not found"} -

-
- ); - } +export default function SessionsPage() { + // Set up state for view mode similar to the discount page + const [mode, setMode] = useState<'view' | 'create' | 'edit'>('view'); + const [editingSession, setEditingSession] = useState(null); + // If in the future we implement editing or creating, we can use the mode state + // to conditionally render different views, just like in the discount page + return (
-
-

- Event Sessions for {event.title} -

-

- Manage your event sessions and scheduling here. -

-
+
); -} +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx new file mode 100644 index 0000000..2652ed7 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx @@ -0,0 +1,412 @@ +"use client" + +import * as React from 'react'; +import { useState } from 'react'; +import { format, parseISO } from 'date-fns'; +import { + Calendar, Clock, LinkIcon, MapPin, Share2, MoreHorizontal, + Edit, Trash2, Users, Layers, AlertTriangle, RefreshCcw, Plus +} from 'lucide-react'; +import { toast } from 'sonner'; +import { useEventContext } from '@/providers/EventProvider'; +import { SessionDetailDTO } from '@/lib/validators/event'; +import { SessionType } from '@/types/enums/sessionType'; +import { SessionStatus } from '@/types/enums/sessionStatus'; +import { Card, CardHeader, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface SessionShareDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + session: SessionDetailDTO; +} + +// Simple placeholder share dialog +const SessionShareDialog: React.FC = ({ + open, + onOpenChange, + session +}) => { + return ( + + + + Share Session + + Share the session details for {format(parseISO(session.startTime), "EEEE, MMMM d, yyyy")}. + (This is a placeholder dialog) + + + + Cancel + Copy Link + + + + ); +}; + +interface SessionCardProps { + session: SessionDetailDTO; +} + +export const SessionCard: React.FC = ({ session }) => { + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + + const handleEdit = () => { + // Placeholder for edit functionality + toast.info(`Edit session ${session.id} (Dummy function)`); + }; + + const handleDelete = () => { + // Placeholder for delete functionality + toast.info(`Delete session ${session.id} (Dummy function)`); + }; + + const startDate = parseISO(session.startTime); + const endDate = parseISO(session.endTime); + const isOnline = session.sessionType === SessionType.ONLINE; + + // Calculate event duration + const getDuration = (): string => { + try { + const durationMs = endDate.getTime() - startDate.getTime(); + const hours = Math.floor(durationMs / (1000 * 60 * 60)); + const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); + + return hours > 0 + ? `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}` + : `${minutes}m`; + } catch (e) { + console.error("Error calculating duration:", e); + return "N/A"; + } + }; + + const { event } = useEventContext(); + + // Get seating summary by tier + const getSeatingDetails = () => { + if (!session.layoutData?.layout?.blocks?.length) return { + byType: [], + byTier: [] + }; + + const blocks = session.layoutData.layout.blocks; + const byTier: {tier: string; count: number; color?: string}[] = []; + + // Count sellable seats/capacity + let standingCount = 0; + let seatedCount = 0; + let nonSellableCount = 0; + + // Create map of tier counts + const tierCounts = new Map(); + + blocks.forEach(block => { + if (block.type === 'standing_capacity' && block.capacity) { + standingCount += block.capacity; + + // We don't have tierId on blocks directly, but we can potentially count all standing capacity + // toward specific tiers if needed in the future + } else if (block.type === 'seated_grid') { + // Count seats in rows and group by tier + if (block.rows?.length) { + block.rows.forEach(row => { + row.seats.forEach(seat => { + seatedCount++; + if (seat.tierId) { + tierCounts.set(seat.tierId, (tierCounts.get(seat.tierId) || 0) + 1); + } + }); + }); + } + + // Count direct seats and group by tier + if (block.seats?.length) { + block.seats.forEach(seat => { + seatedCount++; + if (seat.tierId) { + tierCounts.set(seat.tierId, (tierCounts.get(seat.tierId) || 0) + 1); + } + }); + } + } else if (block.type === 'non_sellable') { + nonSellableCount++; + } + }); + + // Convert tier counts to summary array with tier names and colors + if (event?.tiers && tierCounts.size > 0) { + for (const [tierId, count] of tierCounts.entries()) { + const tier = event.tiers.find(t => t.id === tierId); + if (tier) { + byTier.push({ + tier: tier.name, + count, + color: tier.color + }); + } else { + byTier.push({ + tier: 'Unknown Tier', + count + }); + } + } + } + + return { + byTier + }; + }; + + const seatingDetails = getSeatingDetails(); + + return ( + <> + + +
+
+

+ {format(startDate, "MMM d, yyyy")} +

+

+ {format(startDate, "h:mm a")} - {format(endDate, "h:mm a")} +

+ + {isOnline ? 'Online' : 'Physical'} + + + {session.status} + +
+
+ +
+ + + + + + + + + Edit + + { + e.preventDefault(); + }} + > + + +
+ + Delete +
+
+ + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the session + on {format(startDate, "MMMM d, yyyy")} and all associated data. + + + + Cancel + Delete + + +
+
+
+
+
+
+ +
+
+
+ + {getDuration()} +
+ +
+ + Sales start on: {format(parseISO(session.salesStartTime), 'MMM d, h:mm a')} +
+
+ + {/* Detailed venue information */} +
+ {isOnline && session.venueDetails?.onlineLink ? ( + + ) : (!isOnline && session.venueDetails?.address) ? ( +
+ + + {session.venueDetails?.name || 'No venue'} | {session.venueDetails?.address} + +
+ ) : null} +
+ + {/* Seating tier summary */} + {seatingDetails.byTier.length > 0 && ( +
+ {seatingDetails.byTier.map((tier, index) => ( + + {tier.tier}: {tier.count} + + ))} +
+ )} +
+
+
+ + + + ); +}; + +export const SessionsManager: React.FC = () => { + const { event, isLoading, error } = useEventContext(); + + const handleCreateSession = () => { + // Placeholder for create session functionality + toast.info("Create new session (Dummy function)"); + }; + + const handleRefresh = () => { + // This would ideally call a refetch function similar to refetchDiscounts + toast.info("Refreshing sessions data (Dummy function)"); + }; + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (!event) { + return ( +
+
+ +
+

+ Event not found +

+
+ ); + } + + return ( +
+
+
+

Session Management

+

+ Create and manage sessions for {event.title} +

+
+
+ + +
+
+ + {error ? ( +
+ {error} +
+ ) : isLoading ? ( + + ) : event && (!event.sessions || event.sessions.length === 0) ? ( +
+

No sessions found

+ +
+ ) : ( +
+ {event.sessions.map((session) => ( + + ))} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-page-link.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-page-link.tsx new file mode 100644 index 0000000..8c62913 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-page-link.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { CalendarDays } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export const SessionsPageLink: React.FC = () => { + const params = useParams(); + const organizationId = params.organization_id as string; + const eventId = params.event_id as string; + + if (!organizationId || !eventId) return null; + + return ( + + + + ); +}; \ No newline at end of file diff --git a/src/lib/actions/sessionActions.ts b/src/lib/actions/sessionActions.ts new file mode 100644 index 0000000..12e0f7a --- /dev/null +++ b/src/lib/actions/sessionActions.ts @@ -0,0 +1,141 @@ +import { apiFetch } from '@/lib/api'; +import { + SessionRequest, + SessionDetailDTO, + VenueDetails, + SessionSeatingMapRequest, +} from "@/lib/validators/event"; +import { SessionStatus } from '@/types/enums/sessionStatus'; + +const API_BASE_PATH = '/event-seating/v1'; + +/** + * Response type for batch session creation + */ +export interface SessionBatchResponse { + eventId: string; + totalCreated: number; + sessions: SessionDetailDTO[]; +} + +/** + * Request type for creating multiple sessions + */ +export interface CreateSessionsRequest { + eventId: string; + sessions: SessionRequest[]; +} + +/** + * Request type for updating session time + */ +export interface SessionTimeUpdateRequest { + startTime: string; + endTime: string; + salesStartTime: string; +} + +/** + * Request type for updating session status + */ +export interface SessionStatusUpdateRequest { + status: SessionStatus; +} + +/** + * Request type for updating session venue and seating + */ +export interface SessionVenueUpdateRequest { + venueDetails: VenueDetails; + layoutData: SessionSeatingMapRequest; +} + +/** + * Creates multiple sessions for an event. + * + * @param createData Request containing event ID and session details + * @returns Response with created sessions information + */ +export const createSessions = (createData: CreateSessionsRequest): Promise => { + return apiFetch(`${API_BASE_PATH}/sessions`, { + method: 'POST', + body: JSON.stringify(createData), + }); +}; + +/** + * Fetches a specific session by ID. + * + * @param sessionId ID of the session to fetch + * @returns The session details + */ +export const getSession = (sessionId: string): Promise => { + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}`); +}; + +/** + * Updates a session's time details (start time, end time, sales start time). + * Only allowed for SCHEDULED and ON_SALE sessions. + * + * @param sessionId ID of the session to update + * @param timeData New time details + * @returns The updated session details + */ +export const updateSessionTime = ( + sessionId: string, + timeData: SessionTimeUpdateRequest +): Promise => { + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/time`, { + method: 'PUT', + body: JSON.stringify(timeData), + }); +}; + +/** + * Updates a session's status. + * Valid transitions: SCHEDULED -> ON_SALE -> CLOSED + * + * @param sessionId ID of the session to update + * @param statusData New status data + * @returns The updated session details + */ +export const updateSessionStatus = ( + sessionId: string, + statusData: SessionStatusUpdateRequest +): Promise => { + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/status`, { + method: 'PUT', + body: JSON.stringify(statusData), + }); +}; + +/** + * Updates a session's venue details and seating layout. + * Only allowed for SCHEDULED sessions. + * + * @param sessionId ID of the session to update + * @param venueData New venue details and seating layout + * @returns The updated session details + */ +export const updateSessionVenue = ( + sessionId: string, + venueData: SessionVenueUpdateRequest +): Promise => { + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/venue`, { + method: 'PUT', + body: JSON.stringify(venueData), + }); +}; + +/** + * Deletes a session. + * Only allowed before sales start. + * + * @param sessionId ID of the session to delete + * @returns void as the endpoint returns no content + */ +export const deleteSession = (sessionId: string): Promise => { + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}`, { + method: 'DELETE', + }); +}; \ No newline at end of file From 5865d88b1c175f5afd10efc22ea3dc229f7ada5a Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Mon, 6 Oct 2025 20:48:57 +0530 Subject: [PATCH 14/51] Add session management functions to EventProvider and SessionsManager --- .../_components/sessions/sessions-manager.tsx | 66 +++++++------------ src/lib/actions/sessionActions.ts | 10 +++ src/providers/EventProvider.tsx | 65 +++++++++++++++++- 3 files changed, 95 insertions(+), 46 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx index 2652ed7..f2a81ea 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import { format, parseISO } from 'date-fns'; import { Calendar, Clock, LinkIcon, MapPin, Share2, MoreHorizontal, - Edit, Trash2, Users, Layers, AlertTriangle, RefreshCcw, Plus + Edit, Trash2, AlertTriangle, RefreshCcw, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { useEventContext } from '@/providers/EventProvider'; @@ -71,6 +71,9 @@ interface SessionCardProps { export const SessionCard: React.FC = ({ session }) => { const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const { refetchSession, loadingSessionId } = useEventContext(); + + const isRefreshing = loadingSessionId === session.id; const handleEdit = () => { // Placeholder for edit functionality @@ -82,6 +85,10 @@ export const SessionCard: React.FC = ({ session }) => { toast.info(`Delete session ${session.id} (Dummy function)`); }; + const handleRefreshSession = () => { + refetchSession(session.id); + }; + const startDate = parseISO(session.startTime); const endDate = parseISO(session.endTime); const isOnline = session.sessionType === SessionType.ONLINE; @@ -111,50 +118,11 @@ export const SessionCard: React.FC = ({ session }) => { byTier: [] }; - const blocks = session.layoutData.layout.blocks; const byTier: {tier: string; count: number; color?: string}[] = []; - // Count sellable seats/capacity - let standingCount = 0; - let seatedCount = 0; - let nonSellableCount = 0; - // Create map of tier counts const tierCounts = new Map(); - blocks.forEach(block => { - if (block.type === 'standing_capacity' && block.capacity) { - standingCount += block.capacity; - - // We don't have tierId on blocks directly, but we can potentially count all standing capacity - // toward specific tiers if needed in the future - } else if (block.type === 'seated_grid') { - // Count seats in rows and group by tier - if (block.rows?.length) { - block.rows.forEach(row => { - row.seats.forEach(seat => { - seatedCount++; - if (seat.tierId) { - tierCounts.set(seat.tierId, (tierCounts.get(seat.tierId) || 0) + 1); - } - }); - }); - } - - // Count direct seats and group by tier - if (block.seats?.length) { - block.seats.forEach(seat => { - seatedCount++; - if (seat.tierId) { - tierCounts.set(seat.tierId, (tierCounts.get(seat.tierId) || 0) + 1); - } - }); - } - } else if (block.type === 'non_sellable') { - nonSellableCount++; - } - }); - // Convert tier counts to summary array with tier names and colors if (event?.tiers && tierCounts.size > 0) { for (const [tierId, count] of tierCounts.entries()) { @@ -210,6 +178,15 @@ export const SessionCard: React.FC = ({ session }) => {
+ + +
+ {step > 1 && !inConfigMode && ( + + )} + + {step < totalSteps ? ( + + ) : ( + + )} +
+
+ + + + +
+ ); +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx index 69fe808..0b89761 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/page.tsx @@ -1,16 +1,6 @@ -"use client"; - import { SessionsManager } from '@/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager'; -import { useState } from 'react'; export default function SessionsPage() { - // Set up state for view mode similar to the discount page - const [mode, setMode] = useState<'view' | 'create' | 'edit'>('view'); - const [editingSession, setEditingSession] = useState(null); - - // If in the future we implement editing or creating, we can use the mode state - // to conditionally render different views, just like in the discount page - return (
diff --git a/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx b/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx index 2da389a..9fccbe8 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx @@ -13,10 +13,11 @@ import { import {SingleSessionDialog} from "@/app/manage/organization/[organization_id]/event/_components/SingleSessionDialog"; import {SessionListItem} from "@/app/manage/organization/[organization_id]/event/_components/SessionListItem"; import {useLimits} from "@/providers/LimitProvider"; +import {CreateSessionsFormData} from "@/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page"; // --- Main Scheduling Step Component --- export function SchedulingStep() { - const {control, formState: {errors}} = useFormContext(); + const {control, formState: {errors}} = useFormContext(); const [isRecurringDialogOpen, setIsRecurringDialogOpen] = useState(false); const [isSingleSessionDialogOpen, setIsSingleSessionDialogOpen] = useState(false); const {myLimits} = useLimits(); diff --git a/src/app/manage/organization/[organization_id]/event/_components/SeatingStep.tsx b/src/app/manage/organization/[organization_id]/event/_components/SeatingStep.tsx index 35e4bb5..c79b860 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/SeatingStep.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/SeatingStep.tsx @@ -20,6 +20,7 @@ import { DialogFooter, } from "@/components/ui/dialog"; import {SessionType} from "@/types/enums/sessionType"; +import {CreateSessionsFormData} from "@/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page"; interface SeatingStepProps { onConfigModeChange?: (isInConfigMode: boolean) => void; @@ -27,7 +28,7 @@ interface SeatingStepProps { // --- Main Seating Step Component --- export function SeatingStep({onConfigModeChange}: SeatingStepProps) { - const {control, formState: {errors}, watch, getValues, setValue} = useFormContext(); + const {control, formState: {errors}, watch, getValues, setValue} = useFormContext(); const [configuringIndex, setConfiguringIndex] = useState(null); const [configuredLayoutData, setConfiguredLayoutData] = useState(null); const [showApplyDialog, setShowApplyDialog] = useState(false); diff --git a/src/app/manage/organization/[organization_id]/event/_components/SessionListItem.tsx b/src/app/manage/organization/[organization_id]/event/_components/SessionListItem.tsx index ae902d4..56394bf 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/SessionListItem.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/SessionListItem.tsx @@ -9,6 +9,7 @@ import {TimeConfigDialog} from './TimeConfigDialog'; // Assuming this component import {LinkIcon, MapPin, Settings, Trash2, Edit, Tag, Hourglass} from 'lucide-react'; import dynamic from "next/dynamic"; import {SessionType} from "@/types/enums/sessionType"; +import {CreateSessionsFormData} from "@/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page"; const LocationConfigDialog = dynamic( () => import("./LocationConfigDialog").then(mod => mod.LocationConfigDialog), @@ -61,7 +62,7 @@ export function SessionListItem({ index, onRemoveAction}: { index: number; onRemoveAction: (index: number) => void }) { - const {watch} = useFormContext(); + const {watch} = useFormContext(); const [isTimeDialogOpen, setIsTimeDialogOpen] = useState(false); const [isLocationDialogOpen, setIsLocationDialogOpen] = useState(false); const sessionData = watch(`sessions.${index}`); @@ -127,7 +128,7 @@ export function SessionListItem({ index, onRemoveAction}: { )}

- {format(parseISO(startTime), "PPP p")} - {format(parseISO(endTime), "p")} + {format(parseISO(startTime), "PPP p")} - {format(parseISO(endTime), "PPP p")}

diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx index f2a81ea..7108587 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx @@ -1,390 +1,441 @@ "use client" import * as React from 'react'; -import { useState } from 'react'; -import { format, parseISO } from 'date-fns'; -import { - Calendar, Clock, LinkIcon, MapPin, Share2, MoreHorizontal, - Edit, Trash2, AlertTriangle, RefreshCcw, Plus +import {useState} from 'react'; +import {format, parseISO} from 'date-fns'; +import { + Calendar, Clock, LinkIcon, MapPin, Share2, MoreHorizontal, + Edit, Trash2, AlertTriangle, RefreshCcw, Plus } from 'lucide-react'; -import { toast } from 'sonner'; -import { useEventContext } from '@/providers/EventProvider'; -import { SessionDetailDTO } from '@/lib/validators/event'; -import { SessionType } from '@/types/enums/sessionType'; -import { SessionStatus } from '@/types/enums/sessionStatus'; -import { Card, CardHeader, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; +import {toast} from 'sonner'; +import {useEventContext} from '@/providers/EventProvider'; +import {SessionDetailDTO} from '@/lib/validators/event'; +import {SessionType} from '@/types/enums/sessionType'; +import {SessionStatus} from '@/types/enums/sessionStatus'; +import {Card, CardHeader, CardContent} from '@/components/ui/card'; +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Skeleton} from '@/components/ui/skeleton'; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; interface SessionShareDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - session: SessionDetailDTO; + open: boolean; + onOpenChange: (open: boolean) => void; + session: SessionDetailDTO; } // Simple placeholder share dialog -const SessionShareDialog: React.FC = ({ - open, - onOpenChange, - session -}) => { - return ( - - - - Share Session - - Share the session details for {format(parseISO(session.startTime), "EEEE, MMMM d, yyyy")}. - (This is a placeholder dialog) - - - - Cancel - Copy Link - - - - ); +const SessionShareDialog: React.FC = ({ + open, + onOpenChange, + session + }) => { + return ( + + + + Share Session + + Share the session details for {format(parseISO(session.startTime), "EEEE, MMMM d, yyyy")}. + (This is a placeholder dialog) + + + + Cancel + Copy Link + + + + ); }; interface SessionCardProps { - session: SessionDetailDTO; + session: SessionDetailDTO; } -export const SessionCard: React.FC = ({ session }) => { - const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); - const { refetchSession, loadingSessionId } = useEventContext(); - - const isRefreshing = loadingSessionId === session.id; - - const handleEdit = () => { - // Placeholder for edit functionality - toast.info(`Edit session ${session.id} (Dummy function)`); - }; - - const handleDelete = () => { - // Placeholder for delete functionality - toast.info(`Delete session ${session.id} (Dummy function)`); - }; - - const handleRefreshSession = () => { - refetchSession(session.id); - }; - - const startDate = parseISO(session.startTime); - const endDate = parseISO(session.endTime); - const isOnline = session.sessionType === SessionType.ONLINE; - - // Calculate event duration - const getDuration = (): string => { - try { - const durationMs = endDate.getTime() - startDate.getTime(); - const hours = Math.floor(durationMs / (1000 * 60 * 60)); - const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); - - return hours > 0 - ? `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}` - : `${minutes}m`; - } catch (e) { - console.error("Error calculating duration:", e); - return "N/A"; - } - }; - - const { event } = useEventContext(); - - // Get seating summary by tier - const getSeatingDetails = () => { - if (!session.layoutData?.layout?.blocks?.length) return { - byType: [], - byTier: [] +export const SessionCard: React.FC = ({session}) => { + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const {refetchSession, loadingSessionId} = useEventContext(); + + const isRefreshing = loadingSessionId === session.id; + + const handleEdit = () => { + // Placeholder for edit functionality + toast.info(`Edit session ${session.id} (Dummy function)`); + }; + + const handleDelete = () => { + // Placeholder for delete functionality + toast.info(`Delete session ${session.id} (Dummy function)`); + }; + + const handleRefreshSession = () => { + refetchSession(session.id); }; - - const byTier: {tier: string; count: number; color?: string}[] = []; - - // Create map of tier counts - const tierCounts = new Map(); - - // Convert tier counts to summary array with tier names and colors - if (event?.tiers && tierCounts.size > 0) { - for (const [tierId, count] of tierCounts.entries()) { - const tier = event.tiers.find(t => t.id === tierId); - if (tier) { - byTier.push({ - tier: tier.name, - count, - color: tier.color - }); - } else { - byTier.push({ - tier: 'Unknown Tier', - count - }); + + const startDate = parseISO(session.startTime); + const endDate = parseISO(session.endTime); + const isOnline = session.sessionType === SessionType.ONLINE; + + // Calculate event duration + const getDuration = (): string => { + try { + const durationMs = endDate.getTime() - startDate.getTime(); + const hours = Math.floor(durationMs / (1000 * 60 * 60)); + const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); + + return hours > 0 + ? `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}` + : `${minutes}m`; + } catch (e) { + console.error("Error calculating duration:", e); + return "N/A"; } - } - } - - return { - byTier }; - }; - - const seatingDetails = getSeatingDetails(); - - return ( - <> - - -
-
-

- {format(startDate, "MMM d, yyyy")} -

-

- {format(startDate, "h:mm a")} - {format(endDate, "h:mm a")} -

- - {isOnline ? 'Online' : 'Physical'} - - { + if (!session.layoutData?.layout?.blocks?.length) return { + byType: [], + byTier: [], + totalSeats: 0 + }; + + // Create a record of tier counts + const tierCountsRecord: Record = {}; + + // Process blocks to count seats by tier + session.layoutData.layout.blocks.forEach((block) => { + if (block.rows && block.rows.length > 0) { + // Count seats in rows for seated_grid blocks + block.rows.forEach((row) => { + row.seats.forEach((seat) => { + if (seat.status !== "RESERVED") { + const tierId = seat.tierId || "unassigned"; + tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + 1; + } + }); + }); + } else if (block.seats && block.seats.length > 0) { + // Count direct seats array (for seated blocks without rows) + block.seats.forEach((seat) => { + if (seat.status !== "RESERVED") { + const tierId = seat.tierId || "unassigned"; + tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + 1; + } + }); + } else if (block.type === "standing_capacity" && block.capacity) { + // Get tier for standing capacity blocks + let tierId = "unassigned"; + + // Check if block has seats array with tier information + if (block.seats && block.seats.length > 0 && block.seats[0].tierId) { + tierId = block.seats[0].tierId; } - > - {session.status} - -
-
- -
- - - - - - - - - - Edit - - { - e.preventDefault(); - }} - > - - -
- - Delete -
-
- - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete the session - on {format(startDate, "MMMM d, yyyy")} and all associated data. - - - - Cancel - Delete - - -
-
-
-
-
-
- -
-
-
- - {getDuration()} -
- -
- - Sales start on: {format(parseISO(session.salesStartTime), 'MMM d, h:mm a')} -
-
- - {/* Detailed venue information */} -
- {isOnline && session.venueDetails?.onlineLink ? ( - - ) : (!isOnline && session.venueDetails?.address) ? ( -
- - + + // Add the capacity to the tier count + tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + (block.capacity || 0); + } + }); + + // Calculate total seats + const totalSeats = Object.values(tierCountsRecord).reduce( + (sum, count) => sum + count, + 0 + ); + + // Convert tier counts record to byTier array with tier names and colors + const byTier: { tier: string; count: number; color?: string }[] = []; + + if (event?.tiers) { + Object.entries(tierCountsRecord).forEach(([tierId, count]) => { + if (tierId === "unassigned") { + byTier.push({ + tier: 'Unassigned', + count + }); + } else { + const tier = event.tiers.find(t => t.id === tierId); + if (tier) { + byTier.push({ + tier: tier.name, + count, + color: tier.color + }); + } else { + byTier.push({ + tier: 'Unknown Tier', + count + }); + } + } + }); + } + + return { + byTier, + totalSeats + }; + }; + + const seatingDetails = getSeatingDetails(); + + return ( + <> + + +
+
+

+ {format(startDate, "MMM d, yyyy")} +

+

+ {format(startDate, "h:mm a")} - {format(endDate, "h:mm a")} +

+ + {isOnline ? 'Online' : 'Physical'} + + + {session.status} + +
+
+ +
+ + + + + + + + + + Edit + + { + e.preventDefault(); + }} + > + + +
+ + Delete +
+
+ + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the + session + on {format(startDate, "MMMM d, yyyy")} and all associated data. + + + + Cancel + Delete + + +
+
+
+
+
+
+ +
+
+
+ + {getDuration()} +
+ +
+ + Sales start on: {format(parseISO(session.salesStartTime), 'MMM d, h:mm a')} +
+
+ + {/* Detailed venue information */} +
+ {isOnline && session.venueDetails?.onlineLink ? ( + + ) : (!isOnline && session.venueDetails?.address) ? ( +
+ + {session.venueDetails?.name || 'No venue'} | {session.venueDetails?.address} -
- ) : null} -
- - {/* Seating tier summary */} - {seatingDetails.byTier.length > 0 && ( -
- {seatingDetails.byTier.map((tier, index) => ( - - {tier.tier}: {tier.count} - - ))} -
- )} -
-
-
- - - - ); +
+ ) : null} +
+ + {/* Seating tier summary */} + {seatingDetails.byTier.length > 0 && ( +
+ {seatingDetails.byTier.map((tier, index) => ( + + {tier.tier}: {tier.count} + + ))} +
+ )} +
+
+
+ + + + ); }; export const SessionsManager: React.FC = () => { - const { event, isLoading, error, refetchSessions, loadingSessionId } = useEventContext(); - - const handleCreateSession = () => { - // Placeholder for create session functionality - toast.info("Create new session (Dummy function)"); - }; - - const handleRefresh = () => { - // Use the new refetchSessions function from the context - refetchSessions(); - }; - - if (isLoading) { - return ( -
- - - -
- ); - } + const {event, isLoading, error, refetchSessions} = useEventContext(); + + const handleCreateSession = () => { + // Placeholder for create session functionality + toast.info("Create new session (Dummy function)"); + }; + + const handleRefresh = () => { + // Use the new refetchSessions function from the context + refetchSessions().then(); + }; + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (!event) { + return ( +
+
+ +
+

+ Event not found +

+
+ ); + } - if (!event) { return ( -
-
- -
-

- Event not found -

-
- ); - } - - return ( -
-
-
-

Session Management

-

- Create and manage sessions for {event.title} -

-
-
- - -
-
+
+
+
+

Session Management

+

+ Create and manage sessions for {event.title} +

+
+
+ + +
+
- {error ? ( -
- {error} -
- ) : isLoading ? ( - - ) : event && (!event.sessions || event.sessions.length === 0) ? ( -
-

No sessions found

- -
- ) : ( -
- {event.sessions.map((session) => ( - - ))} + {error ? ( +
+ {error} +
+ ) : isLoading ? ( + + ) : event && (!event.sessions || event.sessions.length === 0) ? ( +
+

No sessions found

+ +
+ ) : ( +
+ {event.sessions.map((session) => ( + + ))} +
+ )}
- )} -
- ); + ); }; \ No newline at end of file diff --git a/src/components/ui/stepper.tsx b/src/components/ui/stepper.tsx new file mode 100644 index 0000000..3e4f935 --- /dev/null +++ b/src/components/ui/stepper.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" + +interface StepperProps { + currentStep: number + steps: { + label: string + description?: string + icon?: React.ReactNode + }[] + className?: string +} + +export function Stepper({ currentStep, steps, className }: StepperProps) { + return ( +
+ {steps.map((step, index) => { + const stepNumber = index + 1 + const isActive = stepNumber === currentStep + const isCompleted = stepNumber < currentStep + + return ( + + {/* Step */} +
+
+ {isCompleted ? ( + + ) : ( + step.icon || {stepNumber} + )} +
+ +
+
+ {step.label} +
+ {step.description && ( +
+ {step.description} +
+ )} +
+
+ + {/* Connector line */} + {index < steps.length - 1 && ( +
+
+ {isCompleted && ( +
+ )} +
+
+ )} + + ) + })} +
+ ) +} + +// Legacy Step component for backwards compatibility +export function Step({ + icon + }: { + icon?: React.ReactNode, + label?: string, + description?: string +}) { + return <>{icon || null} +} \ No newline at end of file diff --git a/src/lib/validators/event.ts b/src/lib/validators/event.ts index d1dd785..4256db4 100644 --- a/src/lib/validators/event.ts +++ b/src/lib/validators/event.ts @@ -178,7 +178,7 @@ export const baseSessionSchema = z.object({ }); // 2. A session that is "complete" for Step 3. -const sessionWithVenueSchema = baseSessionSchema +export const sessionWithVenueSchema = baseSessionSchema .refine(data => data.sessionType !== null, { message: "A session type (Physical or Online) must be selected.", path: ["sessionType"], @@ -206,7 +206,7 @@ const sessionWithVenueSchema = baseSessionSchema // 3. A session that is "complete" for Step 4. -const sessionWithSeatingSchema = sessionWithVenueSchema +export const sessionWithSeatingSchema = sessionWithVenueSchema .safeExtend({ layoutData: sessionSeatingMapRequestSchema.extend({ layout: z.object({ From dc0373cb2cd8309ab63ec4bbe28c686ac64d4185 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Tue, 7 Oct 2025 10:51:44 +0530 Subject: [PATCH 16/51] Add redirect functionality for creating new sessions in SessionsManager --- .../_components/sessions/sessions-manager.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx index 7108587..4e90ee3 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import {useState} from 'react'; import {format, parseISO} from 'date-fns'; +import Link from 'next/link'; import { Calendar, Clock, LinkIcon, MapPin, Share2, MoreHorizontal, Edit, Trash2, AlertTriangle, RefreshCcw, Plus @@ -357,11 +358,6 @@ export const SessionCard: React.FC = ({session}) => { export const SessionsManager: React.FC = () => { const {event, isLoading, error, refetchSessions} = useEventContext(); - const handleCreateSession = () => { - // Placeholder for create session functionality - toast.info("Create new session (Dummy function)"); - }; - const handleRefresh = () => { // Use the new refetchSessions function from the context refetchSessions().then(); @@ -409,10 +405,14 @@ export const SessionsManager: React.FC = () => { Refresh {isLoading ? 'ing...' : ''} - + + +
@@ -425,9 +425,11 @@ export const SessionsManager: React.FC = () => { ) : event && (!event.sessions || event.sessions.length === 0) ? (

No sessions found

- + + +
) : (
From f9406b6edbb85da9850c3f9fe499483cacbd0075 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Tue, 7 Oct 2025 11:06:31 +0530 Subject: [PATCH 17/51] Refactor session creation UI by removing Card component and integrating ShareComponent in session share dialog --- .../event/[eventId]/sessions/create/page.tsx | 137 +++++++++--------- .../_components/sessions/sessions-manager.tsx | 39 +++-- 2 files changed, 94 insertions(+), 82 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page.tsx index 16e71e9..10b37d9 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page.tsx @@ -9,7 +9,6 @@ import {toast} from 'sonner'; import {Check, ChevronLeft, ChevronRight} from 'lucide-react'; import {Stepper} from '@/components/ui/stepper'; import {Button} from '@/components/ui/button'; -import {Card, CardContent} from '@/components/ui/card'; import {Separator} from '@/components/ui/separator'; import {useEventContext} from '@/providers/EventProvider'; import {createSessions} from '@/lib/actions/sessionActions'; @@ -201,7 +200,7 @@ export default function CreateSessionPage() { }; return ( -
+

Create New Sessions

@@ -209,82 +208,78 @@ export default function CreateSessionPage() {

- - - 1 - }, - { - label: "Seating", - description: "Configure seating layout", - icon: 2 - } - ]} - /> - - - - -
- {renderStep()} - - - -
+ 1 + }, + { + label: "Seating", + description: "Configure seating layout", + icon: 2 + } + ]} + /> + + + + + + {renderStep()} + + + +
+ + +
+ {step > 1 && !inConfigMode && ( + )} -
- {step > 1 && !inConfigMode && ( - - )} - - {step < totalSteps ? ( - - ) : ( - - )} -
-
- - - - + {step < totalSteps ? ( + + ) : ( + + )} +
+
+ +
); } \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx index 4e90ee3..dd17ade 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx @@ -34,6 +34,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import {ShareComponent} from '@/components/ui/share/share-component'; interface SessionShareDialogProps { open: boolean; @@ -41,12 +42,20 @@ interface SessionShareDialogProps { session: SessionDetailDTO; } -// Simple placeholder share dialog +// Session share dialog using the ShareComponent const SessionShareDialog: React.FC = ({ - open, - onOpenChange, - session - }) => { + open, + onOpenChange, + session +}) => { + const {event} = useEventContext(); + const eventUrl = `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8090'}/events/${event?.id}`; + + const handleCopy = () => { + // Optional: handle any additional logic after copying + onOpenChange(false); // Close dialog after copying + }; + return ( @@ -54,12 +63,20 @@ const SessionShareDialog: React.FC = ({ Share Session Share the session details for {format(parseISO(session.startTime), "EEEE, MMMM d, yyyy")}. - (This is a placeholder dialog) + +
+ +
+ - Cancel - Copy Link + Close
@@ -110,7 +127,7 @@ export const SessionCard: React.FC = ({session}) => { } }; - const {event} = useEventContext(); + const {event: eventContext} = useEventContext(); // Get seating summary by tier const getSeatingDetails = () => { @@ -166,7 +183,7 @@ export const SessionCard: React.FC = ({session}) => { // Convert tier counts record to byTier array with tier names and colors const byTier: { tier: string; count: number; color?: string }[] = []; - if (event?.tiers) { + if (eventContext?.tiers) { Object.entries(tierCountsRecord).forEach(([tierId, count]) => { if (tierId === "unassigned") { byTier.push({ @@ -174,7 +191,7 @@ export const SessionCard: React.FC = ({session}) => { count }); } else { - const tier = event.tiers.find(t => t.id === tierId); + const tier = eventContext.tiers.find(t => t.id === tierId); if (tier) { byTier.push({ tier: tier.name, From 184a541c1c42c1892b94ac6defce59b3f7152865 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Tue, 7 Oct 2025 11:07:40 +0530 Subject: [PATCH 18/51] Add SessionCard and SessionShareDialog components for session management UI --- .../_components/sessions/session-card.tsx | 316 +++++++++++++++ .../sessions/session-share-dialog.tsx | 61 +++ .../_components/sessions/sessions-manager.tsx | 368 +----------------- 3 files changed, 379 insertions(+), 366 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/_components/sessions/session-card.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/_components/sessions/session-share-dialog.tsx diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/session-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/session-card.tsx new file mode 100644 index 0000000..7fc29eb --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/session-card.tsx @@ -0,0 +1,316 @@ +import {SessionDetailDTO} from "@/lib/validators/event"; +import * as React from "react"; +import {useState} from "react"; +import {useEventContext} from "@/providers/EventProvider"; +import {toast} from "sonner"; +import {format, parseISO} from "date-fns"; +import {SessionType} from "@/types/enums/sessionType"; +import {Card, CardContent, CardHeader} from "@/components/ui/card"; +import {Badge} from "@/components/ui/badge"; +import {SessionStatus} from "@/types/enums/sessionStatus"; +import {Button} from "@/components/ui/button"; +import {Calendar, Clock, Edit, LinkIcon, MapPin, MoreHorizontal, RefreshCcw, Share2, Trash2} from "lucide-react"; +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from "@/components/ui/alert-dialog"; +import { + SessionShareDialog +} from "@/app/manage/organization/[organization_id]/event/_components/sessions/session-share-dialog"; + +interface SessionCardProps { + session: SessionDetailDTO; +} + +export const SessionCard: React.FC = ({session}) => { + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const {refetchSession, loadingSessionId} = useEventContext(); + + const isRefreshing = loadingSessionId === session.id; + + const handleEdit = () => { + // Placeholder for edit functionality + toast.info(`Edit session ${session.id} (Dummy function)`); + }; + + const handleDelete = () => { + // Placeholder for delete functionality + toast.info(`Delete session ${session.id} (Dummy function)`); + }; + + const handleRefreshSession = () => { + refetchSession(session.id); + }; + + const startDate = parseISO(session.startTime); + const endDate = parseISO(session.endTime); + const isOnline = session.sessionType === SessionType.ONLINE; + + // Calculate event duration + const getDuration = (): string => { + try { + const durationMs = endDate.getTime() - startDate.getTime(); + const hours = Math.floor(durationMs / (1000 * 60 * 60)); + const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); + + return hours > 0 + ? `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}` + : `${minutes}m`; + } catch (e) { + console.error("Error calculating duration:", e); + return "N/A"; + } + }; + + const {event: eventContext} = useEventContext(); + + // Get seating summary by tier + const getSeatingDetails = () => { + if (!session.layoutData?.layout?.blocks?.length) return { + byType: [], + byTier: [], + totalSeats: 0 + }; + + // Create a record of tier counts + const tierCountsRecord: Record = {}; + + // Process blocks to count seats by tier + session.layoutData.layout.blocks.forEach((block) => { + if (block.rows && block.rows.length > 0) { + // Count seats in rows for seated_grid blocks + block.rows.forEach((row) => { + row.seats.forEach((seat) => { + if (seat.status !== "RESERVED") { + const tierId = seat.tierId || "unassigned"; + tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + 1; + } + }); + }); + } else if (block.seats && block.seats.length > 0) { + // Count direct seats array (for seated blocks without rows) + block.seats.forEach((seat) => { + if (seat.status !== "RESERVED") { + const tierId = seat.tierId || "unassigned"; + tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + 1; + } + }); + } else if (block.type === "standing_capacity" && block.capacity) { + // Get tier for standing capacity blocks + let tierId = "unassigned"; + + // Check if block has seats array with tier information + if (block.seats && block.seats.length > 0 && block.seats[0].tierId) { + tierId = block.seats[0].tierId; + } + + // Add the capacity to the tier count + tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + (block.capacity || 0); + } + }); + + // Calculate total seats + const totalSeats = Object.values(tierCountsRecord).reduce( + (sum, count) => sum + count, + 0 + ); + + // Convert tier counts record to byTier array with tier names and colors + const byTier: { tier: string; count: number; color?: string }[] = []; + + if (eventContext?.tiers) { + Object.entries(tierCountsRecord).forEach(([tierId, count]) => { + if (tierId === "unassigned") { + byTier.push({ + tier: 'Unassigned', + count + }); + } else { + const tier = eventContext.tiers.find(t => t.id === tierId); + if (tier) { + byTier.push({ + tier: tier.name, + count, + color: tier.color + }); + } else { + byTier.push({ + tier: 'Unknown Tier', + count + }); + } + } + }); + } + + return { + byTier, + totalSeats + }; + }; + + const seatingDetails = getSeatingDetails(); + + return ( + <> + + +
+
+

+ {format(startDate, "MMM d, yyyy")} +

+

+ {format(startDate, "h:mm a")} - {format(endDate, "h:mm a")} +

+ + {isOnline ? 'Online' : 'Physical'} + + + {session.status} + +
+
+ +
+ + + + + + + + + + Edit + + { + e.preventDefault(); + }} + > + + +
+ + Delete +
+
+ + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the + session + on {format(startDate, "MMMM d, yyyy")} and all associated data. + + + + Cancel + Delete + + +
+
+
+
+
+
+ +
+
+
+ + {getDuration()} +
+ +
+ + Sales start on: {format(parseISO(session.salesStartTime), 'MMM d, h:mm a')} +
+
+ + {/* Detailed venue information */} +
+ {isOnline && session.venueDetails?.onlineLink ? ( + + ) : (!isOnline && session.venueDetails?.address) ? ( +
+ + + {session.venueDetails?.name || 'No venue'} | {session.venueDetails?.address} + +
+ ) : null} +
+ + {/* Seating tier summary */} + {seatingDetails.byTier.length > 0 && ( +
+ {seatingDetails.byTier.map((tier, index) => ( + + {tier.tier}: {tier.count} + + ))} +
+ )} +
+
+
+ + + + ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/session-share-dialog.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/session-share-dialog.tsx new file mode 100644 index 0000000..1a337c3 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/session-share-dialog.tsx @@ -0,0 +1,61 @@ +import {SessionDetailDTO} from "@/lib/validators/event"; +import * as React from "react"; +import {useEventContext} from "@/providers/EventProvider"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from "@/components/ui/alert-dialog"; +import {format, parseISO} from "date-fns"; +import {ShareComponent} from "@/components/ui/share/share-component"; + +interface SessionShareDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + session: SessionDetailDTO; +} + +// Session share dialog using the ShareComponent +export const SessionShareDialog: React.FC = ({ + open, + onOpenChange, + session + }) => { + const {event} = useEventContext(); + const eventUrl = `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8090'}/events/${event?.id}`; + + const handleCopy = () => { + // Optional: handle any additional logic after copying + onOpenChange(false); // Close dialog after copying + }; + + return ( + + + + Share Session + + Share the session details for {format(parseISO(session.startTime), "EEEE, MMMM d, yyyy")}. + + + +
+ +
+ + + Close + +
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx index dd17ade..eb89527 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/sessions-manager.tsx @@ -1,376 +1,12 @@ "use client" import * as React from 'react'; -import {useState} from 'react'; -import {format, parseISO} from 'date-fns'; import Link from 'next/link'; -import { - Calendar, Clock, LinkIcon, MapPin, Share2, MoreHorizontal, - Edit, Trash2, AlertTriangle, RefreshCcw, Plus -} from 'lucide-react'; -import {toast} from 'sonner'; +import {AlertTriangle, Plus, RefreshCcw} from 'lucide-react'; import {useEventContext} from '@/providers/EventProvider'; -import {SessionDetailDTO} from '@/lib/validators/event'; -import {SessionType} from '@/types/enums/sessionType'; -import {SessionStatus} from '@/types/enums/sessionStatus'; -import {Card, CardHeader, CardContent} from '@/components/ui/card'; -import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Skeleton} from '@/components/ui/skeleton'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import {ShareComponent} from '@/components/ui/share/share-component'; - -interface SessionShareDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - session: SessionDetailDTO; -} - -// Session share dialog using the ShareComponent -const SessionShareDialog: React.FC = ({ - open, - onOpenChange, - session -}) => { - const {event} = useEventContext(); - const eventUrl = `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8090'}/events/${event?.id}`; - - const handleCopy = () => { - // Optional: handle any additional logic after copying - onOpenChange(false); // Close dialog after copying - }; - - return ( - - - - Share Session - - Share the session details for {format(parseISO(session.startTime), "EEEE, MMMM d, yyyy")}. - - - -
- -
- - - Close - -
-
- ); -}; - -interface SessionCardProps { - session: SessionDetailDTO; -} - -export const SessionCard: React.FC = ({session}) => { - const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); - const {refetchSession, loadingSessionId} = useEventContext(); - - const isRefreshing = loadingSessionId === session.id; - - const handleEdit = () => { - // Placeholder for edit functionality - toast.info(`Edit session ${session.id} (Dummy function)`); - }; - - const handleDelete = () => { - // Placeholder for delete functionality - toast.info(`Delete session ${session.id} (Dummy function)`); - }; - - const handleRefreshSession = () => { - refetchSession(session.id); - }; - - const startDate = parseISO(session.startTime); - const endDate = parseISO(session.endTime); - const isOnline = session.sessionType === SessionType.ONLINE; - - // Calculate event duration - const getDuration = (): string => { - try { - const durationMs = endDate.getTime() - startDate.getTime(); - const hours = Math.floor(durationMs / (1000 * 60 * 60)); - const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); - - return hours > 0 - ? `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}` - : `${minutes}m`; - } catch (e) { - console.error("Error calculating duration:", e); - return "N/A"; - } - }; - - const {event: eventContext} = useEventContext(); - - // Get seating summary by tier - const getSeatingDetails = () => { - if (!session.layoutData?.layout?.blocks?.length) return { - byType: [], - byTier: [], - totalSeats: 0 - }; - - // Create a record of tier counts - const tierCountsRecord: Record = {}; - - // Process blocks to count seats by tier - session.layoutData.layout.blocks.forEach((block) => { - if (block.rows && block.rows.length > 0) { - // Count seats in rows for seated_grid blocks - block.rows.forEach((row) => { - row.seats.forEach((seat) => { - if (seat.status !== "RESERVED") { - const tierId = seat.tierId || "unassigned"; - tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + 1; - } - }); - }); - } else if (block.seats && block.seats.length > 0) { - // Count direct seats array (for seated blocks without rows) - block.seats.forEach((seat) => { - if (seat.status !== "RESERVED") { - const tierId = seat.tierId || "unassigned"; - tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + 1; - } - }); - } else if (block.type === "standing_capacity" && block.capacity) { - // Get tier for standing capacity blocks - let tierId = "unassigned"; - - // Check if block has seats array with tier information - if (block.seats && block.seats.length > 0 && block.seats[0].tierId) { - tierId = block.seats[0].tierId; - } - - // Add the capacity to the tier count - tierCountsRecord[tierId] = (tierCountsRecord[tierId] || 0) + (block.capacity || 0); - } - }); - - // Calculate total seats - const totalSeats = Object.values(tierCountsRecord).reduce( - (sum, count) => sum + count, - 0 - ); - - // Convert tier counts record to byTier array with tier names and colors - const byTier: { tier: string; count: number; color?: string }[] = []; - - if (eventContext?.tiers) { - Object.entries(tierCountsRecord).forEach(([tierId, count]) => { - if (tierId === "unassigned") { - byTier.push({ - tier: 'Unassigned', - count - }); - } else { - const tier = eventContext.tiers.find(t => t.id === tierId); - if (tier) { - byTier.push({ - tier: tier.name, - count, - color: tier.color - }); - } else { - byTier.push({ - tier: 'Unknown Tier', - count - }); - } - } - }); - } - - return { - byTier, - totalSeats - }; - }; - - const seatingDetails = getSeatingDetails(); - - return ( - <> - - -
-
-

- {format(startDate, "MMM d, yyyy")} -

-

- {format(startDate, "h:mm a")} - {format(endDate, "h:mm a")} -

- - {isOnline ? 'Online' : 'Physical'} - - - {session.status} - -
-
- -
- - - - - - - - - - Edit - - { - e.preventDefault(); - }} - > - - -
- - Delete -
-
- - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete the - session - on {format(startDate, "MMMM d, yyyy")} and all associated data. - - - - Cancel - Delete - - -
-
-
-
-
-
- -
-
-
- - {getDuration()} -
- -
- - Sales start on: {format(parseISO(session.salesStartTime), 'MMM d, h:mm a')} -
-
- - {/* Detailed venue information */} -
- {isOnline && session.venueDetails?.onlineLink ? ( - - ) : (!isOnline && session.venueDetails?.address) ? ( -
- - - {session.venueDetails?.name || 'No venue'} | {session.venueDetails?.address} - -
- ) : null} -
- - {/* Seating tier summary */} - {seatingDetails.byTier.length > 0 && ( -
- {seatingDetails.byTier.map((tier, index) => ( - - {tier.tier}: {tier.count} - - ))} -
- )} -
-
-
- - - - ); -}; +import {SessionCard} from "@/app/manage/organization/[organization_id]/event/_components/sessions/session-card"; export const SessionsManager: React.FC = () => { const {event, isLoading, error, refetchSessions} = useEventContext(); From a9d86137fbb8ed33e0346b6ff883af6f4b64a254 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Tue, 7 Oct 2025 13:54:55 +0530 Subject: [PATCH 19/51] Refactor discount-related components to use DTOs instead of request types --- .../_components/review/ReviewSessions.tsx | 8 +- .../_components/review/SeatingInformation.tsx | 4 +- .../_components/review/SeatingLayout.tsx | 4 +- .../event/[eventId]/discounts/page.tsx | 4 +- .../_components/CustomSeatingLayout.tsx | 205 ++++++++++ .../_components/SeatStatusSummary.tsx | 125 ++++++ .../_components/SessionSeatingLayout.tsx | 40 ++ .../_components/seatingCapacitySummary.tsx | 90 +++++ .../[eventId]/sessions/[sessionId]/page.tsx | 382 ++++++++++++++++++ .../_components/discounts/discount-card.tsx | 10 +- .../discounts/discount-code-form.tsx | 16 +- .../discounts/discount-form-dialog.tsx | 8 +- .../_components/discounts/discount-list.tsx | 10 +- .../discounts/discount-share-dialog.tsx | 6 +- .../_components/discounts/discount-step.tsx | 10 +- .../discounts/full-discount-form-view.tsx | 10 +- .../discounts/review-discounts.tsx | 8 +- .../_components/sessions/session-card.tsx | 52 ++- src/lib/actions/discountActions.ts | 8 +- src/lib/actions/sessionActions.ts | 4 +- src/lib/utils.ts | 6 +- src/lib/validators/event.ts | 9 +- src/types/enums/SeatStatus.ts | 5 + 23 files changed, 948 insertions(+), 76 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/CustomSeatingLayout.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatStatusSummary.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionSeatingLayout.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/seatingCapacitySummary.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx create mode 100644 src/types/enums/SeatStatus.ts diff --git a/src/app/manage/_components/review/ReviewSessions.tsx b/src/app/manage/_components/review/ReviewSessions.tsx index 4323e76..650b97f 100644 --- a/src/app/manage/_components/review/ReviewSessions.tsx +++ b/src/app/manage/_components/review/ReviewSessions.tsx @@ -3,7 +3,7 @@ import {format, parseISO} from 'date-fns'; import {Calendar, Clock, LinkIcon, MapPin, Tag} from 'lucide-react'; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger,} from "@/components/ui/accordion"; import {Badge} from '@/components/ui/badge'; -import {SessionRequest, TierFormData} from '@/lib/validators/event'; +import {SessionDTO, TierFormData} from '@/lib/validators/event'; import dynamic from "next/dynamic"; import {SessionType} from "@/types/enums/sessionType"; @@ -13,7 +13,7 @@ const SeatingInformation = dynamic( ); interface ReviewSessionsProps { - sessions: SessionRequest[]; + sessions: SessionDTO[]; tiers: TierFormData[]; } @@ -38,7 +38,7 @@ export const ReviewSessions: React.FC = ({sessions, tiers}) }; interface SessionAccordionItemProps { - session: SessionRequest; + session: SessionDTO; tiers: TierFormData[]; index: number; } @@ -94,7 +94,7 @@ const SessionAccordionItem: React.FC = ({session, ind }; interface SessionDetailsProps { - session: SessionRequest; + session: SessionDTO; } const SessionDetails: React.FC = ({session}) => { diff --git a/src/app/manage/_components/review/SeatingInformation.tsx b/src/app/manage/_components/review/SeatingInformation.tsx index ae2a92b..7a17e94 100644 --- a/src/app/manage/_components/review/SeatingInformation.tsx +++ b/src/app/manage/_components/review/SeatingInformation.tsx @@ -1,6 +1,6 @@ "use client"; -import { SessionRequest, TierFormData} from "@/lib/validators/event"; +import { SessionDTO, TierFormData} from "@/lib/validators/event"; import * as React from "react"; import {MapContainer, TileLayer, Marker, Popup} from "react-leaflet"; import {Armchair, Users} from "lucide-react"; @@ -11,7 +11,7 @@ import L, {LatLngTuple} from "leaflet"; interface SeatingInformationProps { isOnline: boolean; - session: SessionRequest; + session: SessionDTO; tiers: TierFormData[]; } diff --git a/src/app/manage/_components/review/SeatingLayout.tsx b/src/app/manage/_components/review/SeatingLayout.tsx index dc08283..c1b8f44 100644 --- a/src/app/manage/_components/review/SeatingLayout.tsx +++ b/src/app/manage/_components/review/SeatingLayout.tsx @@ -2,12 +2,12 @@ import * as React from "react"; import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; import {Button} from "@/components/ui/button"; import {Badge} from "@/components/ui/badge"; -import {SessionRequest, TierFormData} from "@/lib/validators/event"; +import {SessionDTO, TierFormData} from "@/lib/validators/event"; import {getTierColor, getTierName} from "@/lib/utils"; import {SessionType} from "@/types/enums/sessionType"; interface SeatingLayoutProps { - session: SessionRequest; + session: SessionDTO; tiers: TierFormData[]; } diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx index 4a9498a..1d438f4 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx @@ -8,7 +8,7 @@ import {createDiscount, DiscountResponse, deleteDiscount, updateDiscount} from "@/lib/actions/discountActions"; import {toast} from "sonner"; import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; - import {DiscountRequest} from "@/lib/validators/event"; + import {DiscountDTO} from "@/lib/validators/event"; import { FullDiscountFormView } from "@/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view"; @@ -37,7 +37,7 @@ - const handleSaveDiscount = async (data: DiscountRequest) => { + const handleSaveDiscount = async (data: DiscountDTO) => { const action = mode === 'create' ? createDiscount(event!.id, data) : updateDiscount(event!.id, editingDiscount!.id, data); diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/CustomSeatingLayout.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/CustomSeatingLayout.tsx new file mode 100644 index 0000000..4a9c38c --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/CustomSeatingLayout.tsx @@ -0,0 +1,205 @@ +import * as React from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { SessionDTO, TierFormData } from "@/lib/validators/event"; +import { getTierColor, getTierName } from "@/lib/utils"; +import { SessionType } from "@/types/enums/sessionType"; +import { SeatStatus } from "@/types/enums/SeatStatus"; + +interface SeatingLayoutProps { + session: SessionDTO; + tiers: TierFormData[]; +} + +export const CustomSeatingLayout: React.FC = ({ session, tiers }) => { + const { layoutData } = session; + + // Only render for physical events with layout data + if (!layoutData || session.sessionType !== SessionType.PHYSICAL) return null; + + // Get status style for a seat + const getSeatStatusStyle = (status: string | undefined) => { + switch (status) { + case SeatStatus.RESERVED: + return { opacity: 0.3, cursor: 'not-allowed' }; + case SeatStatus.BOOKED: + return { opacity: 1, border: '2px solid #3B82F6', cursor: 'not-allowed' }; // Blue border for booked seats + default: + return { opacity: 1 }; + } + }; + + // Get variant for status badge + const getStatusVariant = (status: string | undefined) => { + switch (status) { + case SeatStatus.RESERVED: + return 'secondary'; + case SeatStatus.BOOKED: + return 'default'; + default: + return 'outline'; + } + }; + + // Get status label + const getStatusLabel = (status: string | undefined) => { + if (!status) return 'Available'; + + switch (status) { + case SeatStatus.RESERVED: + return 'Reserved'; + case SeatStatus.BOOKED: + return 'Booked'; + case SeatStatus.AVAILABLE: + return 'Available'; + default: + return status; + } + }; + + return ( +
+

Seating Layout

+
+ {layoutData.layout.blocks.map(block => { + // Get tier color for the block based on first seat's tier + const firstSeatTierId = block.seats?.[0]?.tierId; + const blockTierColor = firstSeatTierId ? + `${getTierColor(firstSeatTierId, session, tiers)}80` : // 50% opacity + undefined; + + return ( +
+ {block.type !== 'non_sellable' && ( +
{block.name}
+ )} + + {block.type === 'seated_grid' && block.rows && ( +
+ {block.rows.map(row => + row.seats.map(seat => { + const seatStatusStyle = getSeatStatusStyle(seat.status); + + return ( + + + + + +
+
Seat Information
+
+
+ Block: + {block.name} +
+
+ Row: + {row.label} +
+
+ Seat: + {seat.label} +
+
+ Status: + + {getStatusLabel(seat.status)} + +
+
+ Tier: +
+ {seat.tierId && ( +
+ )} + + {seat.tierId ? getTierName(seat.tierId, session, tiers) : 'Unassigned'} + +
+
+
+
+ + + ); + }) + )} +
+ )} + + {block.type === 'standing_capacity' && ( +
+

+ Standing Area + + Capacity: {block.capacity || 0} + +

+
+ )} + + {block.type === 'non_sellable' && ( +
+ {block.name} +
+ )} +
+ ) + })} +
+ + {/* Legend for seat status */} +
+
+
+
+ Available +
+
+
+ Reserved +
+
+
+ Booked +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatStatusSummary.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatStatusSummary.tsx new file mode 100644 index 0000000..be789cb --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatStatusSummary.tsx @@ -0,0 +1,125 @@ +// Define SeatStatusSummary component inline to avoid import issues +import * as React from "react"; +import {SeatStatus} from "@/types/enums/SeatStatus"; +import {SessionType} from "@/types/enums/sessionType"; +import {Armchair, Users} from "lucide-react"; +import {Badge} from "@/components/ui/badge"; +import {SessionDetailDTO} from "@/lib/validators/event"; + +interface SeatStatusSummaryProps { + session: SessionDetailDTO; +} + +export const SeatStatusSummary: React.FC = ({session}) => { + // Function to count seats by status + const getSeatCountByStatus = (): Record => { + const {layoutData} = session; + if (!layoutData || !layoutData.layout || !layoutData.layout.blocks) { + return {}; + } + + const statusCounts: Record = { + [SeatStatus.AVAILABLE]: 0, + [SeatStatus.RESERVED]: 0, + [SeatStatus.BOOKED]: 0, + }; + + layoutData.layout.blocks.forEach((block) => { + if (block.rows && block.rows.length > 0) { + // Count seats in rows for seated_grid blocks + block.rows.forEach((row) => { + row.seats.forEach((seat) => { + const status = seat.status || SeatStatus.AVAILABLE; + statusCounts[status] = (statusCounts[status] || 0) + 1; + }); + }); + } else if (block.seats && block.seats.length > 0) { + // Count direct seats array (for seated blocks without rows) + block.seats.forEach((seat) => { + const status = seat.status || SeatStatus.AVAILABLE; + statusCounts[status] = (statusCounts[status] || 0) + 1; + }); + } else if (block.type === "standing_capacity" && block.capacity) { + // For standing capacity blocks, all seats are considered available unless specified + statusCounts[SeatStatus.AVAILABLE] = + (statusCounts[SeatStatus.AVAILABLE] || 0) + (block.capacity || 0); + } + }); + + return statusCounts; + }; + + const seatCountByStatus = getSeatCountByStatus(); + const totalSeats = Object.values(seatCountByStatus).reduce((sum: number, count: number) => sum + count, 0); + + const getStatusColor = (status: string): string => { + switch (status) { + case SeatStatus.AVAILABLE: + return "bg-emerald-500"; + case SeatStatus.RESERVED: + return "bg-amber-500"; + case SeatStatus.BOOKED: + return "bg-blue-500"; + default: + return "bg-gray-400"; + } + }; + + const getStatusVariant = (status: string): "default" | "secondary" | "outline" => { + switch (status) { + case SeatStatus.AVAILABLE: + return "default"; + case SeatStatus.RESERVED: + return "secondary"; + case SeatStatus.BOOKED: + return "outline"; + default: + return "outline"; + } + }; + + const getStatusLabel = (status: string): string => { + switch (status) { + case SeatStatus.AVAILABLE: + return "Available"; + case SeatStatus.RESERVED: + return "Reserved"; + case SeatStatus.BOOKED: + return "Booked"; + default: + return status; + } + }; + + return ( +
+
+ {session.sessionType === SessionType.ONLINE ? ( + + ) : ( + + )} + Seat Status Summary +
+
+
Total seats: {totalSeats}
+
+ {Object.entries(seatCountByStatus).map(([status, count]) => ( + count > 0 && ( +
+
+ + + {getStatusLabel(status)}: {count} + + +
+ ) + ))} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionSeatingLayout.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionSeatingLayout.tsx new file mode 100644 index 0000000..37425a6 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionSeatingLayout.tsx @@ -0,0 +1,40 @@ +'use client'; + +import {SessionDetailDTO, TierDTO} from "@/lib/validators/event"; +import * as React from "react"; +import {CustomSeatingLayout} from "./CustomSeatingLayout"; +import {SeatingCapacitySummary} from "./seatingCapacitySummary"; +import { + SeatStatusSummary +} from "@/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatStatusSummary"; + +interface SessionSeatingLayoutProps { + session: SessionDetailDTO; + tiers: TierDTO[]; +} + +export const SessionSeatingLayout: React.FC = ({ session, tiers }) => { + const { layoutData } = session; + + if (!layoutData || !layoutData.layout || layoutData.layout.blocks.length === 0) { + return null; + } + + return ( +
+ {/* Summaries displayed horizontally */} +
+ {/* Capacity summary by tier */} + + + {/* Status summary */} + +
+ + {/* Full-width seating layout with support for booked seats */} +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/seatingCapacitySummary.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/seatingCapacitySummary.tsx new file mode 100644 index 0000000..dc534b1 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/seatingCapacitySummary.tsx @@ -0,0 +1,90 @@ +// Create a component for displaying seating capacity summary +import {SessionDetailDTO, TierDTO} from "@/lib/validators/event"; +import {SessionType} from "@/types/enums/sessionType"; +import {Armchair, Users} from "lucide-react"; +import {getTierColor, getTierName} from "@/lib/utils"; +import React from "react"; + +export const SeatingCapacitySummary = ({session, tiers}: { session: SessionDetailDTO, tiers: TierDTO[] }) => { + // Function to count seats by tier + const getSeatCountByTier = (): Record => { + const {layoutData} = session; + if (!layoutData || !layoutData.layout || !layoutData.layout.blocks) { + return {}; + } + + const tierCounts: Record = {}; + + layoutData.layout.blocks.forEach((block) => { + if (block.rows && block.rows.length > 0) { + // Count seats in rows for seated_grid blocks + block.rows.forEach((row) => { + row.seats.forEach((seat) => { + if (seat.status !== "RESERVED") { + const tierId = seat.tierId || "unassigned"; + tierCounts[tierId] = (tierCounts[tierId] || 0) + 1; + } + }); + }); + } else if (block.seats && block.seats.length > 0) { + // Count direct seats array (for seated blocks without rows) + block.seats.forEach((seat) => { + if (seat.status !== "RESERVED") { + const tierId = seat.tierId || "unassigned"; + tierCounts[tierId] = (tierCounts[tierId] || 0) + 1; + } + }); + } else if (block.type === "standing_capacity" && block.capacity) { + // Get tier for standing capacity blocks + let tierId = "unassigned"; + + // Check if block has seats array with tier information + if (block.seats && block.seats.length > 0 && block.seats[0].tierId) { + tierId = block.seats[0].tierId; + } + + // Add the capacity to the tier count + tierCounts[tierId] = (tierCounts[tierId] || 0) + (block.capacity || 0); + } + }); + + return tierCounts; + }; + + const seatCountByTier = getSeatCountByTier(); + const totalSeats = Object.values(seatCountByTier).reduce((sum: number, count: number) => sum + count, 0); + + return ( +
+
+ {session.sessionType === SessionType.ONLINE ? ( + + ) : ( + + )} + Capacity Information +
+
+
Total capacity: {totalSeats}
+
+ {Object.entries(seatCountByTier).map(([tierId, count]) => ( +
+ {tierId !== "unassigned" && ( +
+ )} + + {getTierName(tierId, session, tiers)}: {count}{" "} + {count === 1 ? "seat" : "seats"} + +
+ ))} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx new file mode 100644 index 0000000..c4c7a34 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -0,0 +1,382 @@ +'use client' + +import React, { useState } from 'react'; +import { useEventContext } from "@/providers/EventProvider"; +import { useParams, useRouter } from "next/navigation"; +import { format, parseISO } from 'date-fns'; +import { AlertTriangle, Calendar, Clock, LinkIcon, MapPin, Share2, Tag, Trash2 } from 'lucide-react'; +import { Button } from "@/components/ui/button"; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { SessionType } from "@/types/enums/sessionType"; +import dynamic from "next/dynamic"; +import { deleteSession } from "@/lib/actions/sessionActions"; +import { toast } from "sonner"; +import "leaflet/dist/leaflet.css"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { SessionSeatingLayout } from "./_components/SessionSeatingLayout"; +import { SeatingCapacitySummary } from "./_components/seatingCapacitySummary"; + +// Dynamically load just the map component for venue details +const VenueMap = dynamic( + () => import("react-leaflet").then(mod => { + // Create a custom component that just renders the map + const MapComponent = ({ center, venueName }: { center: [number, number], venueName: string }) => { + const { MapContainer, TileLayer, Marker, Popup } = mod; + + // Fix default marker icons (required for Leaflet in Next.js) + React.useEffect(() => { + const L = require("leaflet"); + delete L.Icon.Default.prototype._getIconUrl; + L.Icon.Default.mergeOptions({ + iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png", + iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png", + shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png" + }); + }, []); + + return ( + + + + {venueName || "Event Venue"} + + + ); + }; + return MapComponent; + }), + { ssr: false } +); + +const SessionPage = () => { + const params = useParams(); + const sessionId = params.sessionId as string; + const router = useRouter(); + const { event, refetchEventData } = useEventContext(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + if (!event) { + return ( +
+ Event Not Found +
+ ); + } + + const session = event.sessions.find(s => s.id === sessionId); + + if (!session) { + return ( +
+ Session Not Found +
+ ); + } + + const startDate = parseISO(session.startTime); + const endDate = parseISO(session.endTime); + const salesStartDate = session.salesStartTime ? parseISO(session.salesStartTime) : null; + const isOnline = session.sessionType === SessionType.ONLINE; + const { venueDetails, layoutData } = session; + + // For the map - set default to Colombo if no coordinates + const mapCenter: [number, number] = + venueDetails?.latitude && venueDetails?.longitude + ? [venueDetails.latitude, venueDetails.longitude] + : [6.9271, 79.8612]; // Default to Colombo + + // Calculate event duration + const getDuration = () => { + const durationMs = endDate.getTime() - startDate.getTime(); + const hours = Math.floor(durationMs / (1000 * 60 * 60)); + const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); + + return hours > 0 + ? `${hours} hour${hours !== 1 ? 's' : ''}${minutes > 0 ? ` ${minutes} min` : ''}` + : `${minutes} minutes`; + }; + + // Handle share + const handleShare = async () => { + try { + const shareUrl = window.location.href; + if (navigator.share) { + await navigator.share({ + title: `${event.title} - ${format(startDate, 'MMM d, yyyy')}`, + text: `Join us for ${event.title} on ${format(startDate, 'EEEE, MMMM d, yyyy')}`, + url: shareUrl, + }); + } else { + await navigator.clipboard.writeText(shareUrl); + toast.success('Session URL copied to clipboard!'); + } + } catch (error) { + console.error('Error sharing:', error); + } + }; + + // Handle delete + const handleDelete = async () => { + try { + setIsDeleting(true); + await deleteSession(sessionId); + toast.success('Session deleted successfully'); + router.push(`/manage/organization/${params.organization_id}/event/${params.eventId}`); + await refetchEventData(); + } catch (error) { + console.error('Error deleting session:', error); + toast.error('Failed to delete session'); + } finally { + setIsDeleting(false); + setIsDeleteDialogOpen(false); + } + }; + + return ( +
+ {/* Action buttons and header */} +
+
+
+

{event.title}

+ + {isOnline ? 'Online' : 'Physical'} + +
+

+ Session on {format(startDate, 'EEEE, MMMM d, yyyy')} +

+
+
+ + +
+
+ + + + {/* Session Metadata Section */} + + + + + Session Information + + + +
+
+
+
Date
+
{format(startDate, 'EEEE, MMMM d, yyyy')}
+
+ +
+
Start Time
+
+ + {format(startDate, 'h:mm a')} +
+
+ +
+
End Time
+
+ + {format(endDate, 'h:mm a')} +
+
+ +
+
Duration
+
+ + {getDuration()} +
+
+
+ +
+
+
Sales Start Time
+ {salesStartDate ? ( +
+
+ + {format(salesStartDate, 'MMM d, yyyy h:mm a')} +
+
+ ) : ( + + + + Sales start time not set + + + )} +
+
+
+
+
+ + {/* Venue Information Section */} + + + + {isOnline ? ( + <> + + Online Event Details + + ) : ( + <> + + Venue Details + + )} + + + + {isOnline ? ( +
+
Online Link
+ {venueDetails?.onlineLink ? ( +
{venueDetails.onlineLink}
+ ) : ( + + + + Online link not provided + + + )} + {/* Capacity summary for online events */} + {layoutData && layoutData.layout.blocks.length > 0 && ( +
+ +
+ )} +
+ ) : ( + <> +
+
+
+
Venue Name
+ {venueDetails?.name ? ( +
{venueDetails.name}
+ ) : ( +
Not specified
+ )} +
+ + {venueDetails?.address && ( +
+
Address
+
{venueDetails.address}
+
+ )} +
+ + {/* Map for physical events */} +
+ +
+
+ + {/* Add a note about the map */} +
+ Map shows approximate location based on venue coordinates +
+ + )} +
+
+ + {/* Seating Layout Section */} + {!isOnline && layoutData && layoutData.layout.blocks.length > 0 && ( + + + Seating Layout + + +
+ +
+
+
+ )} + + {/* Delete Confirmation Dialog */} + + + + Are you sure you want to delete this session? + + This action cannot be undone. This will permanently delete the session + and all its associated data. + + + + Cancel + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + +
+ ); +}; + +export default SessionPage; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index 5029a32..fcb3e2c 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -10,7 +10,7 @@ import { } from "lucide-react" import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu" import {DiscountType} from "@/types/enums/discountType"; -import {DiscountRequest, SessionRequest, TierRequest} from "@/lib/validators/event"; +import {DiscountDTO, SessionDTO, TierDTO} from "@/lib/validators/event"; import {toast} from "sonner"; import {format} from "date-fns"; import {getDiscountValue} from "@/lib/discountUtils"; @@ -26,12 +26,12 @@ import { // --- Component Props --- interface DiscountCardProps { - discount: DiscountRequest, - tiers: TierRequest[], - sessions?: SessionRequest[], + discount: DiscountDTO, + tiers: TierDTO[], + sessions?: SessionDTO[], onDelete?: (id: string) => void, onToggleStatus?: (id: string) => void, - onEdit?: (discount: DiscountRequest) => void, + onEdit?: (discount: DiscountDTO) => void, isReadOnly?: boolean, } diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx index eef36fd..160ad01 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-code-form.tsx @@ -12,10 +12,10 @@ import {Switch} from "@/components/ui/switch" import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" import {CalendarIcon, Percent, DollarSign, Gift} from "lucide-react" import { - DiscountRequest, + DiscountDTO, discountSchema, - SessionRequest, - TierRequest + SessionDTO, + TierDTO } from "@/lib/validators/event" import {TierSelector} from "./tier-selector" import {SessionSelector} from "./session-selector" @@ -24,12 +24,12 @@ import {toast} from "sonner"; import {formatToDateTimeLocalString} from "@/lib/utils"; interface DiscountCodeFormProps { - tiers: TierRequest[], - sessions: SessionRequest[], - onSave: (discount: DiscountRequest) => void, + tiers: TierDTO[], + sessions: SessionDTO[], + onSave: (discount: DiscountDTO) => void, isQuickCreate?: boolean, isEditing?: boolean, - initialData?: DiscountRequest, + initialData?: DiscountDTO, } type PercentageParams = { @@ -97,7 +97,7 @@ export function DiscountCodeForm({ return; } - onSave(validatedResult.data as DiscountRequest) + onSave(validatedResult.data as DiscountDTO) // ✅ UX IMPROVEMENT: Reset the local form for the next entry form.reset({ diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog.tsx index 6569170..2d7dc1a 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-form-dialog.tsx @@ -8,7 +8,7 @@ import { DialogDescription } from "@/components/ui/dialog"; import { DiscountCodeForm } from "./discount-code-form"; -import { DiscountRequest, SessionRequest, TierRequest } from "@/lib/validators/event"; +import { DiscountDTO, SessionDTO, TierDTO } from "@/lib/validators/event"; import {createDiscount, DiscountResponse, updateDiscount} from "@/lib/actions/discountActions"; import { toast } from "sonner"; @@ -19,8 +19,8 @@ interface DiscountFormDialogProps { onSuccess: () => void; initialData?: DiscountResponse; eventId: string; - tiers: TierRequest[]; - sessions: SessionRequest[]; + tiers: TierDTO[]; + sessions: SessionDTO[]; } export const DiscountFormDialog = ({ @@ -34,7 +34,7 @@ export const DiscountFormDialog = ({ sessions, }: DiscountFormDialogProps) => { - const handleSave = async (data: DiscountRequest) => { + const handleSave = async (data: DiscountDTO) => { const action = mode === 'create' ? createDiscount(eventId, data) : updateDiscount(eventId, initialData!.id, data); diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx index 438336b..cf5aa85 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx @@ -6,16 +6,16 @@ import {Input} from "@/components/ui/input" import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" import {Search, Filter} from "lucide-react" import {DiscountType} from "@/types/enums/discountType"; -import {DiscountRequest, SessionRequest, TierRequest} from "@/lib/validators/event"; +import {DiscountDTO, SessionDTO, TierDTO} from "@/lib/validators/event"; import {DiscountCard} from "./discount-card"; interface DiscountListProps { - tiers: TierRequest[], - sessions?: SessionRequest[], - discounts?: DiscountRequest[], + tiers: TierDTO[], + sessions?: SessionDTO[], + discounts?: DiscountDTO[], onDelete?: (id: string) => void, onToggleStatus?: (id: string) => void, - onEdit?: (discount: DiscountRequest) => void, + onEdit?: (discount: DiscountDTO) => void, filters?: boolean, isReadOnly?: boolean, } diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-share-dialog.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-share-dialog.tsx index 2978f98..886d302 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-share-dialog.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-share-dialog.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { SessionRequest, DiscountRequest } from "@/lib/validators/event"; +import { SessionDTO, DiscountDTO } from "@/lib/validators/event"; import { ShareComponent } from "@/components/ui/share/share-component"; import { useEventContext } from "@/providers/EventProvider"; import { format } from "date-fns"; @@ -13,8 +13,8 @@ import { format } from "date-fns"; interface DiscountShareDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - discount: DiscountRequest; - sessions?: SessionRequest[]; + discount: DiscountDTO; + sessions?: SessionDTO[]; } export function DiscountShareDialog({ diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx index 7e2ce8d..c82790b 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx @@ -2,7 +2,7 @@ import { DiscountList } from "./discount-list"; import { useFieldArray, useFormContext } from "react-hook-form"; -import {CreateEventFormData, DiscountRequest, discountSchema} from "@/lib/validators/event"; +import {CreateEventFormData, DiscountDTO, discountSchema} from "@/lib/validators/event"; import {useEffect, useState} from "react"; import { FullDiscountFormView } from "./full-discount-form-view"; import { Button } from "@/components/ui/button"; @@ -14,7 +14,7 @@ interface DiscountStepProps { export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { const [view, setView] = useState<'list' | 'create' | 'edit'>('list'); - const [editingDiscount, setEditingDiscount] = useState(null); + const [editingDiscount, setEditingDiscount] = useState(null); const { control, watch } = useFormContext(); @@ -37,12 +37,12 @@ export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { // --- Event Handlers --- - const handleAddDiscount = (discount: DiscountRequest) => { + const handleAddDiscount = (discount: DiscountDTO) => { append(discount); setView('list'); } - const handleUpdateDiscount = (discount: DiscountRequest) => { + const handleUpdateDiscount = (discount: DiscountDTO) => { const index = discountFields.findIndex(item => item.id === discount.id); if (index !== -1) { update(index, discount); @@ -66,7 +66,7 @@ export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { } } - const handleGoToEditView = (discount: DiscountRequest) => { + const handleGoToEditView = (discount: DiscountDTO) => { setEditingDiscount(discount); setView('edit'); } diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx index ff26b8a..4a5f97a 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view.tsx @@ -3,15 +3,15 @@ import { Button } from "@/components/ui/button" import { ChevronLeft } from "lucide-react" import { DiscountCodeForm } from "./discount-code-form" -import { DiscountRequest, SessionRequest, TierRequest} from "@/lib/validators/event"; +import { DiscountDTO, SessionDTO, TierDTO} from "@/lib/validators/event"; interface FullDiscountFormViewProps { - tiers: TierRequest[], - sessions: SessionRequest[], - onSave: (discount: DiscountRequest) => void, + tiers: TierDTO[], + sessions: SessionDTO[], + onSave: (discount: DiscountDTO) => void, onBack: () => void, isEditing?: boolean, - initialData?: DiscountRequest, + initialData?: DiscountDTO, } export function FullDiscountFormView({ tiers, sessions, onSave, onBack, isEditing, initialData }: FullDiscountFormViewProps) { return ( diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx index 3885fe1..024a997 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/review-discounts.tsx @@ -1,13 +1,13 @@ 'use client'; import * as React from 'react'; -import {DiscountRequest, SessionRequest, TierRequest} from '@/lib/validators/event'; +import {DiscountDTO, SessionDTO, TierDTO} from '@/lib/validators/event'; import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; interface DiscountReviewProps { - tiers: TierRequest[], - sessions?: SessionRequest[], - discounts?: DiscountRequest[], + tiers: TierDTO[], + sessions?: SessionDTO[], + discounts?: DiscountDTO[], } export const DiscountReview: React.FC = ({tiers, sessions, discounts}) => { diff --git a/src/app/manage/organization/[organization_id]/event/_components/sessions/session-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/sessions/session-card.tsx index 7fc29eb..ac6d905 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/sessions/session-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/sessions/session-card.tsx @@ -1,4 +1,4 @@ -import {SessionDetailDTO} from "@/lib/validators/event"; +import {SessionDetailDTO, TierDTO} from "@/lib/validators/event"; import * as React from "react"; import {useState} from "react"; import {useEventContext} from "@/providers/EventProvider"; @@ -8,6 +8,7 @@ import {SessionType} from "@/types/enums/sessionType"; import {Card, CardContent, CardHeader} from "@/components/ui/card"; import {Badge} from "@/components/ui/badge"; import {SessionStatus} from "@/types/enums/sessionStatus"; +import {useRouter} from "next/navigation"; import {Button} from "@/components/ui/button"; import {Calendar, Clock, Edit, LinkIcon, MapPin, MoreHorizontal, RefreshCcw, Share2, Trash2} from "lucide-react"; import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; @@ -32,13 +33,16 @@ interface SessionCardProps { export const SessionCard: React.FC = ({session}) => { const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); - const {refetchSession, loadingSessionId} = useEventContext(); + const {refetchSession, loadingSessionId, event} = useEventContext(); + const router = useRouter(); const isRefreshing = loadingSessionId === session.id; + const organizationId = event?.organizationId; + const eventId = event?.id; const handleEdit = () => { - // Placeholder for edit functionality - toast.info(`Edit session ${session.id} (Dummy function)`); + // Navigate to the session detail page using Next.js router + router.push(`/manage/organization/${organizationId}/event/${eventId}/sessions/${session.id}`); }; const handleDelete = () => { @@ -70,8 +74,6 @@ export const SessionCard: React.FC = ({session}) => { } }; - const {event: eventContext} = useEventContext(); - // Get seating summary by tier const getSeatingDetails = () => { if (!session.layoutData?.layout?.blocks?.length) return { @@ -126,7 +128,7 @@ export const SessionCard: React.FC = ({session}) => { // Convert tier counts record to byTier array with tier names and colors const byTier: { tier: string; count: number; color?: string }[] = []; - if (eventContext?.tiers) { + if (event?.tiers) { Object.entries(tierCountsRecord).forEach(([tierId, count]) => { if (tierId === "unassigned") { byTier.push({ @@ -134,7 +136,7 @@ export const SessionCard: React.FC = ({session}) => { count }); } else { - const tier = eventContext.tiers.find(t => t.id === tierId); + const tier = event.tiers.find((t: TierDTO) => t.id === tierId); if (tier) { byTier.push({ tier: tier.name, @@ -159,9 +161,17 @@ export const SessionCard: React.FC = ({session}) => { const seatingDetails = getSeatingDetails(); + const navigateToSessionDetails = () => { + router.push(`/manage/organization/${organizationId}/event/${eventId}/sessions/${session.id}`); + }; + return ( <> - +
@@ -192,7 +202,10 @@ export const SessionCard: React.FC = ({session}) => { type={'button'} variant="outline" size="sm" - onClick={handleRefreshSession} + onClick={(e) => { + e.stopPropagation(); + handleRefreshSession(); + }} disabled={isRefreshing} > @@ -201,18 +214,28 @@ export const SessionCard: React.FC = ({session}) => { type={'button'} variant="outline" size="sm" - onClick={() => setIsShareDialogOpen(true)} + onClick={(e) => { + e.stopPropagation(); + setIsShareDialogOpen(true); + }} > - - - + e.stopPropagation()}> + { + e.stopPropagation(); + handleEdit(); + }}> Edit @@ -220,6 +243,7 @@ export const SessionCard: React.FC = ({session}) => { className="text-destructive" onSelect={(e) => { e.preventDefault(); + e.stopPropagation(); }} > diff --git a/src/lib/actions/discountActions.ts b/src/lib/actions/discountActions.ts index 5df2d26..100a504 100644 --- a/src/lib/actions/discountActions.ts +++ b/src/lib/actions/discountActions.ts @@ -1,9 +1,9 @@ import { apiFetch } from '@/lib/api'; -import {DiscountRequest} from "@/lib/validators/event"; +import {DiscountDTO} from "@/lib/validators/event"; const API_BASE_PATH = '/event-seating/v1/events'; -export type DiscountResponse = DiscountRequest & {} +export type DiscountResponse = DiscountDTO & {} /** * Creates a new discount for an event. @@ -12,7 +12,7 @@ export type DiscountResponse = DiscountRequest & {} * @param discountData The discount data to create * @returns The created discount details */ -export const createDiscount = (eventId: string, discountData: DiscountRequest): Promise => { +export const createDiscount = (eventId: string, discountData: DiscountDTO): Promise => { return apiFetch(`${API_BASE_PATH}/${eventId}/discounts`, { method: 'POST', body: JSON.stringify(discountData), @@ -61,7 +61,7 @@ export const getDiscount = (eventId: string, discountId: string): Promise => { return apiFetch(`${API_BASE_PATH}/${eventId}/discounts/${discountId}`, { method: 'PUT', diff --git a/src/lib/actions/sessionActions.ts b/src/lib/actions/sessionActions.ts index 7be2cc4..af978fc 100644 --- a/src/lib/actions/sessionActions.ts +++ b/src/lib/actions/sessionActions.ts @@ -1,6 +1,6 @@ import { apiFetch } from '@/lib/api'; import { - SessionRequest, + SessionDTO, SessionDetailDTO, VenueDetails, SessionSeatingMapRequest, @@ -23,7 +23,7 @@ export interface SessionBatchResponse { */ export interface CreateSessionsRequest { eventId: string; - sessions: SessionRequest[]; + sessions: SessionDTO[]; } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4697e13..93e8200 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,12 +1,12 @@ import {type ClassValue, clsx} from "clsx" import {twMerge} from "tailwind-merge" -import {SessionRequest, TierFormData} from "@/lib/validators/event"; +import {SessionDTO, TierFormData} from "@/lib/validators/event"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export const getTierColor = (tierId: string, session: SessionRequest, tiers: TierFormData[]): string => { +export const getTierColor = (tierId: string, session: SessionDTO, tiers: TierFormData[]): string => { if (tierId === 'unassigned') return '#d1d5db'; // gray-300 // We need to check if tiers exist in the session @@ -14,7 +14,7 @@ export const getTierColor = (tierId: string, session: SessionRequest, tiers: Tie return tier?.color || '#6b7280'; // gray-500 as fallback }; // Helper to get tier name -export const getTierName = (tierId: string, session: SessionRequest, tiers: TierFormData[]): string => { +export const getTierName = (tierId: string, session: SessionDTO, tiers: TierFormData[]): string => { if (tierId === 'unassigned') return 'Unassigned'; const tier = tiers.find(t => t.id === tierId); diff --git a/src/lib/validators/event.ts b/src/lib/validators/event.ts index 4256db4..1c06037 100644 --- a/src/lib/validators/event.ts +++ b/src/lib/validators/event.ts @@ -2,6 +2,7 @@ import {z} from 'zod'; import {SessionType} from "@/types/enums/sessionType"; import {DiscountType} from "@/types/enums/discountType"; +import { SeatStatus } from '@/types/enums/SeatStatus'; // --- Reusable Atomic Schemas --- @@ -122,7 +123,7 @@ const seatSchema = z.object({ id: z.uuid(), label: z.string(), tierId: z.string().optional(), - status: z.enum(['AVAILABLE', 'RESERVED']).optional(), + status: z.enum([SeatStatus.AVAILABLE, SeatStatus.RESERVED, SeatStatus.BOOKED]).default(SeatStatus.AVAILABLE), }); const rowSchema = z.object({ @@ -320,16 +321,16 @@ export type SessionBasicData = z.infer; export type SessionWithVenueData = z.infer; export type SessionWithSeatingData = z.infer; export type SessionFormData = z.input; -export type SessionRequest = z.infer; +export type SessionDTO = z.infer; export type TierFormData = z.input; -export type TierRequest = z.infer; +export type TierDTO = z.infer; export type VenueDetails = z.infer; export type Block = z.infer; export type Seat = z.infer; export type SessionSeatingMapRequest = z.infer; export type Row = z.infer; export type DiscountFormData = z.input; -export type DiscountRequest = z.infer; +export type DiscountDTO = z.infer; export type DiscountParameters = z.infer; diff --git a/src/types/enums/SeatStatus.ts b/src/types/enums/SeatStatus.ts new file mode 100644 index 0000000..5f6bfc9 --- /dev/null +++ b/src/types/enums/SeatStatus.ts @@ -0,0 +1,5 @@ +export enum SeatStatus { + AVAILABLE = 'AVAILABLE', + RESERVED = 'RESERVED', + BOOKED = 'BOOKED', +} \ No newline at end of file From c85bca7a4eab22dd8a5cf2eb854f159d5cfd739a Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Tue, 7 Oct 2025 14:03:43 +0530 Subject: [PATCH 20/51] Enhance session page with status handling and tooltips for session type --- .../[eventId]/sessions/[sessionId]/page.tsx | 131 ++++++++++++++---- 1 file changed, 103 insertions(+), 28 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx index c4c7a34..de68db3 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -10,7 +10,9 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { SessionType } from "@/types/enums/sessionType"; +import { SessionStatus } from "@/types/enums/sessionStatus"; import dynamic from "next/dynamic"; import { deleteSession } from "@/lib/actions/sessionActions"; import { toast } from "sonner"; @@ -67,6 +69,26 @@ const VenueMap = dynamic( { ssr: false } ); +// Helper function to get status badge variant and color +const getStatusProperties = (status: string | undefined) => { + switch (status) { + case SessionStatus.PENDING: + return { variant: "outline" as const, color: "text-amber-500", icon: AlertTriangle }; + case SessionStatus.SCHEDULED: + return { variant: "secondary" as const, color: "text-blue-500", icon: Calendar }; + case SessionStatus.ON_SALE: + return { variant: "default" as const, color: "text-green-500", icon: Tag }; + case SessionStatus.SOLD_OUT: + return { variant: "secondary" as const, color: "text-purple-500", icon: Tag }; + case SessionStatus.CLOSED: + return { variant: "outline" as const, color: "text-gray-500", icon: Clock }; + case SessionStatus.CANCELED: + return { variant: "destructive" as const, color: "text-destructive", icon: AlertTriangle }; + default: + return { variant: "outline" as const, color: "text-muted-foreground", icon: Calendar }; + } +}; + const SessionPage = () => { const params = useParams(); const sessionId = params.sessionId as string; @@ -97,7 +119,8 @@ const SessionPage = () => { const endDate = parseISO(session.endTime); const salesStartDate = session.salesStartTime ? parseISO(session.salesStartTime) : null; const isOnline = session.sessionType === SessionType.ONLINE; - const { venueDetails, layoutData } = session; + const { venueDetails, layoutData, status } = session; + const statusProps = getStatusProperties(status); // For the map - set default to Colombo if no coordinates const mapCenter: [number, number] = @@ -108,12 +131,17 @@ const SessionPage = () => { // Calculate event duration const getDuration = () => { const durationMs = endDate.getTime() - startDate.getTime(); - const hours = Math.floor(durationMs / (1000 * 60 * 60)); + const days = Math.floor(durationMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((durationMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); - return hours > 0 - ? `${hours} hour${hours !== 1 ? 's' : ''}${minutes > 0 ? ` ${minutes} min` : ''}` - : `${minutes} minutes`; + if (days > 0) { + return `${days} day${days !== 1 ? 's' : ''}${hours > 0 ? `, ${hours} hour${hours !== 1 ? 's' : ''}` : ''}${minutes > 0 ? `, ${minutes} min` : ''}`; + } else if (hours > 0) { + return `${hours} hour${hours !== 1 ? 's' : ''}${minutes > 0 ? `, ${minutes} min` : ''}`; + } else { + return `${minutes} minutes`; + } }; // Handle share @@ -158,13 +186,28 @@ const SessionPage = () => {
+ + + +
+ {isOnline ? + : + + } +
+
+ +

{isOnline ? 'Online Session' : 'Physical Session'}

+
+
+

{event.title}

- - {isOnline ? 'Online' : 'Physical'} -

- Session on {format(startDate, 'EEEE, MMMM d, yyyy')} + {startDate.toDateString() === endDate.toDateString() + ? `Session on ${format(startDate, 'EEEE, MMMM d, yyyy')}` + : `Session from ${format(startDate, 'MMM d')} to ${format(endDate, 'MMM d, yyyy')}` + }

@@ -182,6 +225,7 @@ const SessionPage = () => { size="sm" className="flex items-center gap-1" onClick={() => setIsDeleteDialogOpen(true)} + disabled={status === SessionStatus.ON_SALE || status === SessionStatus.SOLD_OUT} > Delete @@ -189,29 +233,54 @@ const SessionPage = () => {
+ {/* Status banner */} +
+
+ {React.createElement(statusProps.icon, { className: "h-5 w-5" })} +
+
+

+ Session Status: + + {status || 'PENDING'} + +

+

+ {status === SessionStatus.PENDING && "This session is pending and can be edited."} + {status === SessionStatus.SCHEDULED && "This session is scheduled and some fields can still be edited."} + {status === SessionStatus.ON_SALE && "This session is on sale. Limited editing is available."} + {status === SessionStatus.SOLD_OUT && "This session is sold out. Limited editing is available."} + {status === SessionStatus.CLOSED && "This session is closed and can't be edited."} + {status === SessionStatus.CANCELED && "This session has been canceled."} +

+
+
+ {/* Session Metadata Section */} - + Session Information + + {React.createElement(statusProps.icon, { className: "h-3 w-3 mr-1 inline" })} + {status || 'PENDING'} +
-
-
Date
-
{format(startDate, 'EEEE, MMMM d, yyyy')}
-
-
Start Time
- {format(startDate, 'h:mm a')} + {format(startDate, 'EEEE, MMMM d, yyyy h:mm a')}
@@ -219,7 +288,7 @@ const SessionPage = () => {
End Time
- {format(endDate, 'h:mm a')} + {format(endDate, 'EEEE, MMMM d, yyyy h:mm a')}
@@ -260,17 +329,23 @@ const SessionPage = () => { - {isOnline ? ( - <> - - Online Event Details - - ) : ( - <> - - Venue Details - - )} + + + +
+ {isOnline ? ( + + ) : ( + + )} +
+
+ +

{isOnline ? 'Online Session' : 'Physical Session'}

+
+
+
+ {isOnline ? 'Online Event Details' : 'Venue Details'}
From fc53f1ef372acd48d2d37e2b57789c47a62d97d9 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Tue, 7 Oct 2025 14:15:09 +0530 Subject: [PATCH 21/51] Refactor session action types and add layout update functionality --- src/lib/actions/sessionActions.ts | 132 ++++++++++++++++++------------ 1 file changed, 79 insertions(+), 53 deletions(-) diff --git a/src/lib/actions/sessionActions.ts b/src/lib/actions/sessionActions.ts index af978fc..d3359d3 100644 --- a/src/lib/actions/sessionActions.ts +++ b/src/lib/actions/sessionActions.ts @@ -1,11 +1,11 @@ -import { apiFetch } from '@/lib/api'; -import { - SessionDTO, - SessionDetailDTO, - VenueDetails, - SessionSeatingMapRequest, +import {apiFetch} from '@/lib/api'; +import { + SessionDTO, + SessionDetailDTO, + VenueDetails, + SessionSeatingMapRequest, } from "@/lib/validators/event"; -import { SessionStatus } from '@/types/enums/sessionStatus'; +import {SessionStatus} from '@/types/enums/sessionStatus'; const API_BASE_PATH = '/event-seating/v1'; @@ -13,139 +13,165 @@ const API_BASE_PATH = '/event-seating/v1'; * Response type for batch session creation */ export interface SessionBatchResponse { - eventId: string; - totalCreated: number; - sessions: SessionDetailDTO[]; + eventId: string; + totalCreated: number; + sessions: SessionDetailDTO[]; } /** * Request type for creating multiple sessions */ export interface CreateSessionsRequest { - eventId: string; - sessions: SessionDTO[]; + eventId: string; + sessions: SessionDTO[]; } /** * Request type for updating session time */ export interface SessionTimeUpdateRequest { - startTime: string; - endTime: string; - salesStartTime: string; + startTime: string; + endTime: string; + salesStartTime: string; } /** * Request type for updating session status */ export interface SessionStatusUpdateRequest { - status: SessionStatus; + status: SessionStatus; } /** - * Request type for updating session venue and seating + * Request type for updating session venue */ export interface SessionVenueUpdateRequest { - venueDetails: VenueDetails; - layoutData: SessionSeatingMapRequest; + venueDetails: VenueDetails; } +/** + * Request type for updating session seating + */ +export interface SessionLayoutUpdateRequest { + layoutData: SessionSeatingMapRequest; +} + + /** * Creates multiple sessions for an event. - * + * * @param createData Request containing event ID and session details * @returns Response with created sessions information */ export const createSessions = (createData: CreateSessionsRequest): Promise => { - return apiFetch(`${API_BASE_PATH}/sessions`, { - method: 'POST', - body: JSON.stringify(createData), - }); + return apiFetch(`${API_BASE_PATH}/sessions`, { + method: 'POST', + body: JSON.stringify(createData), + }); }; /** * Fetches a specific session by ID. - * + * * @param sessionId ID of the session to fetch * @returns The session details */ export const getSession = (sessionId: string): Promise => { - return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}`); + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}`); }; /** * Fetches all sessions for a specific event. - * + * * @param eventId ID of the event to fetch sessions for * @returns List of session details */ export const getSessionsByEventId = (eventId: string): Promise => { - return apiFetch(`${API_BASE_PATH}/sessions?eventId=${eventId}`); + return apiFetch(`${API_BASE_PATH}/sessions?eventId=${eventId}`); }; /** * Updates a session's time details (start time, end time, sales start time). * Only allowed for SCHEDULED and ON_SALE sessions. - * + * * @param sessionId ID of the session to update * @param timeData New time details * @returns The updated session details */ export const updateSessionTime = ( - sessionId: string, - timeData: SessionTimeUpdateRequest + sessionId: string, + timeData: SessionTimeUpdateRequest ): Promise => { - return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/time`, { - method: 'PUT', - body: JSON.stringify(timeData), - }); + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/time`, { + method: 'PUT', + body: JSON.stringify(timeData), + }); }; /** * Updates a session's status. * Valid transitions: SCHEDULED -> ON_SALE -> CLOSED - * + * * @param sessionId ID of the session to update * @param statusData New status data * @returns The updated session details */ export const updateSessionStatus = ( - sessionId: string, - statusData: SessionStatusUpdateRequest + sessionId: string, + statusData: SessionStatusUpdateRequest ): Promise => { - return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/status`, { - method: 'PUT', - body: JSON.stringify(statusData), - }); + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/status`, { + method: 'PUT', + body: JSON.stringify(statusData), + }); }; /** * Updates a session's venue details and seating layout. * Only allowed for SCHEDULED sessions. - * + * * @param sessionId ID of the session to update * @param venueData New venue details and seating layout * @returns The updated session details */ export const updateSessionVenue = ( - sessionId: string, - venueData: SessionVenueUpdateRequest + sessionId: string, + venueData: SessionVenueUpdateRequest +): Promise => { + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/venue-details`, { + method: 'PUT', + body: JSON.stringify(venueData), + }); +}; + + +/** + * Updates a session's venue details and seating layout. + * Only allowed for SCHEDULED sessions. + * + * @param sessionId ID of the session to update + * @param layout New seating layout data + * @returns The updated session details + */ +export const updateSessionLayout = ( + sessionId: string, + layout: SessionLayoutUpdateRequest ): Promise => { - return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/venue`, { - method: 'PUT', - body: JSON.stringify(venueData), - }); + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}/layout`, { + method: 'PUT', + body: JSON.stringify(layout), + }); }; /** * Deletes a session. * Only allowed before sales start. - * + * * @param sessionId ID of the session to delete * @returns void as the endpoint returns no content */ export const deleteSession = (sessionId: string): Promise => { - return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}`, { - method: 'DELETE', - }); + return apiFetch(`${API_BASE_PATH}/sessions/${sessionId}`, { + method: 'DELETE', + }); }; \ No newline at end of file From 71b7391cb028c2e31e740f1c2d6de3724fc56b67 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Tue, 7 Oct 2025 15:22:11 +0530 Subject: [PATCH 22/51] Add LocationEditDialog component for managing venue details in sessions --- .../_components/LocationEditDialog.tsx | 169 +++++++++++++++++ .../event/_components/PhysicalConfigView.tsx | 5 +- .../event/_components/SchedulingStep.tsx | 2 +- .../event/_components/SessionListItem.tsx | 174 +++++++++--------- .../_components/SessionListItemSeating.tsx | 4 +- src/lib/utils.ts | 40 ++++ 6 files changed, 305 insertions(+), 89 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/LocationEditDialog.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/LocationEditDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/LocationEditDialog.tsx new file mode 100644 index 0000000..5e81d17 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/LocationEditDialog.tsx @@ -0,0 +1,169 @@ +'use client' + +import * as React from 'react'; +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet'; +import L, { LatLngLiteral } from 'leaflet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Skeleton } from '@/components/ui/skeleton'; +import 'leaflet/dist/leaflet.css'; +import { SessionType } from "@/types/enums/sessionType"; +import * as z from 'zod'; +import {VenueDetails} from "@/lib/validators/event"; + + +// --- Component Props --- +interface LocationConfigDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + sessionType: SessionType; + initialData?: VenueDetails; + onSave: (venueDetails: VenueDetails) => void; // Simplified onSave + sessionIndex: number; +} + +// --- Leaflet Icon Fix & Map Helpers --- +const DEFAULT_MAP_CENTER: LatLngLiteral = { lat: 6.9271, lng: 79.8612 }; +interface IconDefaultPrototype extends L.Icon.Default { _getIconUrl?: unknown; } +delete (L.Icon.Default.prototype as IconDefaultPrototype)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png" +}); + +function LocationMarker({ setMarkerPosition }: { setMarkerPosition: (pos: LatLngLiteral) => void }) { + useMapEvents({ click(e) { setMarkerPosition(e.latlng); } }); + return null; +} + +// --- The Standalone Dialog Component --- +export function LocationEditDialog({ + open, + onOpenChange, + sessionType, + initialData, + onSave, + sessionIndex + }: LocationConfigDialogProps) { + + const [localFormState, setLocalFormState] = useState({}); + const [localErrors, setLocalErrors] = useState<{ venueName?: string; onlineLink?: string }>({}); + const [markerPosition, setMarkerPosition] = useState(DEFAULT_MAP_CENTER); + + useEffect(() => { + if (open) { + const data = initialData || {}; + setLocalFormState(data); + + const position = { + lat: data.latitude ?? DEFAULT_MAP_CENTER.lat, + lng: data.longitude ?? DEFAULT_MAP_CENTER.lng, + }; + setMarkerPosition(position); + setLocalErrors({}); + } + }, [open, initialData]); + + const validateLocalState = (): boolean => { + const errors: { venueName?: string; onlineLink?: string } = {}; + if (sessionType === SessionType.PHYSICAL && !localFormState.name) { + errors.venueName = "Venue name is required"; + } + if (sessionType === SessionType.ONLINE) { + if (!localFormState.onlineLink) { + errors.onlineLink = "Online link is required"; + } else { + const result = z.string().url().safeParse(localFormState.onlineLink); + if (!result.success) { + errors.onlineLink = "Must be a valid URL"; + } + } + } + setLocalErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSave = () => { + if (!validateLocalState()) return; + + const saveData: VenueDetails = + sessionType === SessionType.PHYSICAL + ? { + name: localFormState.name, + address: localFormState.address, + latitude: markerPosition.lat, + longitude: markerPosition.lng, + } + : { + onlineLink: localFormState.onlineLink, + }; + + // Directly call onSave with just the venue details + onSave(saveData); + toast.success(`Location for session ${sessionIndex + 1} updated.`); + onOpenChange(false); + }; + + const handleInputChange = (field: keyof VenueDetails, value: string) => { + setLocalFormState(prev => ({ ...prev, [field]: value })); + }; + + const handleMarkerDragEnd = (e: L.LeafletEvent) => { + const marker = e.target as L.Marker; + const pos = marker.getLatLng(); + setMarkerPosition(pos); + }; + + return ( + + + + Edit Location for Session {sessionIndex + 1} + + +
+ {sessionType === SessionType.PHYSICAL ? ( +
+
+
+ + handleInputChange('name', e.target.value)} /> + {localErrors.venueName &&

{localErrors.venueName}

} +
+
+ + handleInputChange('address', e.target.value)} /> +
+
+
+ {open ? ( + + + + + + ) : } +
+
+ ) : ( +
+ + handleInputChange('onlineLink', e.target.value)} /> + {localErrors.onlineLink &&

{localErrors.onlineLink}

} +
+ )} +
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx b/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx index c54df4e..ac0d3d8 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx @@ -19,6 +19,7 @@ import {LayoutSelector} from "./physical-config/LayoutSelector"; import {DeleteConfirmationDialog} from "./physical-config/DeleteConfirmationDialog"; import {getRowLabel} from "@/app/manage/organization/[organization_id]/seating/create/_lib/getRowLabel"; import {Button} from "@/components/ui/button"; +import {SeatStatus} from "@/types/enums/SeatStatus"; type Step = { id: string; @@ -100,7 +101,7 @@ export function PhysicalConfigView({onSave, initialConfig}: { seats: Array.from({length: numColumns}, (_, colIndex) => ({ id: crypto.randomUUID(), label: `${startCol + colIndex}${getRowLabel(startRowIndex + rowIndex)}`, - status: 'AVAILABLE', + status: SeatStatus.AVAILABLE, })), }; return newRow; @@ -110,7 +111,7 @@ export function PhysicalConfigView({onSave, initialConfig}: { newBlock.seats = Array.from({length: capacity}, (_, i) => ({ id: crypto.randomUUID(), label: `Slot ${i + 1}`, - status: 'AVAILABLE', + status: SeatStatus.AVAILABLE, })); } return newBlock; diff --git a/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx b/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx index 9fccbe8..1322351 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx @@ -105,7 +105,7 @@ export function SchedulingStep() {
{fields.map((field, index) => ( - + ))} {fields.length === 0 && ( diff --git a/src/app/manage/organization/[organization_id]/event/_components/SessionListItem.tsx b/src/app/manage/organization/[organization_id]/event/_components/SessionListItem.tsx index 56394bf..ca33dfc 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/SessionListItem.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/SessionListItem.tsx @@ -1,110 +1,79 @@ import * as React from 'react'; import {useState} from 'react'; import {useFormContext} from 'react-hook-form'; -import {CreateEventFormData, SessionWithVenueData} from '@/lib/validators/event'; +import {CreateEventFormData} from '@/lib/validators/event'; import {Button} from '@/components/ui/button'; -import {format, parseISO, intervalToDuration} from 'date-fns'; +import {format, parseISO} from 'date-fns'; import {Badge} from '@/components/ui/badge'; -import {TimeConfigDialog} from './TimeConfigDialog'; // Assuming this component exists -import {LinkIcon, MapPin, Settings, Trash2, Edit, Tag, Hourglass} from 'lucide-react'; -import dynamic from "next/dynamic"; -import {SessionType} from "@/types/enums/sessionType"; -import {CreateSessionsFormData} from "@/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page"; +import {TimeConfigDialog} from './TimeConfigDialog'; +import {Edit, Hourglass, LinkIcon, MapPin, Settings, Tag, Trash2} from 'lucide-react'; +import dynamic from 'next/dynamic'; +import {SessionType} from '@/types/enums/sessionType'; +import {CreateSessionsFormData} from '@/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page'; +import {getSalesStartTimeDisplay, getSalesWindowDuration} from "@/lib/utils"; const LocationConfigDialog = dynamic( - () => import("./LocationConfigDialog").then(mod => mod.LocationConfigDialog), - {ssr: false} + () => import('./LocationConfigDialog').then((mod) => mod.LocationConfigDialog), + { ssr: false } ); -// Helper function to display the sales start time -const getSalesStartTimeDisplay = (session: SessionWithVenueData): string => { - if (!session?.salesStartTime) return "Sales start time not set"; - try { - const salesStartDate = parseISO(session.salesStartTime); - return `Sales start on ${format(salesStartDate, 'MMM d, yyyy h:mm a')}`; - } catch (e) { - console.error("Error parsing sales start time:", e); - return "Invalid sales start time"; - } -}; - -// NEW: Helper function to calculate and display the sales window duration -const getSalesWindowDuration = (session: SessionWithVenueData): string => { - if (!session?.salesStartTime || !session?.startTime) { - return "Sales window not available"; - } - try { - const salesStart = parseISO(session.salesStartTime); - const sessionStart = parseISO(session.startTime); - - if (salesStart > sessionStart) return "Sales start after session begins"; - - const duration = intervalToDuration({start: salesStart, end: sessionStart}); - - const parts = [ - duration.days && `${duration.days}d`, - duration.hours && `${duration.hours}h`, - duration.minutes && `${duration.minutes}m` - ].filter(Boolean); - - if (parts.length === 0) return "Sales open just before the session"; - - return `Sales open for ${parts.join(', ')} before session`; - } catch (e) { - console.error("Error calculating sales window duration:", e); - return "Could not calculate duration"; - } -}; - - -export function SessionListItem({ index, onRemoveAction}: { - field: SessionWithVenueData; +export function SessionListItem({ + index, + onRemoveAction, + }: { index: number; - onRemoveAction: (index: number) => void + onRemoveAction: (index: number) => void; }) { - const {watch} = useFormContext(); + const { watch } = useFormContext(); const [isTimeDialogOpen, setIsTimeDialogOpen] = useState(false); const [isLocationDialogOpen, setIsLocationDialogOpen] = useState(false); const sessionData = watch(`sessions.${index}`); - if (!sessionData) return null; // Graceful exit on remove + if (!sessionData) return null; // Graceful exit when session is removed - const {sessionType, venueDetails, startTime, endTime} = sessionData; - const hasLocation = venueDetails !== undefined; + const { sessionType, venueDetails, startTime, endTime, salesStartTime } = sessionData; + const hasLocation = !!venueDetails; const isOnline = sessionType === SessionType.ONLINE; - // The main content of the list item, rendered conditionally const content = hasLocation ? ( // --- VIEW WHEN LOCATION IS SET ---
- {isOnline ? : } + {isOnline ? : } - {isOnline ? 'Online Link: ' : 'Venue: '} + {isOnline ? 'Online Link: ' : 'Venue: '} - {isOnline ? venueDetails.onlineLink : venueDetails.name ?? 'Not set'} - - + {isOnline ? venueDetails.onlineLink : venueDetails.name ?? 'Not set'} + +
+
- - {getSalesStartTimeDisplay(sessionData)} + + {getSalesStartTimeDisplay(salesStartTime)}
+
- - {getSalesWindowDuration(sessionData)} + + {getSalesWindowDuration(salesStartTime, startTime)}
) : ( // --- VIEW WHEN LOCATION IS NOT SET (CALL TO ACTION) ---
- +

Location Not Set

-

Add a physical venue or an online link.

+

+ Add a physical venue or an online link. +

-
@@ -116,35 +85,64 @@ export function SessionListItem({ index, onRemoveAction}: {
{hasLocation && ( -
- {isOnline ? : } +
+ {isOnline ? ( + + ) : ( + + )}
)}

Session {index + 1}

{hasLocation && ( - {isOnline ? 'Online' : 'Physical'} + + {isOnline ? 'Online' : 'Physical'} + )}

- {format(parseISO(startTime), "PPP p")} - {format(parseISO(endTime), "PPP p")} + {format(parseISO(startTime), 'PPP p')} - {format(parseISO(endTime), 'PPP p')}

+
- {hasLocation && ( - )} -
@@ -153,8 +151,16 @@ export function SessionListItem({ index, onRemoveAction}: { {content} {/* --- DIALOGS --- */} - - + +
); -} \ No newline at end of file +} diff --git a/src/app/manage/organization/[organization_id]/event/_components/SessionListItemSeating.tsx b/src/app/manage/organization/[organization_id]/event/_components/SessionListItemSeating.tsx index 6e98aad..71d6db6 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/SessionListItemSeating.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/SessionListItemSeating.tsx @@ -1,6 +1,6 @@ // --- Session List Item --- import {useFormContext} from "react-hook-form"; -import {CreateEventFormData, SessionWithSeatingData} from "@/lib/validators/event"; +import {CreateEventFormData, SessionFormData} from "@/lib/validators/event"; import {AlertCircle, Armchair, Info, Users} from "lucide-react"; import {format, parseISO} from "date-fns"; import {Badge} from "@/components/ui/badge"; @@ -17,7 +17,7 @@ import { Card, CardContent } from "@/components/ui/card"; import {SessionType} from "@/types/enums/sessionType"; export function SessionListItemSeating({field, index, onConfigure}: { - field: SessionWithSeatingData; + field: SessionFormData; index: number; onConfigure: () => void; }) { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 93e8200..b198727 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,7 @@ import {type ClassValue, clsx} from "clsx" import {twMerge} from "tailwind-merge" import {SessionDTO, TierFormData} from "@/lib/validators/event"; +import {format, intervalToDuration, parseISO} from "date-fns"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -124,3 +125,42 @@ export const formatToDateTimeLocalString = (dateInput: string | null | undefined }; +// --- Helper: Display sales start time --- +export const getSalesStartTimeDisplay = (salesStartTime?: string): string => { + if (!salesStartTime) return 'Sales start time not set'; + try { + const salesStartDate = parseISO(salesStartTime); + return `Sales start on ${format(salesStartDate, 'MMM d, yyyy h:mm a')}`; + } catch (e) { + console.error('Error parsing sales start time:', e); + return 'Invalid sales start time'; + } +}; + + +// --- Helper: Calculate sales window duration --- +export const getSalesWindowDuration = ( + salesStartTime?: string, + sessionStartTime?: string +): string => { + if (!salesStartTime || !sessionStartTime) return 'Sales window not available'; + try { + const salesStart = parseISO(salesStartTime); + const sessionStart = parseISO(sessionStartTime); + + if (salesStart > sessionStart) return 'Sales start after session begins'; + + const duration = intervalToDuration({start: salesStart, end: sessionStart}); + const parts = [ + duration.days && `${duration.days}d`, + duration.hours && `${duration.hours}h`, + duration.minutes && `${duration.minutes}m`, + ].filter(Boolean); + + if (parts.length === 0) return 'Sales open just before the session'; + return `Sales open for ${parts.join(', ')} before session`; + } catch (e) { + console.error('Error calculating sales window duration:', e); + return 'Could not calculate duration'; + } +}; \ No newline at end of file From ca2fbac25313b7e86a29765f69aac04db70a78b6 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 03:00:06 +0530 Subject: [PATCH 23/51] Add PhysicalLayoutEditor and VenueMap components for enhanced event seating management --- .../_components/PhysicalLayoutEditor.tsx | 393 ++++++++++++++++++ .../[sessionId]/_components/VenueMap.tsx | 43 ++ .../[eventId]/sessions/[sessionId]/page.tsx | 90 ++-- .../_components/LocationConfigDialog.tsx | 1 - .../event/_components/OnlineConfigView.tsx | 3 +- 5 files changed, 466 insertions(+), 64 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/PhysicalLayoutEditor.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueMap.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/PhysicalLayoutEditor.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/PhysicalLayoutEditor.tsx new file mode 100644 index 0000000..fe59f71 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/PhysicalLayoutEditor.tsx @@ -0,0 +1,393 @@ +// --- Physical Configuration View --- +import {Block, Row, SessionSeatingMapRequest, TierDTO} from "@/lib/validators/event"; +import * as React from "react"; +import {useCallback, useEffect, useState} from "react"; +import {LayoutData, SeatingLayoutTemplateResponse} from "@/types/seatingLayout"; +import { + createSeatingLayoutTemplate, + deleteSeatingLayoutTemplate, + getSeatingLayoutTemplatesByOrg, + updateSeatingLayoutTemplate +} from "@/lib/actions/seatingLayoutTemplateActions"; +import {toast} from "sonner"; +import {LayoutEditor} from "@/app/manage/organization/[organization_id]/seating/_components/LayoutEditor"; +import {TierAssignmentEditor} from "@/app/manage/organization/[organization_id]/event/_components/TierAssignmentEditor"; +import {getRowLabel} from "@/app/manage/organization/[organization_id]/seating/create/_lib/getRowLabel"; +import {Button} from "@/components/ui/button"; +import {SeatStatus} from "@/types/enums/SeatStatus"; +import { + LayoutSelector +} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/LayoutSelector"; +import { + ProgressSteps +} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/ProgressSteps"; +import { + NavigationButtons +} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/NavigationButtons"; +import { + DeleteConfirmationDialog +} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/DeleteConfirmationDialog"; + +type Step = { + id: string; + label: string; +} + +export function PhysicalLayoutEditor({onSave, initialConfig, tiers, organizationId}: { + onSave: (layout: SessionSeatingMapRequest) => void; + tiers: TierDTO[]; + organizationId: string; + initialConfig?: SessionSeatingMapRequest; +}) { + const [templates, setTemplates] = useState([]); + const [mode, setMode] = useState<'select' | 'create' | 'assign'>('select'); + const [selectedLayout, setSelectedLayout] = useState(null); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [layoutToDelete, setLayoutToDelete] = useState<{ id: string, name: string } | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [currentAssignedLayout, setCurrentAssignedLayout] = useState(null); + + // Progress steps configuration + const steps: Step[] = [ + {id: 'select', label: 'Select Layout'}, + {id: 'create', label: 'Edit Layout'}, + {id: 'assign', label: 'Assign Tiers'}, + ]; + + // If initialConfig is provided, we should start at the assign step + useEffect(() => { + if (initialConfig && initialConfig.name !== null) { + setCurrentAssignedLayout(initialConfig); + setMode('assign'); + } + }, [initialConfig]); + + const loadTemplates = useCallback(async () => { + setIsLoading(true); + try { + const res = await getSeatingLayoutTemplatesByOrg(organizationId, 0, 100); + setTemplates(res.content); + } catch (error) { + toast.error("Failed to load layout templates"); + console.error(error); + } finally { + setIsLoading(false); + } + }, [organizationId]); + + // Fetch templates when component loads or organization changes + useEffect(() => { + if (organizationId) { + loadTemplates().then(); + } + }, [loadTemplates, organizationId]); + + // Transform layout for tier assignment when selectedLayout changes + useEffect(() => { + if (!selectedLayout || mode !== 'assign') return; + + const transformedBlocks = selectedLayout.layout.blocks.map((block) => { + const newBlock: Block = { + ...block, + rows: [], + seats: [], + }; + + if (block.type === 'seated_grid' && block.rows && block.columns) { + const startRowIndex = block.startRowLabel ? block.startRowLabel.charCodeAt(0) - 'A'.charCodeAt(0) : 0; + const startCol = block.startColumnLabel || 1; + const numRows = block.rows; + const numColumns = block.columns; + + newBlock.rows = Array.from({length: numRows}, (_, rowIndex) => { + const newRow: Row = { + id: crypto.randomUUID(), + label: `${getRowLabel(startRowIndex + rowIndex)}`, + seats: Array.from({length: numColumns}, (_, colIndex) => ({ + id: crypto.randomUUID(), + label: `${startCol + colIndex}${getRowLabel(startRowIndex + rowIndex)}`, + status: SeatStatus.AVAILABLE, + })), + }; + return newRow; + }); + } else if (block.type === 'standing_capacity' && block.capacity) { + const capacity = block.capacity; + newBlock.seats = Array.from({length: capacity}, (_, i) => ({ + id: crypto.randomUUID(), + label: `Slot ${i + 1}`, + status: SeatStatus.AVAILABLE, + })); + } + return newBlock; + }); + + setCurrentAssignedLayout({ + name: selectedLayout.name, + layout: { + blocks: transformedBlocks, + }, + }); + }, [selectedLayout, mode]); + + // Check if we need to auto-navigate to step 1 when going to step 2 without a layout + useEffect(() => { + if (mode === 'create' && !selectedLayout && !initialConfig) { + toast.error("Please select a layout first"); + setMode('select'); + } + }, [mode, selectedLayout, initialConfig]); + + // Handle step navigation + const handleStepClick = (stepId: string) => { + // If trying to go to create step but no layout is selected, redirect to select + if (stepId === 'create' && !selectedLayout) { + toast.error("Please select a layout first"); + setMode('select'); + return; + } + + // If trying to go to assign step but no layout is prepared, redirect to select + if (stepId === 'assign' && !currentAssignedLayout && !selectedLayout) { + toast.error("Please select and configure a layout first"); + setMode('select'); + return; + } + + setMode(stepId as 'select' | 'create' | 'assign'); + }; + + // Handle layout selection + const handleLayoutSelect = (template: SeatingLayoutTemplateResponse) => { + setSelectedLayout(template.layoutData); + setSelectedTemplateId(template.id); + }; + + // Handle create from scratch + const handleCreateFromScratch = () => { + setSelectedLayout(null); + setSelectedTemplateId(null); + setMode('create'); + }; + + // Handle delete confirmation - open dialog + const handleDeleteConfirm = (id: string, name: string) => { + setLayoutToDelete({id, name}); + setIsDeleteDialogOpen(true); + }; + + // Handle actual deletion + const handleDeleteLayout = async () => { + if (!layoutToDelete) return; + + try { + await deleteSeatingLayoutTemplate(layoutToDelete.id); + toast.success(`Layout "${layoutToDelete.name}" deleted successfully`); + + // Refresh the templates list + await loadTemplates(); + + // If deleted template was selected, clear selection + if (selectedTemplateId === layoutToDelete.id) { + setSelectedLayout(null); + setSelectedTemplateId(null); + } + } catch (error) { + toast.error("Failed to delete layout template"); + console.error(error); + } finally { + setIsDeleteDialogOpen(false); + setLayoutToDelete(null); + } + }; + + // Handle tier assignments updates from the editor + const handleTierAssignmentUpdate = (layoutWithTiers: SessionSeatingMapRequest) => { + setCurrentAssignedLayout(layoutWithTiers); + }; + + const handleTierAssignmentSave = () => { + if (!currentAssignedLayout) { + toast.error("No layout data available"); + return; + } + + // Check if all seats and standing blocks have tier assignments + let hasUnassignedElements = false; + + for (const block of currentAssignedLayout.layout.blocks) { + // Check seated blocks (rows with seats) + if (block.type === 'seated_grid' && block.rows) { + for (const row of block.rows) { + for (const seat of row.seats) { + // Seat must either have a tierId or be marked as RESERVED + if (!seat.tierId && seat.status !== 'RESERVED') { + hasUnassignedElements = true; + break; + } + } + if (hasUnassignedElements) break; + } + } + + // Check standing capacity blocks + if (block.type === 'standing_capacity' && block.seats) { + // For standing blocks, check if at least one seat has a tier assignment + const hasAnyAssignedSeat = block.seats.some(seat => seat.tierId); + if (!hasAnyAssignedSeat && block.seats.length > 0) { + hasUnassignedElements = true; + break; + } + } + + if (hasUnassignedElements) break; + } + + if (hasUnassignedElements) { + toast.error("Please assign all seats to a tier or mark them as reserved. Standing areas must also have tier assignments."); + return; + } + onSave(currentAssignedLayout); + }; + + const handleSave = async (layoutData: LayoutData) => { + const request = { + name: layoutData.name, + organizationId, + layoutData, + }; + + try { + // If we're editing an existing template, update it instead of creating a new one + if (selectedTemplateId) { + const data = await updateSeatingLayoutTemplate(selectedTemplateId, request); + toast.success(`Layout "${data.name}" updated successfully!`); + setSelectedLayout(data.layoutData); + setMode("assign"); + await loadTemplates(); + } else { + console.log("Creating new seating layout template", request); + const data = await createSeatingLayoutTemplate(request); + toast.success(`Layout "${data.name}" saved successfully!`); + setSelectedLayout(data.layoutData); + setSelectedTemplateId(data.id); + setMode("assign"); + await loadTemplates(); + } + } catch (err) { + if (err instanceof Error) { + toast.error(err.message || 'Failed to save layout'); + } else { + toast.error('An unexpected error occurred while saving the layout'); + } + console.error(err); + } + }; + + // Navigation between steps + const goToPrevStep = () => { + if (mode === 'create') { + setMode('select'); + } else if (mode === 'assign') { + setMode('create'); + + // Check if we have a layout to edit + if (!selectedLayout) { + toast.error("No layout available to edit"); + setMode('select'); + } + } + }; + + const goToNextStep = () => { + if (mode === 'select') { + if (!selectedLayout) { + toast.error("Please select a layout first"); + return; + } + setMode('create'); + } else if (mode === 'create') { + setMode('assign'); + } + }; + + // Render different steps based on current mode + const renderCurrentStep = () => { + switch (mode) { + case 'create': + return ( +
+ +
+ ); + case 'assign': + // If we have initialConfig or currentAssignedLayout, show the TierAssignmentEditor + const layoutToEdit = currentAssignedLayout || initialConfig; + + return layoutToEdit ? ( + + ) : ( +
+

No layout data available.

+ +
+ ); + case 'select': + default: + return ( + + ); + } + }; + + return ( + <> + + + {renderCurrentStep()} + + + + + + ); +} diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueMap.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueMap.tsx new file mode 100644 index 0000000..44534ec --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueMap.tsx @@ -0,0 +1,43 @@ +'use client'; + +import React from 'react'; +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; + +// Fix the default marker icon issue using the type-safe "replacement" method. +const defaultIcon = new L.Icon({ + iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png", + iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png", + shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], +}); + +L.Marker.prototype.options.icon = defaultIcon; + + +interface VenueMapProps { + center: [number, number]; + venueName: string; +} + +const VenueMap: React.FC = ({ center, venueName }) => { + return ( + + + + {venueName || "Event Venue"} + + + ); +}; + +export default VenueMap; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx index de68db3..c86223f 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -16,7 +16,6 @@ import { SessionStatus } from "@/types/enums/sessionStatus"; import dynamic from "next/dynamic"; import { deleteSession } from "@/lib/actions/sessionActions"; import { toast } from "sonner"; -import "leaflet/dist/leaflet.css"; import { AlertDialog, AlertDialogAction, @@ -30,42 +29,9 @@ import { import { SessionSeatingLayout } from "./_components/SessionSeatingLayout"; import { SeatingCapacitySummary } from "./_components/seatingCapacitySummary"; -// Dynamically load just the map component for venue details +// Dynamically import the new VenueMap component const VenueMap = dynamic( - () => import("react-leaflet").then(mod => { - // Create a custom component that just renders the map - const MapComponent = ({ center, venueName }: { center: [number, number], venueName: string }) => { - const { MapContainer, TileLayer, Marker, Popup } = mod; - - // Fix default marker icons (required for Leaflet in Next.js) - React.useEffect(() => { - const L = require("leaflet"); - delete L.Icon.Default.prototype._getIconUrl; - L.Icon.Default.mergeOptions({ - iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png", - iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png", - shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png" - }); - }, []); - - return ( - - - - {venueName || "Event Venue"} - - - ); - }; - return MapComponent; - }), + () => import('./_components/VenueMap'), { ssr: false } ); @@ -121,9 +87,9 @@ const SessionPage = () => { const isOnline = session.sessionType === SessionType.ONLINE; const { venueDetails, layoutData, status } = session; const statusProps = getStatusProperties(status); - + // For the map - set default to Colombo if no coordinates - const mapCenter: [number, number] = + const mapCenter: [number, number] = venueDetails?.latitude && venueDetails?.longitude ? [venueDetails.latitude, venueDetails.longitude] : [6.9271, 79.8612]; // Default to Colombo @@ -190,8 +156,8 @@ const SessionPage = () => {
- {isOnline ? - : + {isOnline ? + : }
@@ -204,25 +170,25 @@ const SessionPage = () => {

{event.title}

- {startDate.toDateString() === endDate.toDateString() + {startDate.toDateString() === endDate.toDateString() ? `Session on ${format(startDate, 'EEEE, MMMM d, yyyy')}` : `Session from ${format(startDate, 'MMM d')} to ${format(endDate, 'MMM d, yyyy')}` }

- -
- {/* Status banner */} + {/* Status banner */}
{React.createElement(statusProps.icon, { className: "h-5 w-5" })}

- Session Status: + Session Status: {status || 'PENDING'} @@ -265,8 +231,8 @@ const SessionPage = () => { Session Information - {React.createElement(statusProps.icon, { className: "h-3 w-3 mr-1 inline" })} @@ -365,9 +331,9 @@ const SessionPage = () => { {/* Capacity summary for online events */} {layoutData && layoutData.layout.blocks.length > 0 && (
-
)} @@ -392,16 +358,16 @@ const SessionPage = () => {

)}
- + {/* Map for physical events */}
-
- + {/* Add a note about the map */}
Map shows approximate location based on venue coordinates @@ -419,9 +385,9 @@ const SessionPage = () => {
-
@@ -440,7 +406,7 @@ const SessionPage = () => { Cancel - Configure Location for Session {index + 1} -
void; @@ -28,7 +29,7 @@ export function OnlineConfigView({onSave}: { id: crypto.randomUUID(), label: `Slot ${i + 1}`, tierId: selectedTierId, - status: 'AVAILABLE', + status: SeatStatus.AVAILABLE, })); const layoutData: SessionSeatingMapRequest = { From dabbafb68c3307d5ef6b0f74c469766abd79201a Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 03:10:28 +0530 Subject: [PATCH 24/51] feat: add OnlineConfigEditorBase component for managing online event capacity and tier selection refactor: simplify OnlineConfigView by integrating OnlineConfigEditorBase for capacity and tier management refactor: streamline PhysicalConfigView by incorporating PhysicalLayoutEditorBase for layout management and tier assignment feat: implement PhysicalLayoutEditorBase component to handle physical layout editing, tier assignment, and template management --- .../_components/OnlineLayoutEditor.tsx | 18 + .../_components/PhysicalLayoutEditor.tsx | 389 +---------------- .../_components/OnlineConfigEditorBase.tsx | 92 ++++ .../event/_components/OnlineConfigView.tsx | 70 +--- .../event/_components/PhysicalConfigView.tsx | 380 +---------------- .../_components/PhysicalLayoutEditorBase.tsx | 395 ++++++++++++++++++ 6 files changed, 531 insertions(+), 813 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/OnlineLayoutEditor.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/_components/OnlineConfigEditorBase.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/_components/PhysicalLayoutEditorBase.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/OnlineLayoutEditor.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/OnlineLayoutEditor.tsx new file mode 100644 index 0000000..22ad194 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/OnlineLayoutEditor.tsx @@ -0,0 +1,18 @@ +// --- Online Configuration Editor --- +import {SessionSeatingMapRequest, TierDTO} from "@/lib/validators/event"; +import * as React from "react"; +import {OnlineConfigEditorBase} from "@/app/manage/organization/[organization_id]/event/_components/OnlineConfigEditorBase"; + +export function OnlineLayoutEditor({onSave, initialConfig, tiers}: { + onSave: (layout: SessionSeatingMapRequest) => void; + tiers: TierDTO[]; + initialConfig?: SessionSeatingMapRequest; +}) { + return ( + + ); +} diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/PhysicalLayoutEditor.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/PhysicalLayoutEditor.tsx index fe59f71..c1b2dc2 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/PhysicalLayoutEditor.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/PhysicalLayoutEditor.tsx @@ -1,37 +1,7 @@ // --- Physical Configuration View --- -import {Block, Row, SessionSeatingMapRequest, TierDTO} from "@/lib/validators/event"; +import {SessionSeatingMapRequest, TierDTO} from "@/lib/validators/event"; import * as React from "react"; -import {useCallback, useEffect, useState} from "react"; -import {LayoutData, SeatingLayoutTemplateResponse} from "@/types/seatingLayout"; -import { - createSeatingLayoutTemplate, - deleteSeatingLayoutTemplate, - getSeatingLayoutTemplatesByOrg, - updateSeatingLayoutTemplate -} from "@/lib/actions/seatingLayoutTemplateActions"; -import {toast} from "sonner"; -import {LayoutEditor} from "@/app/manage/organization/[organization_id]/seating/_components/LayoutEditor"; -import {TierAssignmentEditor} from "@/app/manage/organization/[organization_id]/event/_components/TierAssignmentEditor"; -import {getRowLabel} from "@/app/manage/organization/[organization_id]/seating/create/_lib/getRowLabel"; -import {Button} from "@/components/ui/button"; -import {SeatStatus} from "@/types/enums/SeatStatus"; -import { - LayoutSelector -} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/LayoutSelector"; -import { - ProgressSteps -} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/ProgressSteps"; -import { - NavigationButtons -} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/NavigationButtons"; -import { - DeleteConfirmationDialog -} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/DeleteConfirmationDialog"; - -type Step = { - id: string; - label: string; -} +import {PhysicalLayoutEditorBase} from "@/app/manage/organization/[organization_id]/event/_components/PhysicalLayoutEditorBase"; export function PhysicalLayoutEditor({onSave, initialConfig, tiers, organizationId}: { onSave: (layout: SessionSeatingMapRequest) => void; @@ -39,355 +9,12 @@ export function PhysicalLayoutEditor({onSave, initialConfig, tiers, organization organizationId: string; initialConfig?: SessionSeatingMapRequest; }) { - const [templates, setTemplates] = useState([]); - const [mode, setMode] = useState<'select' | 'create' | 'assign'>('select'); - const [selectedLayout, setSelectedLayout] = useState(null); - const [selectedTemplateId, setSelectedTemplateId] = useState(null); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [layoutToDelete, setLayoutToDelete] = useState<{ id: string, name: string } | null>(null); - const [isLoading, setIsLoading] = useState(false); - const [currentAssignedLayout, setCurrentAssignedLayout] = useState(null); - - // Progress steps configuration - const steps: Step[] = [ - {id: 'select', label: 'Select Layout'}, - {id: 'create', label: 'Edit Layout'}, - {id: 'assign', label: 'Assign Tiers'}, - ]; - - // If initialConfig is provided, we should start at the assign step - useEffect(() => { - if (initialConfig && initialConfig.name !== null) { - setCurrentAssignedLayout(initialConfig); - setMode('assign'); - } - }, [initialConfig]); - - const loadTemplates = useCallback(async () => { - setIsLoading(true); - try { - const res = await getSeatingLayoutTemplatesByOrg(organizationId, 0, 100); - setTemplates(res.content); - } catch (error) { - toast.error("Failed to load layout templates"); - console.error(error); - } finally { - setIsLoading(false); - } - }, [organizationId]); - - // Fetch templates when component loads or organization changes - useEffect(() => { - if (organizationId) { - loadTemplates().then(); - } - }, [loadTemplates, organizationId]); - - // Transform layout for tier assignment when selectedLayout changes - useEffect(() => { - if (!selectedLayout || mode !== 'assign') return; - - const transformedBlocks = selectedLayout.layout.blocks.map((block) => { - const newBlock: Block = { - ...block, - rows: [], - seats: [], - }; - - if (block.type === 'seated_grid' && block.rows && block.columns) { - const startRowIndex = block.startRowLabel ? block.startRowLabel.charCodeAt(0) - 'A'.charCodeAt(0) : 0; - const startCol = block.startColumnLabel || 1; - const numRows = block.rows; - const numColumns = block.columns; - - newBlock.rows = Array.from({length: numRows}, (_, rowIndex) => { - const newRow: Row = { - id: crypto.randomUUID(), - label: `${getRowLabel(startRowIndex + rowIndex)}`, - seats: Array.from({length: numColumns}, (_, colIndex) => ({ - id: crypto.randomUUID(), - label: `${startCol + colIndex}${getRowLabel(startRowIndex + rowIndex)}`, - status: SeatStatus.AVAILABLE, - })), - }; - return newRow; - }); - } else if (block.type === 'standing_capacity' && block.capacity) { - const capacity = block.capacity; - newBlock.seats = Array.from({length: capacity}, (_, i) => ({ - id: crypto.randomUUID(), - label: `Slot ${i + 1}`, - status: SeatStatus.AVAILABLE, - })); - } - return newBlock; - }); - - setCurrentAssignedLayout({ - name: selectedLayout.name, - layout: { - blocks: transformedBlocks, - }, - }); - }, [selectedLayout, mode]); - - // Check if we need to auto-navigate to step 1 when going to step 2 without a layout - useEffect(() => { - if (mode === 'create' && !selectedLayout && !initialConfig) { - toast.error("Please select a layout first"); - setMode('select'); - } - }, [mode, selectedLayout, initialConfig]); - - // Handle step navigation - const handleStepClick = (stepId: string) => { - // If trying to go to create step but no layout is selected, redirect to select - if (stepId === 'create' && !selectedLayout) { - toast.error("Please select a layout first"); - setMode('select'); - return; - } - - // If trying to go to assign step but no layout is prepared, redirect to select - if (stepId === 'assign' && !currentAssignedLayout && !selectedLayout) { - toast.error("Please select and configure a layout first"); - setMode('select'); - return; - } - - setMode(stepId as 'select' | 'create' | 'assign'); - }; - - // Handle layout selection - const handleLayoutSelect = (template: SeatingLayoutTemplateResponse) => { - setSelectedLayout(template.layoutData); - setSelectedTemplateId(template.id); - }; - - // Handle create from scratch - const handleCreateFromScratch = () => { - setSelectedLayout(null); - setSelectedTemplateId(null); - setMode('create'); - }; - - // Handle delete confirmation - open dialog - const handleDeleteConfirm = (id: string, name: string) => { - setLayoutToDelete({id, name}); - setIsDeleteDialogOpen(true); - }; - - // Handle actual deletion - const handleDeleteLayout = async () => { - if (!layoutToDelete) return; - - try { - await deleteSeatingLayoutTemplate(layoutToDelete.id); - toast.success(`Layout "${layoutToDelete.name}" deleted successfully`); - - // Refresh the templates list - await loadTemplates(); - - // If deleted template was selected, clear selection - if (selectedTemplateId === layoutToDelete.id) { - setSelectedLayout(null); - setSelectedTemplateId(null); - } - } catch (error) { - toast.error("Failed to delete layout template"); - console.error(error); - } finally { - setIsDeleteDialogOpen(false); - setLayoutToDelete(null); - } - }; - - // Handle tier assignments updates from the editor - const handleTierAssignmentUpdate = (layoutWithTiers: SessionSeatingMapRequest) => { - setCurrentAssignedLayout(layoutWithTiers); - }; - - const handleTierAssignmentSave = () => { - if (!currentAssignedLayout) { - toast.error("No layout data available"); - return; - } - - // Check if all seats and standing blocks have tier assignments - let hasUnassignedElements = false; - - for (const block of currentAssignedLayout.layout.blocks) { - // Check seated blocks (rows with seats) - if (block.type === 'seated_grid' && block.rows) { - for (const row of block.rows) { - for (const seat of row.seats) { - // Seat must either have a tierId or be marked as RESERVED - if (!seat.tierId && seat.status !== 'RESERVED') { - hasUnassignedElements = true; - break; - } - } - if (hasUnassignedElements) break; - } - } - - // Check standing capacity blocks - if (block.type === 'standing_capacity' && block.seats) { - // For standing blocks, check if at least one seat has a tier assignment - const hasAnyAssignedSeat = block.seats.some(seat => seat.tierId); - if (!hasAnyAssignedSeat && block.seats.length > 0) { - hasUnassignedElements = true; - break; - } - } - - if (hasUnassignedElements) break; - } - - if (hasUnassignedElements) { - toast.error("Please assign all seats to a tier or mark them as reserved. Standing areas must also have tier assignments."); - return; - } - onSave(currentAssignedLayout); - }; - - const handleSave = async (layoutData: LayoutData) => { - const request = { - name: layoutData.name, - organizationId, - layoutData, - }; - - try { - // If we're editing an existing template, update it instead of creating a new one - if (selectedTemplateId) { - const data = await updateSeatingLayoutTemplate(selectedTemplateId, request); - toast.success(`Layout "${data.name}" updated successfully!`); - setSelectedLayout(data.layoutData); - setMode("assign"); - await loadTemplates(); - } else { - console.log("Creating new seating layout template", request); - const data = await createSeatingLayoutTemplate(request); - toast.success(`Layout "${data.name}" saved successfully!`); - setSelectedLayout(data.layoutData); - setSelectedTemplateId(data.id); - setMode("assign"); - await loadTemplates(); - } - } catch (err) { - if (err instanceof Error) { - toast.error(err.message || 'Failed to save layout'); - } else { - toast.error('An unexpected error occurred while saving the layout'); - } - console.error(err); - } - }; - - // Navigation between steps - const goToPrevStep = () => { - if (mode === 'create') { - setMode('select'); - } else if (mode === 'assign') { - setMode('create'); - - // Check if we have a layout to edit - if (!selectedLayout) { - toast.error("No layout available to edit"); - setMode('select'); - } - } - }; - - const goToNextStep = () => { - if (mode === 'select') { - if (!selectedLayout) { - toast.error("Please select a layout first"); - return; - } - setMode('create'); - } else if (mode === 'create') { - setMode('assign'); - } - }; - - // Render different steps based on current mode - const renderCurrentStep = () => { - switch (mode) { - case 'create': - return ( -
- -
- ); - case 'assign': - // If we have initialConfig or currentAssignedLayout, show the TierAssignmentEditor - const layoutToEdit = currentAssignedLayout || initialConfig; - - return layoutToEdit ? ( - - ) : ( -
-

No layout data available.

- -
- ); - case 'select': - default: - return ( - - ); - } - }; - return ( - <> - - - {renderCurrentStep()} - - - - - + ); } diff --git a/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigEditorBase.tsx b/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigEditorBase.tsx new file mode 100644 index 0000000..2664f35 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigEditorBase.tsx @@ -0,0 +1,92 @@ +// --- Online Configuration Base Component --- +import {Seat, SessionSeatingMapRequest, TierDTO} from "@/lib/validators/event"; +import * as React from "react"; +import {useState} from "react"; +import {toast} from "sonner"; +import {Label} from "@/components/ui/label"; +import {Input} from "@/components/ui/input"; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; +import {Button} from "@/components/ui/button"; +import {SeatStatus} from "@/types/enums/SeatStatus"; + +export interface OnlineConfigEditorBaseProps { + onSave: (layout: SessionSeatingMapRequest) => void; + tiers: TierDTO[]; + initialConfig?: SessionSeatingMapRequest; +} + +export function OnlineConfigEditorBase({onSave, tiers, initialConfig}: OnlineConfigEditorBaseProps) { + // Initialize with values from initialConfig if available + const initialCapacity = initialConfig?.layout.blocks[0]?.capacity || 100; + const initialTierId = initialConfig?.layout.blocks[0]?.seats?.[0]?.tierId || tiers[0]?.id; + + const [capacity, setCapacity] = useState(initialCapacity); + const [selectedTierId, setSelectedTierId] = useState(initialTierId); + + const handleSave = () => { + if (!selectedTierId) { + toast.error("Please select a tier."); + return; + } + + // Generate the seats programmatically + const seats: Seat[] = Array.from({length: capacity}, (_, i) => ({ + id: crypto.randomUUID(), + label: `Slot ${i + 1}`, + tierId: selectedTierId, + status: SeatStatus.AVAILABLE, + })); + + const layoutData: SessionSeatingMapRequest = { + name: "Online Event Capacity", + layout: { + blocks: [{ + id: crypto.randomUUID(), + name: "Online Attendees", + type: 'standing_capacity', + position: {x: 0, y: 0}, + capacity: capacity, + seats: seats, + }] + } + }; + onSave(layoutData); + }; + + return ( +
+
+ + setCapacity(parseInt(e.target.value) || 0)} + /> +
+
+ + +
+ +
+ ); +} diff --git a/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigView.tsx b/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigView.tsx index 92ad7f8..7130020 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigView.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/OnlineConfigView.tsx @@ -1,71 +1,21 @@ // --- Online Configuration View --- -import {CreateEventFormData, Seat, SessionSeatingMapRequest} from "@/lib/validators/event"; +import {CreateEventFormData, SessionSeatingMapRequest} from "@/lib/validators/event"; import {useFormContext} from "react-hook-form"; import * as React from "react"; -import {useState} from "react"; -import {toast} from "sonner"; -import {Label} from "@/components/ui/label"; -import {Input} from "@/components/ui/input"; -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; -import {Button} from "@/components/ui/button"; -import {SeatStatus} from "@/types/enums/SeatStatus"; +import {OnlineConfigEditorBase} from "@/app/manage/organization/[organization_id]/event/_components/OnlineConfigEditorBase"; -export function OnlineConfigView({onSave}: { +export function OnlineConfigView({onSave, initialConfig}: { onSave: (layout: SessionSeatingMapRequest) => void; + initialConfig?: SessionSeatingMapRequest; }) { - const {watch, getValues} = useFormContext(); + const {watch} = useFormContext(); const tiers = watch('tiers'); - const [capacity, setCapacity] = useState(100); - const [selectedTierId, setSelectedTierId] = useState(tiers[0]?.id); - - const handleSave = () => { - if (!selectedTierId) { - toast.error("Please select a tier."); - return; - } - - // Generate the seats programmatically - const seats: Seat[] = Array.from({length: capacity}, (_, i) => ({ - id: crypto.randomUUID(), - label: `Slot ${i + 1}`, - tierId: selectedTierId, - status: SeatStatus.AVAILABLE, - })); - - const layoutData: SessionSeatingMapRequest = { - name: "Online Event Capacity", - layout: { - blocks: [{ - id: crypto.randomUUID(), - name: "Online Attendees", - type: 'standing_capacity', - position: {x: 0, y: 0}, - capacity: capacity, - seats: seats, - }] - } - }; - onSave(layoutData); - }; return ( -
-
- - setCapacity(parseInt(e.target.value) || 0)}/> -
-
- - -
- -
+ ); } \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx b/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx index ac0d3d8..03d6c8c 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/PhysicalConfigView.tsx @@ -1,30 +1,8 @@ // --- Physical Configuration View --- -import {Block, CreateEventFormData, Row, SessionSeatingMapRequest} from "@/lib/validators/event"; +import {CreateEventFormData, SessionSeatingMapRequest} from "@/lib/validators/event"; import {useFormContext} from "react-hook-form"; import * as React from "react"; -import {useCallback, useEffect, useState} from "react"; -import {LayoutData, SeatingLayoutTemplateResponse} from "@/types/seatingLayout"; -import { - createSeatingLayoutTemplate, - deleteSeatingLayoutTemplate, - getSeatingLayoutTemplatesByOrg, - updateSeatingLayoutTemplate -} from "@/lib/actions/seatingLayoutTemplateActions"; -import {toast} from "sonner"; -import {LayoutEditor} from "@/app/manage/organization/[organization_id]/seating/_components/LayoutEditor"; -import {TierAssignmentEditor} from "@/app/manage/organization/[organization_id]/event/_components/TierAssignmentEditor"; -import {ProgressSteps} from "./physical-config/ProgressSteps"; -import {NavigationButtons} from "./physical-config/NavigationButtons"; -import {LayoutSelector} from "./physical-config/LayoutSelector"; -import {DeleteConfirmationDialog} from "./physical-config/DeleteConfirmationDialog"; -import {getRowLabel} from "@/app/manage/organization/[organization_id]/seating/create/_lib/getRowLabel"; -import {Button} from "@/components/ui/button"; -import {SeatStatus} from "@/types/enums/SeatStatus"; - -type Step = { - id: string; - label: string; -} +import {PhysicalLayoutEditorBase} from "@/app/manage/organization/[organization_id]/event/_components/PhysicalLayoutEditorBase"; export function PhysicalConfigView({onSave, initialConfig}: { onSave: (layout: SessionSeatingMapRequest) => void; @@ -33,355 +11,13 @@ export function PhysicalConfigView({onSave, initialConfig}: { const {watch} = useFormContext(); const organizationId = watch('organizationId'); const tiers = watch('tiers'); - const [templates, setTemplates] = useState([]); - const [mode, setMode] = useState<'select' | 'create' | 'assign'>('select'); - const [selectedLayout, setSelectedLayout] = useState(null); - const [selectedTemplateId, setSelectedTemplateId] = useState(null); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [layoutToDelete, setLayoutToDelete] = useState<{ id: string, name: string } | null>(null); - const [isLoading, setIsLoading] = useState(false); - const [currentAssignedLayout, setCurrentAssignedLayout] = useState(null); - - // Progress steps configuration - const steps: Step[] = [ - {id: 'select', label: 'Select Layout'}, - {id: 'create', label: 'Edit Layout'}, - {id: 'assign', label: 'Assign Tiers'}, - ]; - - // If initialConfig is provided, we should start at the assign step - useEffect(() => { - if (initialConfig && initialConfig.name !== null) { - setCurrentAssignedLayout(initialConfig); - setMode('assign'); - } - }, [initialConfig]); - - const loadTemplates = useCallback(async () => { - setIsLoading(true); - try { - const res = await getSeatingLayoutTemplatesByOrg(organizationId, 0, 100); - setTemplates(res.content); - } catch (error) { - toast.error("Failed to load layout templates"); - console.error(error); - } finally { - setIsLoading(false); - } - }, [organizationId]); - - // Fetch templates when component loads or organization changes - useEffect(() => { - if (organizationId) { - loadTemplates().then(); - } - }, [loadTemplates, organizationId]); - - // Transform layout for tier assignment when selectedLayout changes - useEffect(() => { - if (!selectedLayout || mode !== 'assign') return; - - const transformedBlocks = selectedLayout.layout.blocks.map((block) => { - const newBlock: Block = { - ...block, - rows: [], - seats: [], - }; - - if (block.type === 'seated_grid' && block.rows && block.columns) { - const startRowIndex = block.startRowLabel ? block.startRowLabel.charCodeAt(0) - 'A'.charCodeAt(0) : 0; - const startCol = block.startColumnLabel || 1; - const numRows = block.rows; - const numColumns = block.columns; - - newBlock.rows = Array.from({length: numRows}, (_, rowIndex) => { - const newRow: Row = { - id: crypto.randomUUID(), - label: `${getRowLabel(startRowIndex + rowIndex)}`, - seats: Array.from({length: numColumns}, (_, colIndex) => ({ - id: crypto.randomUUID(), - label: `${startCol + colIndex}${getRowLabel(startRowIndex + rowIndex)}`, - status: SeatStatus.AVAILABLE, - })), - }; - return newRow; - }); - } else if (block.type === 'standing_capacity' && block.capacity) { - const capacity = block.capacity; - newBlock.seats = Array.from({length: capacity}, (_, i) => ({ - id: crypto.randomUUID(), - label: `Slot ${i + 1}`, - status: SeatStatus.AVAILABLE, - })); - } - return newBlock; - }); - - setCurrentAssignedLayout({ - name: selectedLayout.name, - layout: { - blocks: transformedBlocks, - }, - }); - }, [selectedLayout, mode]); - - // Check if we need to auto-navigate to step 1 when going to step 2 without a layout - useEffect(() => { - if (mode === 'create' && !selectedLayout && !initialConfig) { - toast.error("Please select a layout first"); - setMode('select'); - } - }, [mode, selectedLayout, initialConfig]); - - // Handle step navigation - const handleStepClick = (stepId: string) => { - // If trying to go to create step but no layout is selected, redirect to select - if (stepId === 'create' && !selectedLayout) { - toast.error("Please select a layout first"); - setMode('select'); - return; - } - - // If trying to go to assign step but no layout is prepared, redirect to select - if (stepId === 'assign' && !currentAssignedLayout && !selectedLayout) { - toast.error("Please select and configure a layout first"); - setMode('select'); - return; - } - - setMode(stepId as 'select' | 'create' | 'assign'); - }; - - // Handle layout selection - const handleLayoutSelect = (template: SeatingLayoutTemplateResponse) => { - setSelectedLayout(template.layoutData); - setSelectedTemplateId(template.id); - }; - - // Handle create from scratch - const handleCreateFromScratch = () => { - setSelectedLayout(null); - setSelectedTemplateId(null); - setMode('create'); - }; - - // Handle delete confirmation - open dialog - const handleDeleteConfirm = (id: string, name: string) => { - setLayoutToDelete({id, name}); - setIsDeleteDialogOpen(true); - }; - - // Handle actual deletion - const handleDeleteLayout = async () => { - if (!layoutToDelete) return; - - try { - await deleteSeatingLayoutTemplate(layoutToDelete.id); - toast.success(`Layout "${layoutToDelete.name}" deleted successfully`); - - // Refresh the templates list - await loadTemplates(); - - // If deleted template was selected, clear selection - if (selectedTemplateId === layoutToDelete.id) { - setSelectedLayout(null); - setSelectedTemplateId(null); - } - } catch (error) { - toast.error("Failed to delete layout template"); - console.error(error); - } finally { - setIsDeleteDialogOpen(false); - setLayoutToDelete(null); - } - }; - - // Handle tier assignments updates from the editor - const handleTierAssignmentUpdate = (layoutWithTiers: SessionSeatingMapRequest) => { - setCurrentAssignedLayout(layoutWithTiers); - }; - - const handleTierAssignmentSave = () => { - if (!currentAssignedLayout) { - toast.error("No layout data available"); - return; - } - - // Check if all seats and standing blocks have tier assignments - let hasUnassignedElements = false; - - for (const block of currentAssignedLayout.layout.blocks) { - // Check seated blocks (rows with seats) - if (block.type === 'seated_grid' && block.rows) { - for (const row of block.rows) { - for (const seat of row.seats) { - // Seat must either have a tierId or be marked as RESERVED - if (!seat.tierId && seat.status !== 'RESERVED') { - hasUnassignedElements = true; - break; - } - } - if (hasUnassignedElements) break; - } - } - - // Check standing capacity blocks - if (block.type === 'standing_capacity' && block.seats) { - // For standing blocks, check if at least one seat has a tier assignment - const hasAnyAssignedSeat = block.seats.some(seat => seat.tierId); - if (!hasAnyAssignedSeat && block.seats.length > 0) { - hasUnassignedElements = true; - break; - } - } - - if (hasUnassignedElements) break; - } - - if (hasUnassignedElements) { - toast.error("Please assign all seats to a tier or mark them as reserved. Standing areas must also have tier assignments."); - return; - } - onSave(currentAssignedLayout); - }; - - const handleSave = async (layoutData: LayoutData) => { - const request = { - name: layoutData.name, - organizationId, - layoutData, - }; - - try { - // If we're editing an existing template, update it instead of creating a new one - if (selectedTemplateId) { - const data = await updateSeatingLayoutTemplate(selectedTemplateId, request); - toast.success(`Layout "${data.name}" updated successfully!`); - setSelectedLayout(data.layoutData); - setMode("assign"); - await loadTemplates(); - } else { - console.log("Creating new seating layout template", request); - const data = await createSeatingLayoutTemplate(request); - toast.success(`Layout "${data.name}" saved successfully!`); - setSelectedLayout(data.layoutData); - setSelectedTemplateId(data.id); - setMode("assign"); - await loadTemplates(); - } - } catch (err) { - if (err instanceof Error) { - toast.error(err.message || 'Failed to save layout'); - } else { - toast.error('An unexpected error occurred while saving the layout'); - } - console.error(err); - } - }; - - // Navigation between steps - const goToPrevStep = () => { - if (mode === 'create') { - setMode('select'); - } else if (mode === 'assign') { - setMode('create'); - - // Check if we have a layout to edit - if (!selectedLayout) { - toast.error("No layout available to edit"); - setMode('select'); - } - } - }; - - const goToNextStep = () => { - if (mode === 'select') { - if (!selectedLayout) { - toast.error("Please select a layout first"); - return; - } - setMode('create'); - } else if (mode === 'create') { - setMode('assign'); - } - }; - - // Render different steps based on current mode - const renderCurrentStep = () => { - switch (mode) { - case 'create': - return ( -
- -
- ); - case 'assign': - // If we have initialConfig or currentAssignedLayout, show the TierAssignmentEditor - const layoutToEdit = currentAssignedLayout || initialConfig; - - return layoutToEdit ? ( - - ) : ( -
-

No layout data available.

- -
- ); - case 'select': - default: - return ( - - ); - } - }; return ( - <> - - - {renderCurrentStep()} - - - - - + ); } diff --git a/src/app/manage/organization/[organization_id]/event/_components/PhysicalLayoutEditorBase.tsx b/src/app/manage/organization/[organization_id]/event/_components/PhysicalLayoutEditorBase.tsx new file mode 100644 index 0000000..6b6dc7f --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/PhysicalLayoutEditorBase.tsx @@ -0,0 +1,395 @@ +// --- Physical Layout Editor Base Component --- +import {Block, Row, SessionSeatingMapRequest, TierDTO} from "@/lib/validators/event"; +import * as React from "react"; +import {useCallback, useEffect, useState} from "react"; +import {LayoutData, SeatingLayoutTemplateResponse} from "@/types/seatingLayout"; +import { + createSeatingLayoutTemplate, + deleteSeatingLayoutTemplate, + getSeatingLayoutTemplatesByOrg, + updateSeatingLayoutTemplate +} from "@/lib/actions/seatingLayoutTemplateActions"; +import {toast} from "sonner"; +import {LayoutEditor} from "@/app/manage/organization/[organization_id]/seating/_components/LayoutEditor"; +import {TierAssignmentEditor} from "@/app/manage/organization/[organization_id]/event/_components/TierAssignmentEditor"; +import {getRowLabel} from "@/app/manage/organization/[organization_id]/seating/create/_lib/getRowLabel"; +import {Button} from "@/components/ui/button"; +import {SeatStatus} from "@/types/enums/SeatStatus"; +import { + LayoutSelector +} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/LayoutSelector"; +import { + ProgressSteps +} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/ProgressSteps"; +import { + NavigationButtons +} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/NavigationButtons"; +import { + DeleteConfirmationDialog +} from "@/app/manage/organization/[organization_id]/event/_components/physical-config/DeleteConfirmationDialog"; + +type Step = { + id: string; + label: string; +} + +export interface PhysicalLayoutEditorBaseProps { + onSave: (layout: SessionSeatingMapRequest) => void; + initialConfig?: SessionSeatingMapRequest; + tiers: TierDTO[]; + organizationId: string; +} + +export function PhysicalLayoutEditorBase({onSave, initialConfig, tiers, organizationId}: PhysicalLayoutEditorBaseProps) { + const [templates, setTemplates] = useState([]); + const [mode, setMode] = useState<'select' | 'create' | 'assign'>('select'); + const [selectedLayout, setSelectedLayout] = useState(null); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [layoutToDelete, setLayoutToDelete] = useState<{ id: string, name: string } | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [currentAssignedLayout, setCurrentAssignedLayout] = useState(null); + + // Progress steps configuration + const steps: Step[] = [ + {id: 'select', label: 'Select Layout'}, + {id: 'create', label: 'Edit Layout'}, + {id: 'assign', label: 'Assign Tiers'}, + ]; + + // If initialConfig is provided, we should start at the assign step + useEffect(() => { + if (initialConfig && initialConfig.name !== null) { + setCurrentAssignedLayout(initialConfig); + setMode('assign'); + } + }, [initialConfig]); + + const loadTemplates = useCallback(async () => { + setIsLoading(true); + try { + const res = await getSeatingLayoutTemplatesByOrg(organizationId, 0, 100); + setTemplates(res.content); + } catch (error) { + toast.error("Failed to load layout templates"); + console.error(error); + } finally { + setIsLoading(false); + } + }, [organizationId]); + + // Fetch templates when component loads or organization changes + useEffect(() => { + if (organizationId) { + loadTemplates().then(); + } + }, [loadTemplates, organizationId]); + + // Transform layout for tier assignment when selectedLayout changes + useEffect(() => { + if (!selectedLayout || mode !== 'assign') return; + + const transformedBlocks = selectedLayout.layout.blocks.map((block) => { + const newBlock: Block = { + ...block, + rows: [], + seats: [], + }; + + if (block.type === 'seated_grid' && block.rows && block.columns) { + const startRowIndex = block.startRowLabel ? block.startRowLabel.charCodeAt(0) - 'A'.charCodeAt(0) : 0; + const startCol = block.startColumnLabel || 1; + const numRows = block.rows; + const numColumns = block.columns; + + newBlock.rows = Array.from({length: numRows}, (_, rowIndex) => { + const newRow: Row = { + id: crypto.randomUUID(), + label: `${getRowLabel(startRowIndex + rowIndex)}`, + seats: Array.from({length: numColumns}, (_, colIndex) => ({ + id: crypto.randomUUID(), + label: `${startCol + colIndex}${getRowLabel(startRowIndex + rowIndex)}`, + status: SeatStatus.AVAILABLE, + })), + }; + return newRow; + }); + } else if (block.type === 'standing_capacity' && block.capacity) { + const capacity = block.capacity; + newBlock.seats = Array.from({length: capacity}, (_, i) => ({ + id: crypto.randomUUID(), + label: `Slot ${i + 1}`, + status: SeatStatus.AVAILABLE, + })); + } + return newBlock; + }); + + setCurrentAssignedLayout({ + name: selectedLayout.name, + layout: { + blocks: transformedBlocks, + }, + }); + }, [selectedLayout, mode]); + + // Check if we need to auto-navigate to step 1 when going to step 2 without a layout + useEffect(() => { + if (mode === 'create' && !selectedLayout && !initialConfig) { + toast.error("Please select a layout first"); + setMode('select'); + } + }, [mode, selectedLayout, initialConfig]); + + // Handle step navigation + const handleStepClick = (stepId: string) => { + // If trying to go to create step but no layout is selected, redirect to select + if (stepId === 'create' && !selectedLayout) { + toast.error("Please select a layout first"); + setMode('select'); + return; + } + + // If trying to go to assign step but no layout is prepared, redirect to select + if (stepId === 'assign' && !currentAssignedLayout && !selectedLayout) { + toast.error("Please select and configure a layout first"); + setMode('select'); + return; + } + + setMode(stepId as 'select' | 'create' | 'assign'); + }; + + // Handle layout selection + const handleLayoutSelect = (template: SeatingLayoutTemplateResponse) => { + setSelectedLayout(template.layoutData); + setSelectedTemplateId(template.id); + }; + + // Handle create from scratch + const handleCreateFromScratch = () => { + setSelectedLayout(null); + setSelectedTemplateId(null); + setMode('create'); + }; + + // Handle delete confirmation - open dialog + const handleDeleteConfirm = (id: string, name: string) => { + setLayoutToDelete({id, name}); + setIsDeleteDialogOpen(true); + }; + + // Handle actual deletion + const handleDeleteLayout = async () => { + if (!layoutToDelete) return; + + try { + await deleteSeatingLayoutTemplate(layoutToDelete.id); + toast.success(`Layout "${layoutToDelete.name}" deleted successfully`); + + // Refresh the templates list + await loadTemplates(); + + // If deleted template was selected, clear selection + if (selectedTemplateId === layoutToDelete.id) { + setSelectedLayout(null); + setSelectedTemplateId(null); + } + } catch (error) { + toast.error("Failed to delete layout template"); + console.error(error); + } finally { + setIsDeleteDialogOpen(false); + setLayoutToDelete(null); + } + }; + + // Handle tier assignments updates from the editor + const handleTierAssignmentUpdate = (layoutWithTiers: SessionSeatingMapRequest) => { + setCurrentAssignedLayout(layoutWithTiers); + }; + + const handleTierAssignmentSave = () => { + if (!currentAssignedLayout) { + toast.error("No layout data available"); + return; + } + + // Check if all seats and standing blocks have tier assignments + let hasUnassignedElements = false; + + for (const block of currentAssignedLayout.layout.blocks) { + // Check seated blocks (rows with seats) + if (block.type === 'seated_grid' && block.rows) { + for (const row of block.rows) { + for (const seat of row.seats) { + // Seat must either have a tierId or be marked as RESERVED + if (!seat.tierId && seat.status !== 'RESERVED') { + hasUnassignedElements = true; + break; + } + } + if (hasUnassignedElements) break; + } + } + + // Check standing capacity blocks + if (block.type === 'standing_capacity' && block.seats) { + // For standing blocks, check if at least one seat has a tier assignment + const hasAnyAssignedSeat = block.seats.some(seat => seat.tierId); + if (!hasAnyAssignedSeat && block.seats.length > 0) { + hasUnassignedElements = true; + break; + } + } + + if (hasUnassignedElements) break; + } + + if (hasUnassignedElements) { + toast.error("Please assign all seats to a tier or mark them as reserved. Standing areas must also have tier assignments."); + return; + } + onSave(currentAssignedLayout); + }; + + const handleSave = async (layoutData: LayoutData) => { + const request = { + name: layoutData.name, + organizationId, + layoutData, + }; + + try { + // If we're editing an existing template, update it instead of creating a new one + if (selectedTemplateId) { + const data = await updateSeatingLayoutTemplate(selectedTemplateId, request); + toast.success(`Layout "${data.name}" updated successfully!`); + setSelectedLayout(data.layoutData); + setMode("assign"); + await loadTemplates(); + } else { + console.log("Creating new seating layout template", request); + const data = await createSeatingLayoutTemplate(request); + toast.success(`Layout "${data.name}" saved successfully!`); + setSelectedLayout(data.layoutData); + setSelectedTemplateId(data.id); + setMode("assign"); + await loadTemplates(); + } + } catch (err) { + if (err instanceof Error) { + toast.error(err.message || 'Failed to save layout'); + } else { + toast.error('An unexpected error occurred while saving the layout'); + } + console.error(err); + } + }; + + // Navigation between steps + const goToPrevStep = () => { + if (mode === 'create') { + setMode('select'); + } else if (mode === 'assign') { + setMode('create'); + + // Check if we have a layout to edit + if (!selectedLayout) { + toast.error("No layout available to edit"); + setMode('select'); + } + } + }; + + const goToNextStep = () => { + if (mode === 'select') { + if (!selectedLayout) { + toast.error("Please select a layout first"); + return; + } + setMode('create'); + } else if (mode === 'create') { + setMode('assign'); + } + }; + + // Render different steps based on current mode + const renderCurrentStep = () => { + switch (mode) { + case 'create': + return ( +
+ +
+ ); + case 'assign': + // If we have initialConfig or currentAssignedLayout, show the TierAssignmentEditor + const layoutToEdit = currentAssignedLayout || initialConfig; + + return layoutToEdit ? ( + + ) : ( +
+

No layout data available.

+ +
+ ); + case 'select': + default: + return ( + + ); + } + }; + + return ( + <> + + + {renderCurrentStep()} + + + + + + ); +} From 247fd5a63adcd963adea336d886a06c83011fbeb Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 03:27:24 +0530 Subject: [PATCH 25/51] feat: add dialogs for editing session time, layout, location, and status management --- .../_components/ChangeStatusDialog.tsx | 95 ++++ .../_components/EditLayoutDialog.tsx | 80 +++ .../_components/EditTimeDialog.tsx | 167 +++++++ .../[eventId]/sessions/[sessionId]/page.tsx | 459 ++++++++++++------ 4 files changed, 648 insertions(+), 153 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditTimeDialog.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx new file mode 100644 index 0000000..dff8c48 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx @@ -0,0 +1,95 @@ +'use client' + +import * as React from 'react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { SessionStatus } from '@/types/enums/sessionStatus'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Label } from '@/components/ui/label'; +import { SessionStatusUpdateRequest } from '@/lib/actions/sessionActions'; + +interface ChangeStatusDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentStatus: string; + onSave: (data: SessionStatusUpdateRequest) => void; +} + +export function ChangeStatusDialog({ + open, + onOpenChange, + currentStatus, + onSave +}: ChangeStatusDialogProps) { + const [selectedStatus, setSelectedStatus] = useState(currentStatus); + + // Define allowed transitions based on current status + const allowedTransitions = React.useMemo(() => { + switch (currentStatus) { + case SessionStatus.SCHEDULED: + return [SessionStatus.ON_SALE, SessionStatus.CANCELED]; + case SessionStatus.ON_SALE: + return [SessionStatus.CLOSED]; + default: + return []; + } + }, [currentStatus]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (selectedStatus && selectedStatus !== currentStatus) { + onSave({ status: selectedStatus as SessionStatus }); + } + }; + + return ( + + + + Change Session Status + + +
+
+ + + {allowedTransitions.length === 0 && ( +

+ Status cannot be changed in the current state +

+ )} +
+ + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx new file mode 100644 index 0000000..82ace6f --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx @@ -0,0 +1,80 @@ +'use client' + +import * as React from 'react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { SessionType } from '@/types/enums/sessionType'; +import { PhysicalLayoutEditor } from './PhysicalLayoutEditor'; +import { OnlineLayoutEditor } from './OnlineLayoutEditor'; +import { SessionSeatingMapRequest, TierDTO } from '@/lib/validators/event'; +import { SessionLayoutUpdateRequest } from '@/lib/actions/sessionActions'; + +interface EditLayoutDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialLayout?: SessionSeatingMapRequest; + sessionType: SessionType; + tiers: TierDTO[]; + organizationId: string; + onSave: (data: SessionLayoutUpdateRequest) => void; +} + +export function EditLayoutDialog({ + open, + onOpenChange, + initialLayout, + sessionType, + tiers, + organizationId, + onSave +}: EditLayoutDialogProps) { + const [layoutData, setLayoutData] = useState(initialLayout); + + const handleLayoutChange = (layout: SessionSeatingMapRequest) => { + setLayoutData(layout); + }; + + const handleSave = () => { + if (layoutData) { + onSave({ layoutData }); + onOpenChange(false); + } + }; + + return ( + + + + Edit Seating Layout + + +
+ {sessionType === SessionType.PHYSICAL ? ( + + ) : ( + + )} +
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditTimeDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditTimeDialog.tsx new file mode 100644 index 0000000..dcbdff0 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditTimeDialog.tsx @@ -0,0 +1,167 @@ +'use client' + +import * as React from 'react'; +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { formatToDateTimeLocalString } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { SessionStatus } from '@/types/enums/sessionStatus'; +import { parseISO } from 'date-fns'; +import { SessionTimeUpdateRequest } from '@/lib/actions/sessionActions'; + +interface EditTimeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialData: { + startTime: string; + endTime: string; + salesStartTime?: string; + status: string; + }; + onSave: (data: SessionTimeUpdateRequest) => void; +} + +export function EditTimeDialog({ + open, + onOpenChange, + initialData, + onSave +}: EditTimeDialogProps) { + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [salesStartTime, setSalesStartTime] = useState(''); + const [errors, setErrors] = useState<{ + startTime?: string; + endTime?: string; + salesStartTime?: string; + general?: string; + }>({}); + + const isOnSaleStatus = initialData.status === SessionStatus.ON_SALE; + const now = new Date(); + + useEffect(() => { + if (open) { + setStartTime(formatToDateTimeLocalString(initialData.startTime)); + setEndTime(formatToDateTimeLocalString(initialData.endTime)); + setSalesStartTime(formatToDateTimeLocalString(initialData.salesStartTime)); + setErrors({}); + } + }, [open, initialData]); + + const validateForm = () => { + const newErrors: typeof errors = {}; + + if (!startTime) { + newErrors.startTime = "Start time is required"; + } + + if (!endTime) { + newErrors.endTime = "End time is required"; + } + + if (!isOnSaleStatus && !salesStartTime) { + newErrors.salesStartTime = "Sales start time is required"; + } + + // Check if session has already started + if (parseISO(initialData.startTime) < now) { + newErrors.general = "Cannot edit time for a session that has already started"; + } + + // Check date relationships + if (startTime && endTime && new Date(startTime) >= new Date(endTime)) { + newErrors.endTime = "End time must be after start time"; + } + + if (!isOnSaleStatus && startTime && salesStartTime && new Date(salesStartTime) >= new Date(startTime)) { + newErrors.salesStartTime = "Sales start time must be before session start time"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + const updateData: SessionTimeUpdateRequest = { + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + salesStartTime: salesStartTime + ? new Date(salesStartTime).toISOString() + : initialData.salesStartTime || '' + }; + + onSave(updateData); + }; + + return ( + + + + Edit Session Time + + +
+ {errors.general && ( +
+ {errors.general} +
+ )} + +
+ + setStartTime(e.target.value)} + /> + {errors.startTime &&

{errors.startTime}

} +
+ +
+ + setEndTime(e.target.value)} + /> + {errors.endTime &&

{errors.endTime}

} +
+ +
+ + setSalesStartTime(e.target.value)} + disabled={isOnSaleStatus} + /> + {errors.salesStartTime &&

{errors.salesStartTime}

} +
+ + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx index c86223f..0d82cb1 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -14,7 +14,17 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { SessionType } from "@/types/enums/sessionType"; import { SessionStatus } from "@/types/enums/sessionStatus"; import dynamic from "next/dynamic"; -import { deleteSession } from "@/lib/actions/sessionActions"; +import { + deleteSession, + updateSessionLayout, + updateSessionStatus, + updateSessionTime, + updateSessionVenue, + SessionTimeUpdateRequest, + SessionStatusUpdateRequest, + SessionLayoutUpdateRequest, + SessionVenueUpdateRequest +} from "@/lib/actions/sessionActions"; import { toast } from "sonner"; import { AlertDialog, @@ -28,6 +38,13 @@ import { } from "@/components/ui/alert-dialog"; import { SessionSeatingLayout } from "./_components/SessionSeatingLayout"; import { SeatingCapacitySummary } from "./_components/seatingCapacitySummary"; +import { getSalesWindowDuration, getSalesStartTimeDisplay } from '@/lib/utils'; + +// Import edit dialogs +import { EditTimeDialog } from './_components/EditTimeDialog'; +import { LocationEditDialog } from './_components/LocationEditDialog'; +import { EditLayoutDialog } from './_components/EditLayoutDialog'; +import { ChangeStatusDialog } from './_components/ChangeStatusDialog'; // Dynamically import the new VenueMap component const VenueMap = dynamic( @@ -58,10 +75,20 @@ const getStatusProperties = (status: string | undefined) => { const SessionPage = () => { const params = useParams(); const sessionId = params.sessionId as string; + const organizationId = params.organization_id as string; const router = useRouter(); const { event, refetchEventData } = useEventContext(); + + // Dialog states const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isEditTimeDialogOpen, setIsEditTimeDialogOpen] = useState(false); + const [isEditLocationDialogOpen, setIsEditLocationDialogOpen] = useState(false); + const [isEditLayoutDialogOpen, setIsEditLayoutDialogOpen] = useState(false); + const [isChangeStatusDialogOpen, setIsChangeStatusDialogOpen] = useState(false); + + // Operation states const [isDeleting, setIsDeleting] = useState(false); + const [isSaving, setIsSaving] = useState(false); if (!event) { return ( @@ -129,6 +156,70 @@ const SessionPage = () => { } }; + // Handle session time update + const handleTimeUpdate = async (timeData: SessionTimeUpdateRequest) => { + try { + setIsSaving(true); + await updateSessionTime(sessionId, timeData); + toast.success('Session time updated successfully'); + await refetchEventData(); + setIsEditTimeDialogOpen(false); + } catch (error) { + console.error('Error updating session time:', error); + toast.error('Failed to update session time'); + } finally { + setIsSaving(false); + } + }; + + // Handle location/venue update + const handleVenueUpdate = async (venueDetails: any) => { + try { + setIsSaving(true); + await updateSessionVenue(sessionId, { venueDetails }); + toast.success('Venue details updated successfully'); + await refetchEventData(); + setIsEditLocationDialogOpen(false); + } catch (error) { + console.error('Error updating venue details:', error); + toast.error('Failed to update venue details'); + } finally { + setIsSaving(false); + } + }; + + // Handle layout update + const handleLayoutUpdate = async (layoutData: SessionLayoutUpdateRequest) => { + try { + setIsSaving(true); + await updateSessionLayout(sessionId, layoutData); + toast.success('Seating layout updated successfully'); + await refetchEventData(); + setIsEditLayoutDialogOpen(false); + } catch (error) { + console.error('Error updating seating layout:', error); + toast.error('Failed to update seating layout'); + } finally { + setIsSaving(false); + } + }; + + // Handle status change + const handleStatusUpdate = async (statusData: SessionStatusUpdateRequest) => { + try { + setIsSaving(true); + await updateSessionStatus(sessionId, statusData); + toast.success(`Session status updated to ${statusData.status}`); + await refetchEventData(); + setIsChangeStatusDialogOpen(false); + } catch (error) { + console.error('Error updating session status:', error); + toast.error('Failed to update session status'); + } finally { + setIsSaving(false); + } + }; + // Handle delete const handleDelete = async () => { try { @@ -145,6 +236,19 @@ const SessionPage = () => { setIsDeleteDialogOpen(false); } }; + + // Business logic for edit permissions + const canEditTime = session?.status === SessionStatus.SCHEDULED || + (session?.status === SessionStatus.ON_SALE && new Date(session.startTime) > new Date()); + + const canEditVenue = session?.status === SessionStatus.SCHEDULED; + + const canEditLayout = session?.status === SessionStatus.SCHEDULED; + + const canDelete = session?.status === SessionStatus.SCHEDULED; + + const canChangeStatus = session?.status === SessionStatus.SCHEDULED || + session?.status === SessionStatus.ON_SALE; return (
@@ -177,25 +281,37 @@ const SessionPage = () => {

- - + + + + + + +

Share Session

+
+
+
+ + {canDelete && ( + + + + + + +

Delete Session

+
+
+
+ )}
@@ -227,191 +343,228 @@ const SessionPage = () => { {/* Session Metadata Section */} - - - Session Information - - - {React.createElement(statusProps.icon, { className: "h-3 w-3 mr-1 inline" })} - {status || 'PENDING'} - + Session Details + {canEditTime && ( + + )} -
-
-
-
Start Time
-
- - {format(startDate, 'EEEE, MMMM d, yyyy h:mm a')} -
-
- -
-
End Time
-
- - {format(endDate, 'EEEE, MMMM d, yyyy h:mm a')} -
+
+
+
Start Time
+
{format(startDate, 'EEEE, MMMM d, yyyy h:mm a')}
+
+
+
End Time
+
{format(endDate, 'EEEE, MMMM d, yyyy h:mm a')}
+
+
+
Duration
+
{getDuration()}
+
+
+
Sales Start
+
{salesStartDate ? format(salesStartDate, 'MMM d, yyyy h:mm a') : 'Not set'}
+
+ {salesStartDate && ( +
+
Sales Window
+
{getSalesWindowDuration(session.salesStartTime, session.startTime)}
- + )} +
+ + {(canChangeStatus || session.status !== SessionStatus.CANCELED) && ( +
-
Duration
+
Session Status
- - {getDuration()} + + {session.status?.replace('_', ' ')} + + + {session.status === SessionStatus.SCHEDULED && salesStartDate && + getSalesStartTimeDisplay(session.salesStartTime)} +
+ {canChangeStatus && ( + + )}
- -
-
-
Sales Start Time
- {salesStartDate ? ( -
-
- - {format(salesStartDate, 'MMM d, yyyy h:mm a')} -
-
- ) : ( - - - - Sales start time not set - - - )} -
-
-
+ )} {/* Venue Information Section */} - - - - - -
- {isOnline ? ( - - ) : ( - - )} -
-
- -

{isOnline ? 'Online Session' : 'Physical Session'}

-
-
-
- {isOnline ? 'Online Event Details' : 'Venue Details'} + + + {isOnline ? 'Online Event Information' : 'Venue Information'} + {canEditVenue && ( + + )} {isOnline ? ( -
-
Online Link
+
{venueDetails?.onlineLink ? ( -
{venueDetails.onlineLink}
+
+
+
Online Meeting Link
+ + + {venueDetails.onlineLink} + +
+
) : ( - - - - Online link not provided - - - )} - {/* Capacity summary for online events */} - {layoutData && layoutData.layout.blocks.length > 0 && ( -
- +
+ No online link has been provided for this session.
)}
) : ( - <> -
-
-
-
Venue Name
- {venueDetails?.name ? ( -
{venueDetails.name}
- ) : ( -
Not specified
- )} -
- - {venueDetails?.address && ( -
-
Address
-
{venueDetails.address}
-
- )} +
+
+
+
Venue Name
+
{venueDetails?.name || 'Not specified'}
- - {/* Map for physical events */} -
- +
+
Address
+
{venueDetails?.address || 'Not specified'}
- - {/* Add a note about the map */} -
- Map shows approximate location based on venue coordinates + + {/* Map */} +
+ {venueDetails?.latitude && venueDetails?.longitude ? ( + + ) : ( +
+
+ +

No location coordinates specified

+
+
+ )}
- +
)} - {/* Seating Layout Section */} + {/* Seating Layout Section */} {!isOnline && layoutData && layoutData.layout.blocks.length > 0 && ( - + Seating Layout + {canEditLayout && ( + + )} -
- -
+
)} + {/* Edit Time Dialog */} + + + {/* Edit Location Dialog */} + + + {/* Edit Layout Dialog */} + + + {/* Change Status Dialog */} + + {/* Delete Confirmation Dialog */} - Are you sure you want to delete this session? + Delete Session - This action cannot be undone. This will permanently delete the session - and all its associated data. + Are you sure you want to delete this session? This action cannot be undone. Cancel - - {isDeleting ? 'Deleting...' : 'Delete'} + {isDeleting ? "Deleting..." : "Delete"} From 04676ffa8ecf8d8bb871468fc37b8530de8bedeb Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 03:40:13 +0530 Subject: [PATCH 26/51] feat: add components for session management including seating layout, metadata, status banner, and venue information --- .../_components/SeatingLayoutSection.tsx | 44 +++ .../[sessionId]/_components/SessionHeader.tsx | 92 ++++++ .../_components/SessionMetadata.tsx | 110 +++++++ .../[sessionId]/_components/StatusBanner.tsx | 52 +++ .../_components/VenueInformation.tsx | 114 +++++++ .../[sessionId]/_components/index.tsx | 11 + .../[eventId]/sessions/[sessionId]/page.tsx | 308 +++--------------- 7 files changed, 474 insertions(+), 257 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/StatusBanner.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/index.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx new file mode 100644 index 0000000..22903dc --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx @@ -0,0 +1,44 @@ +'use client' + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { SessionSeatingLayout } from './SessionSeatingLayout'; +import { SeatingCapacitySummary } from './seatingCapacitySummary'; + +interface SeatingLayoutSectionProps { + session: any; // Use the appropriate type here + tiers: any[]; // Use the appropriate type here + canEditLayout: boolean; + onEditLayout: () => void; +} + +export const SeatingLayoutSection: React.FC = ({ + session, + tiers, + canEditLayout, + onEditLayout +}) => { + return ( + + + Seating Layout + {canEditLayout && ( + + )} + + + + + + ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx new file mode 100644 index 0000000..f2cc563 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx @@ -0,0 +1,92 @@ +'use client' + +import React from 'react'; +import { format } from 'date-fns'; +import { LinkIcon, MapPin, Share2, Trash2 } from 'lucide-react'; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { SessionType } from "@/types/enums/sessionType"; + +interface SessionHeaderProps { + title: string; + startDate: Date; + endDate: Date; + isOnline: boolean; + canDelete: boolean; + onShare: () => void; + onDelete: () => void; +} + +export const SessionHeader: React.FC = ({ + title, + startDate, + endDate, + isOnline, + canDelete, + onShare, + onDelete +}) => { + return ( +
+
+
+ + + +
+ {isOnline ? + : + + } +
+
+ +

{isOnline ? 'Online Session' : 'Physical Session'}

+
+
+
+

{title}

+
+

+ {startDate.toDateString() === endDate.toDateString() + ? `Session on ${format(startDate, 'EEEE, MMMM d, yyyy')}` + : `Session from ${format(startDate, 'MMM d')} to ${format(endDate, 'MMM d, yyyy')}` + } +

+
+
+ + + + + + +

Share Session

+
+
+
+ + {canDelete && ( + + + + + + +

Delete Session

+
+
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx new file mode 100644 index 0000000..5e044f5 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx @@ -0,0 +1,110 @@ +'use client' + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { format } from 'date-fns'; +import { SessionStatus } from '@/types/enums/sessionStatus'; +import { getSalesWindowDuration, getSalesStartTimeDisplay } from '@/lib/utils'; + +interface SessionMetadataProps { + startDate: Date; + endDate: Date; + salesStartDate: Date | null; + status: string; + statusProps: { + variant: "default" | "destructive" | "outline" | "secondary" | null | undefined; + }; + salesStartTime?: string; + startTime: string; + canEditTime: boolean; + canChangeStatus: boolean; + onEditTime: () => void; + onChangeStatus: () => void; + getDuration: () => string; +} + +export const SessionMetadata: React.FC = ({ + startDate, + endDate, + salesStartDate, + status, + statusProps, + salesStartTime, + startTime, + canEditTime, + canChangeStatus, + onEditTime, + onChangeStatus, + getDuration +}) => { + return ( + + + Session Details + {canEditTime && ( + + )} + + +
+
+
Start Time
+
{format(startDate, 'EEEE, MMMM d, yyyy h:mm a')}
+
+
+
End Time
+
{format(endDate, 'EEEE, MMMM d, yyyy h:mm a')}
+
+
+
Duration
+
{getDuration()}
+
+
+
Sales Start
+
{salesStartDate ? format(salesStartDate, 'MMM d, yyyy h:mm a') : 'Not set'}
+
+ {salesStartDate && salesStartTime && ( +
+
Sales Window
+
{getSalesWindowDuration(salesStartTime, startTime)}
+
+ )} +
+ + {(canChangeStatus || status !== SessionStatus.CANCELED) && ( +
+
+
Session Status
+
+ + {status?.replace('_', ' ')} + + + {status === SessionStatus.SCHEDULED && salesStartDate && + getSalesStartTimeDisplay(salesStartTime)} + +
+
+ {canChangeStatus && ( + + )} +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/StatusBanner.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/StatusBanner.tsx new file mode 100644 index 0000000..e80bf62 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/StatusBanner.tsx @@ -0,0 +1,52 @@ +'use client' + +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { SessionStatus } from '@/types/enums/sessionStatus'; +import { format } from 'date-fns'; + +interface StatusBannerProps { + status: string; + statusProps: { + variant: "default" | "destructive" | "outline" | "secondary" | null | undefined; + color: string; + icon: React.ElementType; + }; + salesStartDate: Date | null; +} + +export const StatusBanner: React.FC = ({ + status, + statusProps, + salesStartDate +}) => { + return ( +
+
+ {React.createElement(statusProps.icon, { className: "h-5 w-5" })} +
+
+

+ Session Status: + + {status || 'PENDING'} + +

+

+ {status === SessionStatus.PENDING && "This session is pending and can be edited."} + {status === SessionStatus.SCHEDULED && "This session is scheduled and some fields can still be edited."} + {status === SessionStatus.ON_SALE && "This session is on sale. Limited editing is available."} + {status === SessionStatus.SOLD_OUT && "This session is sold out. Limited editing is available."} + {status === SessionStatus.CLOSED && "This session is closed and can't be edited."} + {status === SessionStatus.CANCELED && "This session has been canceled."} + {status === SessionStatus.SCHEDULED && salesStartDate && ( + <> +
+ Sales will begin {format(salesStartDate, 'MMMM d, yyyy')} at {format(salesStartDate, 'h:mm a')} + + )} +

+
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx new file mode 100644 index 0000000..0434954 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx @@ -0,0 +1,114 @@ +'use client' + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { LinkIcon, MapPin } from 'lucide-react'; +import dynamic from 'next/dynamic'; + +// Dynamically import the VenueMap component +const VenueMap = dynamic( + () => import('./VenueMap'), + { ssr: false } +); + +interface VenueInformationProps { + isOnline: boolean; + venueDetails: { + name?: string; + address?: string; + latitude?: number; + longitude?: number; + onlineLink?: string; + }; + canEditVenue: boolean; + onEditVenue: () => void; +} + +export const VenueInformation: React.FC = ({ + isOnline, + venueDetails, + canEditVenue, + onEditVenue +}) => { + // For the map - set default to Colombo if no coordinates + const mapCenter: [number, number] = + venueDetails?.latitude && venueDetails?.longitude + ? [venueDetails.latitude, venueDetails.longitude] + : [6.9271, 79.8612]; // Default to Colombo + + return ( + + + + {isOnline ? 'Online Event Information' : 'Venue Information'} + + {canEditVenue && ( + + )} + + + {isOnline ? ( +
+ {venueDetails?.onlineLink ? ( +
+
+
Online Meeting Link
+ + + {venueDetails.onlineLink} + +
+
+ ) : ( +
+ No online link has been provided for this session. +
+ )} +
+ ) : ( +
+
+
+
Venue Name
+
{venueDetails?.name || 'Not specified'}
+
+
+
Address
+
{venueDetails?.address || 'Not specified'}
+
+
+ + {/* Map */} +
+ {venueDetails?.latitude && venueDetails?.longitude ? ( + + ) : ( +
+
+ +

No location coordinates specified

+
+
+ )} +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/index.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/index.tsx new file mode 100644 index 0000000..bfb3036 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/index.tsx @@ -0,0 +1,11 @@ +'use client' + +// Export all session page components +export { SessionHeader } from './SessionHeader'; +export { StatusBanner } from './StatusBanner'; +export { SessionMetadata } from './SessionMetadata'; +export { VenueInformation } from './VenueInformation'; +export { SeatingLayoutSection } from './SeatingLayoutSection'; + +// Import these components with: +// import { SessionHeader, StatusBanner, SessionMetadata, VenueInformation, SeatingLayoutSection } from './_components'; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx index 0d82cb1..6e65857 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -4,16 +4,10 @@ import React, { useState } from 'react'; import { useEventContext } from "@/providers/EventProvider"; import { useParams, useRouter } from "next/navigation"; import { format, parseISO } from 'date-fns'; -import { AlertTriangle, Calendar, Clock, LinkIcon, MapPin, Share2, Tag, Trash2 } from 'lucide-react'; -import { Button } from "@/components/ui/button"; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { AlertTriangle, Calendar, Clock, Tag } from 'lucide-react'; import { Separator } from "@/components/ui/separator"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { SessionType } from "@/types/enums/sessionType"; import { SessionStatus } from "@/types/enums/sessionStatus"; -import dynamic from "next/dynamic"; import { deleteSession, updateSessionLayout, @@ -36,9 +30,15 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { SessionSeatingLayout } from "./_components/SessionSeatingLayout"; -import { SeatingCapacitySummary } from "./_components/seatingCapacitySummary"; -import { getSalesWindowDuration, getSalesStartTimeDisplay } from '@/lib/utils'; + +// Import page components +import { + SessionHeader, + StatusBanner, + SessionMetadata, + VenueInformation, + SeatingLayoutSection +} from './_components'; // Import edit dialogs import { EditTimeDialog } from './_components/EditTimeDialog'; @@ -46,12 +46,6 @@ import { LocationEditDialog } from './_components/LocationEditDialog'; import { EditLayoutDialog } from './_components/EditLayoutDialog'; import { ChangeStatusDialog } from './_components/ChangeStatusDialog'; -// Dynamically import the new VenueMap component -const VenueMap = dynamic( - () => import('./_components/VenueMap'), - { ssr: false } -); - // Helper function to get status badge variant and color const getStatusProperties = (status: string | undefined) => { switch (status) { @@ -253,257 +247,57 @@ const SessionPage = () => { return (
{/* Action buttons and header */} -
-
-
- - - -
- {isOnline ? - : - - } -
-
- -

{isOnline ? 'Online Session' : 'Physical Session'}

-
-
-
-

{event.title}

-
-

- {startDate.toDateString() === endDate.toDateString() - ? `Session on ${format(startDate, 'EEEE, MMMM d, yyyy')}` - : `Session from ${format(startDate, 'MMM d')} to ${format(endDate, 'MMM d, yyyy')}` - } -

-
-
- - - - - - -

Share Session

-
-
-
- - {canDelete && ( - - - - - - -

Delete Session

-
-
-
- )} -
-
+ setIsDeleteDialogOpen(true)} + /> {/* Status banner */} -
-
- {React.createElement(statusProps.icon, { className: "h-5 w-5" })} -
-
-

- Session Status: - - {status || 'PENDING'} - -

-

- {status === SessionStatus.PENDING && "This session is pending and can be edited."} - {status === SessionStatus.SCHEDULED && "This session is scheduled and some fields can still be edited."} - {status === SessionStatus.ON_SALE && "This session is on sale. Limited editing is available."} - {status === SessionStatus.SOLD_OUT && "This session is sold out. Limited editing is available."} - {status === SessionStatus.CLOSED && "This session is closed and can't be edited."} - {status === SessionStatus.CANCELED && "This session has been canceled."} -

-
-
+ {/* Session Metadata Section */} - - - Session Details - {canEditTime && ( - - )} - - -
-
-
Start Time
-
{format(startDate, 'EEEE, MMMM d, yyyy h:mm a')}
-
-
-
End Time
-
{format(endDate, 'EEEE, MMMM d, yyyy h:mm a')}
-
-
-
Duration
-
{getDuration()}
-
-
-
Sales Start
-
{salesStartDate ? format(salesStartDate, 'MMM d, yyyy h:mm a') : 'Not set'}
-
- {salesStartDate && ( -
-
Sales Window
-
{getSalesWindowDuration(session.salesStartTime, session.startTime)}
-
- )} -
- - {(canChangeStatus || session.status !== SessionStatus.CANCELED) && ( -
-
-
Session Status
-
- - {session.status?.replace('_', ' ')} - - - {session.status === SessionStatus.SCHEDULED && salesStartDate && - getSalesStartTimeDisplay(session.salesStartTime)} - -
-
- {canChangeStatus && ( - - )} -
- )} -
-
+ setIsEditTimeDialogOpen(true)} + onChangeStatus={() => setIsChangeStatusDialogOpen(true)} + getDuration={getDuration} + /> {/* Venue Information Section */} - - - - {isOnline ? 'Online Event Information' : 'Venue Information'} - - {canEditVenue && ( - - )} - - - {isOnline ? ( -
- {venueDetails?.onlineLink ? ( -
-
-
Online Meeting Link
- - - {venueDetails.onlineLink} - -
-
- ) : ( -
- No online link has been provided for this session. -
- )} -
- ) : ( -
-
-
-
Venue Name
-
{venueDetails?.name || 'Not specified'}
-
-
-
Address
-
{venueDetails?.address || 'Not specified'}
-
-
- - {/* Map */} -
- {venueDetails?.latitude && venueDetails?.longitude ? ( - - ) : ( -
-
- -

No location coordinates specified

-
-
- )} -
-
- )} -
-
+ setIsEditLocationDialogOpen(true)} + /> - {/* Seating Layout Section */} + {/* Seating Layout Section */} {!isOnline && layoutData && layoutData.layout.blocks.length > 0 && ( - - - Seating Layout - {canEditLayout && ( - - )} - - - - - + setIsEditLayoutDialogOpen(true)} + /> )} {/* Edit Time Dialog */} From 46fa52c4ebb6e866f16e0840e8294590607872be Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 04:41:47 +0530 Subject: [PATCH 27/51] feat: enhance session management components with improved layout and metadata display --- .../_components/SeatingLayoutSection.tsx | 40 +++++- .../[sessionId]/_components/SessionHeader.tsx | 116 ++++++++-------- .../_components/SessionMetadata.tsx | 125 ++++++++++-------- .../_components/VenueInformation.tsx | 116 ++++++++++------ .../[eventId]/sessions/[sessionId]/page.tsx | 12 +- 5 files changed, 236 insertions(+), 173 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx index 22903dc..a94989d 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx @@ -5,6 +5,18 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { SessionSeatingLayout } from './SessionSeatingLayout'; import { SeatingCapacitySummary } from './seatingCapacitySummary'; +import { Grid3x3, Rows, Armchair, Pencil, Users } from 'lucide-react'; + +// A small helper component for consistent display of information items +const InfoItem = ({ icon: Icon, label, children }: { icon: React.ElementType, label: string, children: React.ReactNode }) => ( +
+ +
+ {label} + {children} +
+
+); interface SeatingLayoutSectionProps { session: any; // Use the appropriate type here @@ -19,25 +31,39 @@ export const SeatingLayoutSection: React.FC = ({ canEditLayout, onEditLayout }) => { + // Get total capacity if available + const totalCapacity = tiers?.reduce((acc, tier) => acc + (tier.capacity || 0), 0) || 0; + const tierCount = tiers?.length || 0; + return ( - - Seating Layout + +
+ + Seating Layout +
{canEditLayout && ( )}
- +
+
+

VISUAL LAYOUT

+ +
+
); diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx index f2cc563..1987900 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx @@ -2,10 +2,12 @@ import React from 'react'; import { format } from 'date-fns'; -import { LinkIcon, MapPin, Share2, Trash2 } from 'lucide-react'; +import { LinkIcon, MapPin, Share2, Trash2, ChevronLeft, ArrowLeft } from 'lucide-react'; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { SessionType } from "@/types/enums/sessionType"; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; interface SessionHeaderProps { title: string; @@ -26,66 +28,64 @@ export const SessionHeader: React.FC = ({ onShare, onDelete }) => { + const params = useParams(); + const backUrl = `/manage/organization/${params.organization_id}/event/${params.eventId}/sessions`; return ( -
+
+ {/* Back button */}
-
- - - -
- {isOnline ? - : - - } -
-
- -

{isOnline ? 'Online Session' : 'Physical Session'}

-
-
-
-

{title}

-
-

- {startDate.toDateString() === endDate.toDateString() - ? `Session on ${format(startDate, 'EEEE, MMMM d, yyyy')}` - : `Session from ${format(startDate, 'MMM d')} to ${format(endDate, 'MMM d, yyyy')}` - } -

+ + +
-
- - - - - - -

Share Session

-
-
-
- - {canDelete && ( - - - - - - -

Delete Session

-
-
-
- )} + +
+
+
+ + + +
+ {isOnline ? + : + + } +
+
+ +

{isOnline ? 'Online Session' : 'Physical Session'}

+
+
+
+

{title}

+
+

+ {startDate.toDateString() === endDate.toDateString() + ? `Session on ${format(startDate, 'EEEE, MMMM d, yyyy')}` + : `Session from ${format(startDate, 'MMM d')} to ${format(endDate, 'MMM d, yyyy')}` + } +

+
+
+ + + {canDelete && ( + + )} +
); diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx index 5e044f5..82839f0 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx @@ -7,6 +7,19 @@ import { Badge } from '@/components/ui/badge'; import { format } from 'date-fns'; import { SessionStatus } from '@/types/enums/sessionStatus'; import { getSalesWindowDuration, getSalesStartTimeDisplay } from '@/lib/utils'; +import { Calendar, Clock, Timer, AlarmClock, Ticket, Pencil, Tag } from 'lucide-react'; + +// A small helper component for consistent display of information items +const InfoItem = ({ icon: Icon, label, children }: { icon: React.ElementType, label: string, children: React.ReactNode }) => ( +
+ +
+ {label} + {children} +
+
+); + interface SessionMetadataProps { startDate: Date; @@ -41,69 +54,65 @@ export const SessionMetadata: React.FC = ({ }) => { return ( - - Session Details - {canEditTime && ( - - )} + +
+ + Session Details +
+
+ + {status?.replace('_', ' ')} + + {canEditTime && ( + + )} + {canChangeStatus && ( + + )} +
- -
-
-
Start Time
-
{format(startDate, 'EEEE, MMMM d, yyyy h:mm a')}
-
-
-
End Time
-
{format(endDate, 'EEEE, MMMM d, yyyy h:mm a')}
+ +
+ {/* --- Session Timing Section --- */} +
+

SESSION TIMING

+
+ + {format(startDate, 'MMM d, yyyy ')} at {format(startDate, 'h:mm a')} + + + {format(endDate, 'MMM d, yyyy ')} at {format(endDate, 'h:mm a')} + + + {getDuration()} + +
-
-
Duration
-
{getDuration()}
-
-
-
Sales Start
-
{salesStartDate ? format(salesStartDate, 'MMM d, yyyy h:mm a') : 'Not set'}
-
- {salesStartDate && salesStartTime && ( -
-
Sales Window
-
{getSalesWindowDuration(salesStartTime, startTime)}
+ + {/* --- Sales Timing Section (Conditional) --- */} + {salesStartDate && ( +
+

SALES TIMING

+
+ + {format(salesStartDate, 'MMM d, yyyy')} at {format(salesStartDate, 'h:mm a')} + + + {salesStartTime && ( + + {getSalesWindowDuration(salesStartTime, startTime)} + + )} +
)}
- - {(canChangeStatus || status !== SessionStatus.CANCELED) && ( -
-
-
Session Status
-
- - {status?.replace('_', ' ')} - - - {status === SessionStatus.SCHEDULED && salesStartDate && - getSalesStartTimeDisplay(salesStartTime)} - -
-
- {canChangeStatus && ( - - )} -
- )} ); diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx index 0434954..6989fcc 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { LinkIcon, MapPin } from 'lucide-react'; +import { LinkIcon, MapPin, Building2, Navigation, Pencil } from 'lucide-react'; import dynamic from 'next/dynamic'; // Dynamically import the VenueMap component @@ -12,6 +12,17 @@ const VenueMap = dynamic( { ssr: false } ); +// A small helper component for consistent display of information items +const InfoItem = ({ icon: Icon, label, children }: { icon: React.ElementType, label: string, children: React.ReactNode }) => ( +
+ +
+ {label} + {children} +
+
+); + interface VenueInformationProps { isOnline: boolean; venueDetails: { @@ -39,72 +50,89 @@ export const VenueInformation: React.FC = ({ return ( - - - {isOnline ? 'Online Event Information' : 'Venue Information'} - + +
+ {isOnline ? ( + + ) : ( + + )} + + {isOnline ? 'Online Event' : 'Venue Details'} + +
{canEditVenue && ( - )}
{isOnline ? ( -
- {venueDetails?.onlineLink ? ( -
-
-
Online Meeting Link
+
+
+

ONLINE DETAILS

+ {venueDetails?.onlineLink ? ( + - {venueDetails.onlineLink} + + ) : ( +
+ No online link has been provided for this session.
-
- ) : ( -
- No online link has been provided for this session. -
- )} + )} +
) : (
-
-
-
Venue Name
-
{venueDetails?.name || 'Not specified'}
-
-
-
Address
-
{venueDetails?.address || 'Not specified'}
+
+

VENUE DETAILS

+
+ + {venueDetails?.name || 'Not specified'} + + + {venueDetails?.address || 'Not specified'} + + {venueDetails?.latitude && venueDetails?.longitude && ( + + {venueDetails.latitude.toFixed(6)}, {venueDetails.longitude.toFixed(6)} + + )}
{/* Map */} -
- {venueDetails?.latitude && venueDetails?.longitude ? ( - - ) : ( -
-
- -

No location coordinates specified

+
+

LOCATION MAP

+
+ {venueDetails?.latitude && venueDetails?.longitude ? ( + + ) : ( +
+
+ +

No location coordinates specified

+
-
- )} + )} +
)} diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx index 6e65857..8a79e87 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -71,7 +71,7 @@ const SessionPage = () => { const sessionId = params.sessionId as string; const organizationId = params.organization_id as string; const router = useRouter(); - const { event, refetchEventData } = useEventContext(); + const { event, refetchSession } = useEventContext(); // Dialog states const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -156,7 +156,7 @@ const SessionPage = () => { setIsSaving(true); await updateSessionTime(sessionId, timeData); toast.success('Session time updated successfully'); - await refetchEventData(); + await refetchSession(sessionId); setIsEditTimeDialogOpen(false); } catch (error) { console.error('Error updating session time:', error); @@ -172,7 +172,7 @@ const SessionPage = () => { setIsSaving(true); await updateSessionVenue(sessionId, { venueDetails }); toast.success('Venue details updated successfully'); - await refetchEventData(); + await refetchSession(sessionId); setIsEditLocationDialogOpen(false); } catch (error) { console.error('Error updating venue details:', error); @@ -188,7 +188,7 @@ const SessionPage = () => { setIsSaving(true); await updateSessionLayout(sessionId, layoutData); toast.success('Seating layout updated successfully'); - await refetchEventData(); + await refetchSession(sessionId); setIsEditLayoutDialogOpen(false); } catch (error) { console.error('Error updating seating layout:', error); @@ -204,7 +204,7 @@ const SessionPage = () => { setIsSaving(true); await updateSessionStatus(sessionId, statusData); toast.success(`Session status updated to ${statusData.status}`); - await refetchEventData(); + await refetchSession(sessionId); setIsChangeStatusDialogOpen(false); } catch (error) { console.error('Error updating session status:', error); @@ -221,7 +221,7 @@ const SessionPage = () => { await deleteSession(sessionId); toast.success('Session deleted successfully'); router.push(`/manage/organization/${params.organization_id}/event/${params.eventId}`); - await refetchEventData(); + await refetchSession(sessionId); } catch (error) { console.error('Error deleting session:', error); toast.error('Failed to delete session'); From fba2b655491fdedb9fbd151c45a73879a35337c3 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 11:41:44 +0530 Subject: [PATCH 28/51] feat: implement EditLayoutPage for managing session seating layout and update SeatingLayoutSection to link to the new page --- .../_components/EditLayoutDialog.tsx | 22 +--- .../_components/SeatingLayoutSection.tsx | 31 ++++-- .../sessions/[sessionId]/edit-layout/page.tsx | 104 ++++++++++++++++++ .../[eventId]/sessions/[sessionId]/page.tsx | 32 +----- 4 files changed, 130 insertions(+), 59 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/edit-layout/page.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx index 82ace6f..5a59d32 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx @@ -29,17 +29,10 @@ export function EditLayoutDialog({ organizationId, onSave }: EditLayoutDialogProps) { - const [layoutData, setLayoutData] = useState(initialLayout); - - const handleLayoutChange = (layout: SessionSeatingMapRequest) => { - setLayoutData(layout); - }; - - const handleSave = () => { - if (layoutData) { - onSave({ layoutData }); - onOpenChange(false); - } + // Direct save function that will be passed to the editors + const handleDirectSave = (layout: SessionSeatingMapRequest) => { + onSave({ layoutData: layout }); + onOpenChange(false); }; return ( @@ -52,14 +45,14 @@ export function EditLayoutDialog({
{sessionType === SessionType.PHYSICAL ? ( ) : ( @@ -70,9 +63,6 @@ export function EditLayoutDialog({ - diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx index a94989d..30b48a8 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx @@ -6,6 +6,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { SessionSeatingLayout } from './SessionSeatingLayout'; import { SeatingCapacitySummary } from './seatingCapacitySummary'; import { Grid3x3, Rows, Armchair, Pencil, Users } from 'lucide-react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; // A small helper component for consistent display of information items const InfoItem = ({ icon: Icon, label, children }: { icon: React.ElementType, label: string, children: React.ReactNode }) => ( @@ -22,15 +24,19 @@ interface SeatingLayoutSectionProps { session: any; // Use the appropriate type here tiers: any[]; // Use the appropriate type here canEditLayout: boolean; - onEditLayout: () => void; } export const SeatingLayoutSection: React.FC = ({ session, tiers, - canEditLayout, - onEditLayout + canEditLayout }) => { + const params = useParams(); + const organizationId = params.organization_id as string; + const eventId = params.eventId as string; + const sessionId = params.sessionId as string; + const editLayoutUrl = `/manage/organization/${organizationId}/event/${eventId}/sessions/${sessionId}/edit-layout`; + // Get total capacity if available const totalCapacity = tiers?.reduce((acc, tier) => acc + (tier.capacity || 0), 0) || 0; const tierCount = tiers?.length || 0; @@ -43,15 +49,16 @@ export const SeatingLayoutSection: React.FC = ({ Seating Layout
{canEditLayout && ( - + + + )} diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/edit-layout/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/edit-layout/page.tsx new file mode 100644 index 0000000..7906cc5 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/edit-layout/page.tsx @@ -0,0 +1,104 @@ +'use client' + +import React, { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { ChevronLeft, Save } from 'lucide-react'; +import { useEventContext } from "@/providers/EventProvider"; +import { toast } from "sonner"; +import { SessionType } from '@/types/enums/sessionType'; +import { PhysicalLayoutEditor } from '../_components/PhysicalLayoutEditor'; +import { OnlineLayoutEditor } from '../_components/OnlineLayoutEditor'; +import { SessionSeatingMapRequest } from '@/lib/validators/event'; +import { updateSessionLayout, SessionLayoutUpdateRequest } from '@/lib/actions/sessionActions'; +import Link from 'next/link'; + +export default function EditLayoutPage() { + const params = useParams(); + const router = useRouter(); + const { event, refetchSession } = useEventContext(); + + const [isSaving, setIsSaving] = useState(false); + + const sessionId = params.sessionId as string; + const organizationId = params.organization_id as string; + const eventId = params.eventId as string; + const returnUrl = `/manage/organization/${organizationId}/event/${eventId}/sessions/${sessionId}`; + + if (!event) { + return ( +
+ Event Not Found +
+ ); + } + + const session = event.sessions.find(s => s.id === sessionId); + + if (!session) { + return ( +
+ Session Not Found +
+ ); + } + + const { layoutData, sessionType } = session; + + const handleSave = async (layout: SessionSeatingMapRequest) => { + try { + setIsSaving(true); + const updateData: SessionLayoutUpdateRequest = { layoutData: layout }; + await updateSessionLayout(sessionId, updateData); + toast.success('Seating layout updated successfully'); + await refetchSession(sessionId); + router.push(returnUrl); + } catch (error) { + console.error('Error updating seating layout:', error); + toast.error('Failed to update seating layout'); + setIsSaving(false); + } + }; + + return ( +
+ {/* Header with back button */} +
+
+ + + + +

Edit Seating Layout

+
+ +

+ Configure the seating layout for this session. Changes will be saved when you confirm your layout. +

+
+ + {/* Main content */} +
+
+ {sessionType === SessionType.PHYSICAL ? ( + + ) : ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx index 8a79e87..b48e596 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -10,13 +10,11 @@ import { SessionType } from "@/types/enums/sessionType"; import { SessionStatus } from "@/types/enums/sessionStatus"; import { deleteSession, - updateSessionLayout, updateSessionStatus, updateSessionTime, updateSessionVenue, SessionTimeUpdateRequest, SessionStatusUpdateRequest, - SessionLayoutUpdateRequest, SessionVenueUpdateRequest } from "@/lib/actions/sessionActions"; import { toast } from "sonner"; @@ -43,7 +41,6 @@ import { // Import edit dialogs import { EditTimeDialog } from './_components/EditTimeDialog'; import { LocationEditDialog } from './_components/LocationEditDialog'; -import { EditLayoutDialog } from './_components/EditLayoutDialog'; import { ChangeStatusDialog } from './_components/ChangeStatusDialog'; // Helper function to get status badge variant and color @@ -77,7 +74,6 @@ const SessionPage = () => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isEditTimeDialogOpen, setIsEditTimeDialogOpen] = useState(false); const [isEditLocationDialogOpen, setIsEditLocationDialogOpen] = useState(false); - const [isEditLayoutDialogOpen, setIsEditLayoutDialogOpen] = useState(false); const [isChangeStatusDialogOpen, setIsChangeStatusDialogOpen] = useState(false); // Operation states @@ -182,21 +178,7 @@ const SessionPage = () => { } }; - // Handle layout update - const handleLayoutUpdate = async (layoutData: SessionLayoutUpdateRequest) => { - try { - setIsSaving(true); - await updateSessionLayout(sessionId, layoutData); - toast.success('Seating layout updated successfully'); - await refetchSession(sessionId); - setIsEditLayoutDialogOpen(false); - } catch (error) { - console.error('Error updating seating layout:', error); - toast.error('Failed to update seating layout'); - } finally { - setIsSaving(false); - } - }; + // Layout update is now handled in a separate page // Handle status change const handleStatusUpdate = async (statusData: SessionStatusUpdateRequest) => { @@ -296,7 +278,6 @@ const SessionPage = () => { session={session} tiers={event.tiers} canEditLayout={canEditLayout} - onEditLayout={() => setIsEditLayoutDialogOpen(true)} /> )} @@ -323,17 +304,6 @@ const SessionPage = () => { sessionIndex={0} /> - {/* Edit Layout Dialog */} - - {/* Change Status Dialog */} Date: Wed, 8 Oct 2025 11:59:10 +0530 Subject: [PATCH 29/51] feat: enhance ChangeStatusDialog with detailed status information and confirmation dialog for status changes --- .../_components/ChangeStatusDialog.tsx | 318 +++++++++++++++--- 1 file changed, 274 insertions(+), 44 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx index dff8c48..127ad21 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx @@ -3,11 +3,25 @@ import * as React from 'react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { SessionStatus } from '@/types/enums/sessionStatus'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; import { SessionStatusUpdateRequest } from '@/lib/actions/sessionActions'; +import { AlertTriangle, Calendar, Clock, Tag, CheckCircle, Info } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; interface ChangeStatusDialogProps { open: boolean; @@ -23,73 +37,289 @@ export function ChangeStatusDialog({ onSave }: ChangeStatusDialogProps) { const [selectedStatus, setSelectedStatus] = useState(currentStatus); + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [confirmText, setConfirmText] = useState(""); + + // Define status descriptions and allowed transitions based on current status + const statusInfo = React.useMemo(() => { + return { + [SessionStatus.PENDING]: { + description: "Initial state when a session is first created", + icon: , + color: "bg-slate-100 text-slate-800" + }, + [SessionStatus.SCHEDULED]: { + description: "Session is scheduled but not yet on sale", + icon: , + color: "bg-blue-100 text-blue-800" + }, + [SessionStatus.ON_SALE]: { + description: "Tickets are available for purchase", + icon: , + color: "bg-green-100 text-green-800" + }, + [SessionStatus.SOLD_OUT]: { + description: "All tickets have been sold", + icon: , + color: "bg-amber-100 text-amber-800" + }, + [SessionStatus.CLOSED]: { + description: "Session is no longer accepting ticket purchases", + icon: , + color: "bg-orange-100 text-orange-800" + }, + [SessionStatus.CANCELED]: { + description: "Session has been canceled", + icon: , + color: "bg-red-100 text-red-800" + }, + }; + }, []); // Define allowed transitions based on current status const allowedTransitions = React.useMemo(() => { switch (currentStatus) { + case SessionStatus.PENDING: + return [SessionStatus.SCHEDULED]; case SessionStatus.SCHEDULED: return [SessionStatus.ON_SALE, SessionStatus.CANCELED]; case SessionStatus.ON_SALE: - return [SessionStatus.CLOSED]; + return [SessionStatus.SOLD_OUT, SessionStatus.CLOSED, SessionStatus.CANCELED]; + case SessionStatus.SOLD_OUT: + return [SessionStatus.ON_SALE, SessionStatus.CLOSED]; + case SessionStatus.CLOSED: + return [SessionStatus.CANCELED]; default: return []; } }, [currentStatus]); + // Define transition explanations + const transitionExplanations = React.useMemo(() => { + return { + [SessionStatus.PENDING]: { + [SessionStatus.SCHEDULED]: "Mark as ready with a defined date and time", + }, + [SessionStatus.SCHEDULED]: { + [SessionStatus.ON_SALE]: "Start selling tickets for this session", + [SessionStatus.CANCELED]: "Cancel this scheduled session", + }, + [SessionStatus.ON_SALE]: { + [SessionStatus.SOLD_OUT]: "Mark as sold out (no more tickets available)", + [SessionStatus.CLOSED]: "Close ticket sales but keep the session active", + [SessionStatus.CANCELED]: "Cancel this session (will notify ticket holders)", + }, + [SessionStatus.SOLD_OUT]: { + [SessionStatus.ON_SALE]: "Release more tickets for sale", + [SessionStatus.CLOSED]: "Close this sold out session", + }, + [SessionStatus.CLOSED]: { + [SessionStatus.CANCELED]: "Cancel this closed session", + }, + [SessionStatus.CANCELED]: { + // No transitions from CANCELED state + } + } as Record>; + }, []); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (selectedStatus && selectedStatus !== currentStatus) { + // Open confirmation dialog instead of immediately saving + setIsConfirmDialogOpen(true); + } + }; + + const handleConfirmStatusChange = () => { + if (confirmText === selectedStatus) { onSave({ status: selectedStatus as SessionStatus }); + setIsConfirmDialogOpen(false); + setConfirmText(""); } }; return ( - - - - Change Session Status - - -
-
- - - {allowedTransitions.length === 0 && ( -

- Status cannot be changed in the current state -

- )} + <> + + + + Change Session Status + + Update the session status based on your event's needs + + + +
+
+
Current Status:
+ +
+ {statusInfo[currentStatus as SessionStatus]?.icon} + {currentStatus.replace('_', ' ')} +
+
+
+ + +
+ +
+

Status Information

+

+ {statusInfo[currentStatus as SessionStatus]?.description} +

+
+
+
- - + + + +
+
+ + {/* Confirmation Dialog */} + + + + Confirm Status Change + + You are about to change the session status from {currentStatus} to {selectedStatus}. +

+ {selectedStatus === SessionStatus.CANCELED ? ( + + Warning: This will cancel the session. If tickets have been sold, notifications will be sent to ticket holders. + + ) : ( + transitionExplanations[currentStatus as SessionStatus]?.[selectedStatus as SessionStatus] + )} +

+ To confirm, please type {selectedStatus} below: +
+
+ setConfirmText(e.target.value)} + placeholder="Type status here to confirm" + className="w-full" + /> +
+
+ + { + setIsConfirmDialogOpen(false); + setConfirmText(""); + }}> Cancel - - - - - -
+ Confirm Change + + + + + ); } \ No newline at end of file From 970a8504032a2e8d2e4d807b112c0ddb64b00bcf Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 13:08:51 +0530 Subject: [PATCH 30/51] feat: add dialog open state management to VenueInformation and LocationEditDialog components --- .../[sessionId]/_components/LocationEditDialog.tsx | 14 ++++++++------ .../[sessionId]/_components/VenueInformation.tsx | 13 +++++++++++-- .../sessions/[sessionId]/_components/VenueMap.tsx | 3 ++- .../event/[eventId]/sessions/[sessionId]/page.tsx | 1 + 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/LocationEditDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/LocationEditDialog.tsx index 5e81d17..2d92c65 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/LocationEditDialog.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/LocationEditDialog.tsx @@ -121,7 +121,7 @@ export function LocationEditDialog({ return ( - + Edit Location for Session {sessionIndex + 1} @@ -142,11 +142,13 @@ export function LocationEditDialog({
{open ? ( - - - - - +
+ + + + + +
) : }
diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx index 6989fcc..1f20695 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueInformation.tsx @@ -34,13 +34,15 @@ interface VenueInformationProps { }; canEditVenue: boolean; onEditVenue: () => void; + isDialogOpen?: boolean; // New prop to check if any dialog is open } export const VenueInformation: React.FC = ({ isOnline, venueDetails, canEditVenue, - onEditVenue + onEditVenue, + isDialogOpen = false // Default to false if not provided }) => { // For the map - set default to Colombo if no coordinates const mapCenter: [number, number] = @@ -119,11 +121,18 @@ export const VenueInformation: React.FC = ({

LOCATION MAP

- {venueDetails?.latitude && venueDetails?.longitude ? ( + {!isDialogOpen && venueDetails?.latitude && venueDetails?.longitude ? ( + ) : isDialogOpen ? ( +
+
+ +

Map temporarily hidden while dialog is open

+
+
) : (
diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueMap.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueMap.tsx index 44534ec..deed468 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueMap.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/VenueMap.tsx @@ -27,7 +27,8 @@ const VenueMap: React.FC = ({ center, venueName }) => { { venueDetails={venueDetails} canEditVenue={canEditVenue} onEditVenue={() => setIsEditLocationDialogOpen(true)} + isDialogOpen={isEditLocationDialogOpen || isChangeStatusDialogOpen || isEditTimeDialogOpen || isDeleteDialogOpen} /> {/* Seating Layout Section */} From 869451e85b23e2d5455e370d2b0ca6e2d1843a2f Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 13:35:59 +0530 Subject: [PATCH 31/51] feat: refactor venue update handling and remove unused map center logic --- .../event/[eventId]/sessions/[sessionId]/page.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx index 90697b2..57f97d1 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -15,7 +15,6 @@ import { updateSessionVenue, SessionTimeUpdateRequest, SessionStatusUpdateRequest, - SessionVenueUpdateRequest } from "@/lib/actions/sessionActions"; import { toast } from "sonner"; import { @@ -42,6 +41,7 @@ import { import { EditTimeDialog } from './_components/EditTimeDialog'; import { LocationEditDialog } from './_components/LocationEditDialog'; import { ChangeStatusDialog } from './_components/ChangeStatusDialog'; +import {VenueDetails} from "@/lib/validators/event"; // Helper function to get status badge variant and color const getStatusProperties = (status: string | undefined) => { @@ -66,7 +66,6 @@ const getStatusProperties = (status: string | undefined) => { const SessionPage = () => { const params = useParams(); const sessionId = params.sessionId as string; - const organizationId = params.organization_id as string; const router = useRouter(); const { event, refetchSession } = useEventContext(); @@ -105,12 +104,6 @@ const SessionPage = () => { const { venueDetails, layoutData, status } = session; const statusProps = getStatusProperties(status); - // For the map - set default to Colombo if no coordinates - const mapCenter: [number, number] = - venueDetails?.latitude && venueDetails?.longitude - ? [venueDetails.latitude, venueDetails.longitude] - : [6.9271, 79.8612]; // Default to Colombo - // Calculate event duration const getDuration = () => { const durationMs = endDate.getTime() - startDate.getTime(); @@ -163,7 +156,7 @@ const SessionPage = () => { }; // Handle location/venue update - const handleVenueUpdate = async (venueDetails: any) => { + const handleVenueUpdate = async (venueDetails: VenueDetails) => { try { setIsSaving(true); await updateSessionVenue(sessionId, { venueDetails }); From a39faa42faaa4da49300f63beda926a5f20e7b98 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 16:56:58 +0530 Subject: [PATCH 32/51] feat: add discount management UI components and enhance session status dialogs --- .../[event_id]/_components/Sessions.tsx | 40 +-- .../_components/ChangeStatusDialog.tsx | 2 +- .../_components/EditLayoutDialog.tsx | 1 - .../_components/EditTimeDialog.tsx | 2 - .../_components/SeatingLayoutSection.tsx | 22 +- .../[sessionId]/_components/SessionHeader.tsx | 9 +- .../_components/SessionMetadata.tsx | 3 +- .../sessions/[sessionId]/edit-layout/page.tsx | 9 +- .../[eventId]/sessions/[sessionId]/page.tsx | 85 +++--- .../_components/discounts/discount-card.tsx | 6 +- .../discounts/discount-creation-card.tsx | 245 ++++++++++++++++++ .../discounts/discount-creation-list.tsx | 133 ++++++++++ .../_components/discounts/discount-step.tsx | 9 +- src/components/ui/simple-icon.tsx | 26 -- 14 files changed, 442 insertions(+), 150 deletions(-) create mode 100644 src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-card.tsx create mode 100644 src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-list.tsx delete mode 100644 src/components/ui/simple-icon.tsx diff --git a/src/app/(home-app)/events/[event_id]/_components/Sessions.tsx b/src/app/(home-app)/events/[event_id]/_components/Sessions.tsx index 42378ba..e3a729a 100644 --- a/src/app/(home-app)/events/[event_id]/_components/Sessions.tsx +++ b/src/app/(home-app)/events/[event_id]/_components/Sessions.tsx @@ -1,49 +1,13 @@ 'use client'; -import {PaginatedResponse} from '@/types/paginatedResponse'; import React, {useEffect, useState} from 'react'; import {SessionInfoBasicDTO} from "@/types/event"; -import {getEventSessions, getEventSessionsInRange} from '@/lib/actions/public/eventActions'; +import {getEventSessionsInRange} from '@/lib/actions/public/eventActions'; import {CalendarIcon} from "lucide-react"; import {Button} from "@/components/ui/button"; import {SessionItem} from "@/app/(home-app)/events/[event_id]/_components/SessionItem"; import {DatePicker} from "@/components/ui/datepicker"; -const Sessions = ({eventId}: { eventId: string }) => { - const [page, setPage] = useState(0); - const [sessionsData, setSessionsData] = useState | null>(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const fetchSessions = async () => { - setIsLoading(true); - const data = await getEventSessions({eventId, page}); - setSessionsData(data); - setIsLoading(false); - }; - fetchSessions(); - }, [eventId, page]); - - if (isLoading) return
Loading sessions...
; - if (!sessionsData || sessionsData.empty) return
No sessions found.
; - - return ( -
-

- Sessions -

-
- {sessionsData.content.map(session => )} -
-
- - Page {sessionsData.number + 1} of {sessionsData.totalPages} - -
-
- ); -}; - const SessionsNoPagination = ({eventId}: { eventId: string }) => { // Default dates: one week ago to three months ahead const now = new Date(); @@ -118,4 +82,4 @@ const SessionsNoPagination = ({eventId}: { eventId: string }) => { ); }; -export default SessionsNoPagination; +export default SessionsNoPagination; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx index 127ad21..83944bf 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/ChangeStatusDialog.tsx @@ -146,7 +146,7 @@ export function ChangeStatusDialog({ Change Session Status - Update the session status based on your event's needs + Update the session status based on your event's needs diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx index 5a59d32..ab7c481 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditLayoutDialog.tsx @@ -1,7 +1,6 @@ 'use client' import * as React from 'react'; -import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { SessionType } from '@/types/enums/sessionType'; diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditTimeDialog.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditTimeDialog.tsx index dcbdff0..6039055 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditTimeDialog.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/EditTimeDialog.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import { useState, useEffect } from 'react'; -import { toast } from 'sonner'; -import { z } from 'zod'; import { formatToDateTimeLocalString } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx index 30b48a8..e2ede35 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SeatingLayoutSection.tsx @@ -4,25 +4,15 @@ import React from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { SessionSeatingLayout } from './SessionSeatingLayout'; -import { SeatingCapacitySummary } from './seatingCapacitySummary'; -import { Grid3x3, Rows, Armchair, Pencil, Users } from 'lucide-react'; +import { Grid3x3, Pencil } from 'lucide-react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; +import {SessionDetailDTO, TierDTO} from "@/lib/validators/event"; // A small helper component for consistent display of information items -const InfoItem = ({ icon: Icon, label, children }: { icon: React.ElementType, label: string, children: React.ReactNode }) => ( -
- -
- {label} - {children} -
-
-); - interface SeatingLayoutSectionProps { - session: any; // Use the appropriate type here - tiers: any[]; // Use the appropriate type here + session: SessionDetailDTO; // Use the appropriate type here + tiers: TierDTO[]; // Use the appropriate type here canEditLayout: boolean; } @@ -36,10 +26,6 @@ export const SeatingLayoutSection: React.FC = ({ const eventId = params.eventId as string; const sessionId = params.sessionId as string; const editLayoutUrl = `/manage/organization/${organizationId}/event/${eventId}/sessions/${sessionId}/edit-layout`; - - // Get total capacity if available - const totalCapacity = tiers?.reduce((acc, tier) => acc + (tier.capacity || 0), 0) || 0; - const tierCount = tiers?.length || 0; return ( diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx index 1987900..e8e45ab 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionHeader.tsx @@ -2,10 +2,9 @@ import React from 'react'; import { format } from 'date-fns'; -import { LinkIcon, MapPin, Share2, Trash2, ChevronLeft, ArrowLeft } from 'lucide-react'; +import { LinkIcon, MapPin, Share2, Trash2, ChevronLeft } from 'lucide-react'; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { SessionType } from "@/types/enums/sessionType"; import Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -70,7 +69,11 @@ export const SessionHeader: React.FC = ({

- diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx index 82839f0..f31f322 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/SessionMetadata.tsx @@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { format } from 'date-fns'; -import { SessionStatus } from '@/types/enums/sessionStatus'; -import { getSalesWindowDuration, getSalesStartTimeDisplay } from '@/lib/utils'; +import { getSalesWindowDuration } from '@/lib/utils'; import { Calendar, Clock, Timer, AlarmClock, Ticket, Pencil, Tag } from 'lucide-react'; // A small helper component for consistent display of information items diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/edit-layout/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/edit-layout/page.tsx index 7906cc5..8b81dd3 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/edit-layout/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/edit-layout/page.tsx @@ -1,9 +1,8 @@ 'use client' -import React, { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; -import { ChevronLeft, Save } from 'lucide-react'; +import { ChevronLeft } from 'lucide-react'; import { useEventContext } from "@/providers/EventProvider"; import { toast } from "sonner"; import { SessionType } from '@/types/enums/sessionType'; @@ -17,9 +16,7 @@ export default function EditLayoutPage() { const params = useParams(); const router = useRouter(); const { event, refetchSession } = useEventContext(); - - const [isSaving, setIsSaving] = useState(false); - + const sessionId = params.sessionId as string; const organizationId = params.organization_id as string; const eventId = params.eventId as string; @@ -47,7 +44,6 @@ export default function EditLayoutPage() { const handleSave = async (layout: SessionSeatingMapRequest) => { try { - setIsSaving(true); const updateData: SessionLayoutUpdateRequest = { layoutData: layout }; await updateSessionLayout(sessionId, updateData); toast.success('Seating layout updated successfully'); @@ -56,7 +52,6 @@ export default function EditLayoutPage() { } catch (error) { console.error('Error updating seating layout:', error); toast.error('Failed to update seating layout'); - setIsSaving(false); } }; diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx index 57f97d1..9ffbbac 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/page.tsx @@ -27,6 +27,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ShareComponent } from "@/components/ui/share/share-component"; // Import page components import { @@ -74,10 +81,8 @@ const SessionPage = () => { const [isEditTimeDialogOpen, setIsEditTimeDialogOpen] = useState(false); const [isEditLocationDialogOpen, setIsEditLocationDialogOpen] = useState(false); const [isChangeStatusDialogOpen, setIsChangeStatusDialogOpen] = useState(false); - - // Operation states - const [isDeleting, setIsDeleting] = useState(false); - const [isSaving, setIsSaving] = useState(false); + + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); if (!event) { return ( @@ -119,55 +124,37 @@ const SessionPage = () => { return `${minutes} minutes`; } }; - + // Handle share - const handleShare = async () => { - try { - const shareUrl = window.location.href; - if (navigator.share) { - await navigator.share({ - title: `${event.title} - ${format(startDate, 'MMM d, yyyy')}`, - text: `Join us for ${event.title} on ${format(startDate, 'EEEE, MMMM d, yyyy')}`, - url: shareUrl, - }); - } else { - await navigator.clipboard.writeText(shareUrl); - toast.success('Session URL copied to clipboard!'); - } - } catch (error) { - console.error('Error sharing:', error); - } + const handleShare = () => { + setIsShareDialogOpen(true); }; // Handle session time update const handleTimeUpdate = async (timeData: SessionTimeUpdateRequest) => { + const t = toast.loading('Updating session time...'); try { - setIsSaving(true); await updateSessionTime(sessionId, timeData); - toast.success('Session time updated successfully'); + toast.success('Session time updated successfully', { id: t }); await refetchSession(sessionId); setIsEditTimeDialogOpen(false); } catch (error) { console.error('Error updating session time:', error); - toast.error('Failed to update session time'); - } finally { - setIsSaving(false); + toast.error('Failed to update session time', { id: t }); } }; // Handle location/venue update const handleVenueUpdate = async (venueDetails: VenueDetails) => { + const t = toast.loading('Updating venue details...'); try { - setIsSaving(true); await updateSessionVenue(sessionId, { venueDetails }); - toast.success('Venue details updated successfully'); + toast.success('Venue details updated successfully', { id: t }); await refetchSession(sessionId); setIsEditLocationDialogOpen(false); } catch (error) { console.error('Error updating venue details:', error); - toast.error('Failed to update venue details'); - } finally { - setIsSaving(false); + toast.error('Failed to update venue details', { id: t }); } }; @@ -175,33 +162,30 @@ const SessionPage = () => { // Handle status change const handleStatusUpdate = async (statusData: SessionStatusUpdateRequest) => { + const t = toast.loading('Updating session status...'); try { - setIsSaving(true); await updateSessionStatus(sessionId, statusData); - toast.success(`Session status updated to ${statusData.status}`); + toast.success(`Session status updated to ${statusData.status}`, { id: t }); await refetchSession(sessionId); setIsChangeStatusDialogOpen(false); } catch (error) { console.error('Error updating session status:', error); - toast.error('Failed to update session status'); - } finally { - setIsSaving(false); + toast.error('Failed to update session status', { id: t }); } }; // Handle delete const handleDelete = async () => { + const t = toast.loading('Deleting session...'); try { - setIsDeleting(true); await deleteSession(sessionId); - toast.success('Session deleted successfully'); + toast.success('Session deleted successfully', { id: t }); router.push(`/manage/organization/${params.organization_id}/event/${params.eventId}`); await refetchSession(sessionId); } catch (error) { console.error('Error deleting session:', error); - toast.error('Failed to delete session'); + toast.error('Failed to delete session', { id: t }); } finally { - setIsDeleting(false); setIsDeleteDialogOpen(false); } }; @@ -263,7 +247,7 @@ const SessionPage = () => { venueDetails={venueDetails} canEditVenue={canEditVenue} onEditVenue={() => setIsEditLocationDialogOpen(true)} - isDialogOpen={isEditLocationDialogOpen || isChangeStatusDialogOpen || isEditTimeDialogOpen || isDeleteDialogOpen} + isDialogOpen={isEditLocationDialogOpen || isChangeStatusDialogOpen || isEditTimeDialogOpen || isDeleteDialogOpen || isShareDialogOpen} /> {/* Seating Layout Section */} @@ -316,17 +300,30 @@ const SessionPage = () => { - Cancel + Cancel - {isDeleting ? "Deleting..." : "Delete"} + Delete + + {/* Share Dialog */} + + + + Share Session + + + +
); }; diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index fcb3e2c..d8b6aae 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -204,9 +204,10 @@ export function DiscountCard({
- + {discount.currentUsage || 0}/{discount.maxUsage}
@@ -220,7 +221,6 @@ export function DiscountCard({ )} - {activeFromDate && expiresAtDate && (
{activeFromDate && ( diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-card.tsx new file mode 100644 index 0000000..21cabfe --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-card.tsx @@ -0,0 +1,245 @@ +"use client" + +import {Card, CardContent, CardHeader} from "@/components/ui/card" +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" +import {Switch} from "@/components/ui/switch" +import { + Edit, Trash2, Copy, Eye, EyeOff, Percent, DollarSign, Gift, + Calendar, Users, MoreHorizontal, +} from "lucide-react" +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu" +import {DiscountType} from "@/types/enums/discountType"; +import {DiscountDTO, SessionDTO, TierDTO} from "@/lib/validators/event"; +import {toast} from "sonner"; +import {format} from "date-fns"; +import {getDiscountValue} from "@/lib/discountUtils"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, + AlertDialogHeader, AlertDialogTitle, + AlertDialogTrigger +} from "@/components/ui/alert-dialog"; + +// --- Component Props --- +interface DiscountCardProps { + discount: DiscountDTO, + tiers: TierDTO[], + sessions?: SessionDTO[], + onDelete?: (id: string) => void, + onToggleStatus?: (id: string) => void, + onEdit?: (discount: DiscountDTO) => void, + isReadOnly?: boolean, +} + +// --- Helper Functions --- +const getDiscountIcon = (type: DiscountType) => { + switch (type) { + case DiscountType.PERCENTAGE: + return ; + case DiscountType.FLAT_OFF: + return ; + case DiscountType.BUY_N_GET_N_FREE: + return ; + default: + return ; + } +} + +export function DiscountCard({ + discount, + tiers, + sessions, + onDelete, + onToggleStatus, + onEdit, + isReadOnly = false + }: DiscountCardProps) { + + const getTierNames = (tierIds?: string[]) => { + const ids = tierIds || []; + if (!tiers?.length || ids.length === tiers.length) return "All Tiers"; + if (ids.length === 0) return "No Tiers Selected"; + return ids.map(id => tiers.find(tier => tier.id === id)?.name).filter(Boolean).join(", "); + } + + const copyToClipboard = (code: string) => { + navigator.clipboard.writeText(code).then(() => toast.success(`Code "${code}" copied!`)); + } + + // Pre-format dates to prevent 'unknown' type errors in JSX + const formatDate = (dateString?: string | null) => { + if (!dateString) return null; + return format(new Date(dateString), 'MMM d, p'); + }; + + const activeFromDate = formatDate(discount.activeFrom); + const expiresAtDate = formatDate(discount.expiresAt); + + // Use a safe fallback for arrays that might be undefined + const applicableSessionIds = discount.applicableSessionIds || []; + const sessionsCount = sessions?.length || 0; + + const usagePercentage = discount.maxUsage && discount.maxUsage > 0 + ? ((discount.currentUsage || 0) / discount.maxUsage) * 100 + : 0; + + return ( + <> + + +
+
+ {getDiscountIcon(discount.parameters.type)} +
+
+
+

{discount.code}

+ + {discount.active ? "Active" : "Inactive"} + + {discount.public && ( + + + Public + + )} + {!discount.public && ( + + + Private + + )} +
+ +

+ {getDiscountValue(discount.parameters)} +

+
+
+ +
+ {!isReadOnly && onToggleStatus && ( + onToggleStatus(discount.id)} + /> + )} + + {!isReadOnly && onEdit && onDelete && ( + + + + + + onEdit(discount)}> + + Edit + + { + // Prevent the dropdown from closing + e.preventDefault(); + }} + > + + +
+ + Delete +
+
+ + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the + discount code "{discount.code}". + + + + Cancel + onDelete(discount.id)} + className="bg-destructive text-white hover:bg-destructive/90" + > + Yes, delete discount + + + +
+
+
+
+ )} +
+
+ +
+
+
+ {discount.maxUsage != null ? ( +
+ + Usage Limit: +
+
+ + {discount.currentUsage || 0}/{discount.maxUsage} + +
+
+ ) : ( +
+ + Uses: + {discount.currentUsage || 0} +
+ )} + + + {activeFromDate && expiresAtDate && ( +
+ {activeFromDate && ( +
+ + Activates {activeFromDate} +
+ )} + {expiresAtDate && ( +
+ + Expires {expiresAtDate} +
+ )} +
+ )} + +
+

+ Applicable Tiers: {getTierNames(discount.applicableTierIds)} +

+

+ Sessions: {sessionsCount > 0 && applicableSessionIds.length === sessionsCount ? "All Sessions" : `${applicableSessionIds.length} Selected`} +

+
+
+
+
+ + + + ); +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-list.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-list.tsx new file mode 100644 index 0000000..167ea19 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-list.tsx @@ -0,0 +1,133 @@ +"use client" + +import {useMemo, useState} from "react" +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card" +import {Input} from "@/components/ui/input" +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" +import {Search, Filter} from "lucide-react" +import {DiscountType} from "@/types/enums/discountType"; +import {DiscountDTO, SessionDTO, TierDTO} from "@/lib/validators/event"; +import {DiscountCard} from "./discount-creation-card"; + +interface DiscountListProps { + tiers: TierDTO[], + sessions?: SessionDTO[], + discounts?: DiscountDTO[], + onDelete?: (id: string) => void, + onToggleStatus?: (id: string) => void, + onEdit?: (discount: DiscountDTO) => void, + filters?: boolean, + isReadOnly?: boolean, +} + +export function DiscountList({ + tiers, + sessions, + discounts, + onDelete, + onToggleStatus, + onEdit, + filters = true, + isReadOnly = false + }: DiscountListProps) { + const [searchTerm, setSearchTerm] = useState("") + const [filterType, setFilterType] = useState("all") + const [filterStatus, setFilterStatus] = useState("all") + + const filteredCodes = useMemo(() => { + if (!discounts) return [] + return discounts.filter((code) => { + if (!code) return false; + const matchesSearch = code.code.toLowerCase().includes(searchTerm.toLowerCase()) + const matchesType = filterType === "all" || code.parameters.type === filterType + const matchesStatus = + filterStatus === "all" || + (filterStatus === "active" && code.active) || + (filterStatus === "inactive" && !code.active) + return matchesSearch && matchesType && matchesStatus + }) + }, [discounts, searchTerm, filterType, filterStatus]) + + return ( +
+ {filters && ( + + + + + Filters + + + +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ + +
+
+
+ )} + +
+ {filteredCodes.map((discount) => ( + + ))} +
+ + {filteredCodes.length === 0 && ( + + +
+ +

+ {filters ? "No discount codes match your filters." : "No discount codes available."} +

+

+ {filters ? "Try adjusting your search or filter criteria." : "Create discount codes to offer special promotions for your event."} +

+
+
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx index c82790b..6fa29e2 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx @@ -1,8 +1,8 @@ 'use client'; -import { DiscountList } from "./discount-list"; +import { DiscountList } from "./discount-creation-list"; import { useFieldArray, useFormContext } from "react-hook-form"; -import {CreateEventFormData, DiscountDTO, discountSchema} from "@/lib/validators/event"; +import {CreateEventFormData, DiscountDTO, discountSchema, sessionWithSeatingSchema} from "@/lib/validators/event"; import {useEffect, useState} from "react"; import { FullDiscountFormView } from "./full-discount-form-view"; import { Button } from "@/components/ui/button"; @@ -15,7 +15,6 @@ interface DiscountStepProps { export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { const [view, setView] = useState<'list' | 'create' | 'edit'>('list'); const [editingDiscount, setEditingDiscount] = useState(null); - const { control, watch } = useFormContext(); const tiers = watch("tiers"); @@ -77,7 +76,7 @@ export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { return ( sessionWithSeatingSchema.parse(s))} onSave={(discount) => { if (view === 'edit' && editingDiscount) { handleUpdateDiscount(discount); @@ -109,7 +108,7 @@ export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { discountSchema.parse(d))} tiers={tiers} - sessions={sessions} + sessions={sessions?.map((s) => sessionWithSeatingSchema.parse(s))} onDelete={handleDeleteDiscount} onToggleStatus={handleToggleStatus} onEdit={handleGoToEditView} diff --git a/src/components/ui/simple-icon.tsx b/src/components/ui/simple-icon.tsx deleted file mode 100644 index 45fae03..0000000 --- a/src/components/ui/simple-icon.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import * as SimpleIcons from 'simple-icons-react'; - -interface SimpleIconProps { - name: keyof typeof SimpleIcons; - size?: number; - color?: string; - className?: string; -} - -export function SimpleIcon({ name, size = 24, color, className = '' }: SimpleIconProps) { - const IconComponent = SimpleIcons[name]; - - if (!IconComponent) { - console.error(`Icon "${name}" not found in simple-icons`); - return null; - } - - return ( - - ); -} From 609dd11c160fe5acae1ae644ccb2ba9aba264d6e Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Wed, 8 Oct 2025 18:09:10 +0530 Subject: [PATCH 33/51] feat: enhance discount management components with shareable functionality and update styling for booked seats --- .../event/[eventId]/discounts/page.tsx | 1 + .../_components/CustomSeatingLayout.tsx | 4 +- .../_components/discounts/discount-card.tsx | 106 ++++---- .../discounts/discount-creation-card.tsx | 245 ------------------ .../discounts/discount-creation-list.tsx | 133 ---------- .../_components/discounts/discount-list.tsx | 5 +- .../_components/discounts/discount-step.tsx | 4 +- 7 files changed, 66 insertions(+), 432 deletions(-) delete mode 100644 src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-card.tsx delete mode 100644 src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-list.tsx diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx index 1d438f4..351876e 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx @@ -195,6 +195,7 @@ tiers={event.tiers || []} sessions={event.sessions || []} onToggleStatus={handleToggleStatus} + isShareable={true} /> )}
diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/CustomSeatingLayout.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/CustomSeatingLayout.tsx index 4a9c38c..8f16ffc 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/CustomSeatingLayout.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/[sessionId]/_components/CustomSeatingLayout.tsx @@ -24,7 +24,7 @@ export const CustomSeatingLayout: React.FC = ({ session, tie case SeatStatus.RESERVED: return { opacity: 0.3, cursor: 'not-allowed' }; case SeatStatus.BOOKED: - return { opacity: 1, border: '2px solid #3B82F6', cursor: 'not-allowed' }; // Blue border for booked seats + return { opacity: 1, border: '2px solid #22C55E', cursor: 'not-allowed' }; // Green border for booked seats default: return { opacity: 1 }; } @@ -196,7 +196,7 @@ export const CustomSeatingLayout: React.FC = ({ session, tie Reserved
-
+
Booked
diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index d8b6aae..e96fc80 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -1,21 +1,21 @@ "use client" -import {Card, CardContent, CardHeader} from "@/components/ui/card" -import {Badge} from "@/components/ui/badge" -import {Button} from "@/components/ui/button" -import {Switch} from "@/components/ui/switch" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" import { Edit, Trash2, Copy, Eye, EyeOff, Percent, DollarSign, Gift, Calendar, Users, MoreHorizontal, Share2, } from "lucide-react" -import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu" -import {DiscountType} from "@/types/enums/discountType"; -import {DiscountDTO, SessionDTO, TierDTO} from "@/lib/validators/event"; -import {toast} from "sonner"; -import {format} from "date-fns"; -import {getDiscountValue} from "@/lib/discountUtils"; -import {useState} from "react"; -import {DiscountShareDialog} from "./discount-share-dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { DiscountType } from "@/types/enums/discountType"; +import { DiscountDTO, SessionDTO, TierDTO } from "@/lib/validators/event"; +import { toast } from "sonner"; +import { format } from "date-fns"; +import { getDiscountValue } from "@/lib/discountUtils"; +import { useState } from "react"; +import { DiscountShareDialog } from "./discount-share-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, @@ -33,31 +33,33 @@ interface DiscountCardProps { onToggleStatus?: (id: string) => void, onEdit?: (discount: DiscountDTO) => void, isReadOnly?: boolean, + isShareable?: boolean, } // --- Helper Functions --- const getDiscountIcon = (type: DiscountType) => { switch (type) { case DiscountType.PERCENTAGE: - return ; + return ; case DiscountType.FLAT_OFF: - return ; + return ; case DiscountType.BUY_N_GET_N_FREE: - return ; + return ; default: - return ; + return ; } } export function DiscountCard({ - discount, - tiers, - sessions, - onDelete, - onToggleStatus, - onEdit, - isReadOnly = false - }: DiscountCardProps) { + discount, + tiers, + sessions, + onDelete, + onToggleStatus, + onEdit, + isReadOnly = false, + isShareable = true +}: DiscountCardProps) { const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const getTierNames = (tierIds?: string[]) => { @@ -105,13 +107,13 @@ export function DiscountCard({ {discount.public && ( - + Public )} {!discount.public && ( - + Private )} @@ -131,27 +133,29 @@ export function DiscountCard({ /> )} - + {isShareable && ( + + )} {!isReadOnly && onEdit && onDelete && ( onEdit(discount)}> - + Edit
- + Delete
@@ -199,12 +203,12 @@ export function DiscountCard({
{discount.maxUsage != null ? (
- + Usage Limit:
@@ -214,7 +218,7 @@ export function DiscountCard({
) : (
- + Uses: {discount.currentUsage || 0}
@@ -225,13 +229,13 @@ export function DiscountCard({
{activeFromDate && (
- + Activates {activeFromDate}
)} {expiresAtDate && (
- + Expires {expiresAtDate}
)} @@ -253,12 +257,14 @@ export function DiscountCard({ {/* Share Dialog */} - + {isShareable && ( + + )} ); } \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-card.tsx deleted file mode 100644 index 21cabfe..0000000 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-card.tsx +++ /dev/null @@ -1,245 +0,0 @@ -"use client" - -import {Card, CardContent, CardHeader} from "@/components/ui/card" -import {Badge} from "@/components/ui/badge" -import {Button} from "@/components/ui/button" -import {Switch} from "@/components/ui/switch" -import { - Edit, Trash2, Copy, Eye, EyeOff, Percent, DollarSign, Gift, - Calendar, Users, MoreHorizontal, -} from "lucide-react" -import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu" -import {DiscountType} from "@/types/enums/discountType"; -import {DiscountDTO, SessionDTO, TierDTO} from "@/lib/validators/event"; -import {toast} from "sonner"; -import {format} from "date-fns"; -import {getDiscountValue} from "@/lib/discountUtils"; -import { - AlertDialog, AlertDialogAction, AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, AlertDialogFooter, - AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger -} from "@/components/ui/alert-dialog"; - -// --- Component Props --- -interface DiscountCardProps { - discount: DiscountDTO, - tiers: TierDTO[], - sessions?: SessionDTO[], - onDelete?: (id: string) => void, - onToggleStatus?: (id: string) => void, - onEdit?: (discount: DiscountDTO) => void, - isReadOnly?: boolean, -} - -// --- Helper Functions --- -const getDiscountIcon = (type: DiscountType) => { - switch (type) { - case DiscountType.PERCENTAGE: - return ; - case DiscountType.FLAT_OFF: - return ; - case DiscountType.BUY_N_GET_N_FREE: - return ; - default: - return ; - } -} - -export function DiscountCard({ - discount, - tiers, - sessions, - onDelete, - onToggleStatus, - onEdit, - isReadOnly = false - }: DiscountCardProps) { - - const getTierNames = (tierIds?: string[]) => { - const ids = tierIds || []; - if (!tiers?.length || ids.length === tiers.length) return "All Tiers"; - if (ids.length === 0) return "No Tiers Selected"; - return ids.map(id => tiers.find(tier => tier.id === id)?.name).filter(Boolean).join(", "); - } - - const copyToClipboard = (code: string) => { - navigator.clipboard.writeText(code).then(() => toast.success(`Code "${code}" copied!`)); - } - - // Pre-format dates to prevent 'unknown' type errors in JSX - const formatDate = (dateString?: string | null) => { - if (!dateString) return null; - return format(new Date(dateString), 'MMM d, p'); - }; - - const activeFromDate = formatDate(discount.activeFrom); - const expiresAtDate = formatDate(discount.expiresAt); - - // Use a safe fallback for arrays that might be undefined - const applicableSessionIds = discount.applicableSessionIds || []; - const sessionsCount = sessions?.length || 0; - - const usagePercentage = discount.maxUsage && discount.maxUsage > 0 - ? ((discount.currentUsage || 0) / discount.maxUsage) * 100 - : 0; - - return ( - <> - - -
-
- {getDiscountIcon(discount.parameters.type)} -
-
-
-

{discount.code}

- - {discount.active ? "Active" : "Inactive"} - - {discount.public && ( - - - Public - - )} - {!discount.public && ( - - - Private - - )} -
- -

- {getDiscountValue(discount.parameters)} -

-
-
- -
- {!isReadOnly && onToggleStatus && ( - onToggleStatus(discount.id)} - /> - )} - - {!isReadOnly && onEdit && onDelete && ( - - - - - - onEdit(discount)}> - - Edit - - { - // Prevent the dropdown from closing - e.preventDefault(); - }} - > - - -
- - Delete -
-
- - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete the - discount code "{discount.code}". - - - - Cancel - onDelete(discount.id)} - className="bg-destructive text-white hover:bg-destructive/90" - > - Yes, delete discount - - - -
-
-
-
- )} -
-
- -
-
-
- {discount.maxUsage != null ? ( -
- - Usage Limit: -
-
- - {discount.currentUsage || 0}/{discount.maxUsage} - -
-
- ) : ( -
- - Uses: - {discount.currentUsage || 0} -
- )} - - - {activeFromDate && expiresAtDate && ( -
- {activeFromDate && ( -
- - Activates {activeFromDate} -
- )} - {expiresAtDate && ( -
- - Expires {expiresAtDate} -
- )} -
- )} - -
-

- Applicable Tiers: {getTierNames(discount.applicableTierIds)} -

-

- Sessions: {sessionsCount > 0 && applicableSessionIds.length === sessionsCount ? "All Sessions" : `${applicableSessionIds.length} Selected`} -

-
-
-
-
- - - - ); -} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-list.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-list.tsx deleted file mode 100644 index 167ea19..0000000 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-creation-list.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client" - -import {useMemo, useState} from "react" -import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card" -import {Input} from "@/components/ui/input" -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" -import {Search, Filter} from "lucide-react" -import {DiscountType} from "@/types/enums/discountType"; -import {DiscountDTO, SessionDTO, TierDTO} from "@/lib/validators/event"; -import {DiscountCard} from "./discount-creation-card"; - -interface DiscountListProps { - tiers: TierDTO[], - sessions?: SessionDTO[], - discounts?: DiscountDTO[], - onDelete?: (id: string) => void, - onToggleStatus?: (id: string) => void, - onEdit?: (discount: DiscountDTO) => void, - filters?: boolean, - isReadOnly?: boolean, -} - -export function DiscountList({ - tiers, - sessions, - discounts, - onDelete, - onToggleStatus, - onEdit, - filters = true, - isReadOnly = false - }: DiscountListProps) { - const [searchTerm, setSearchTerm] = useState("") - const [filterType, setFilterType] = useState("all") - const [filterStatus, setFilterStatus] = useState("all") - - const filteredCodes = useMemo(() => { - if (!discounts) return [] - return discounts.filter((code) => { - if (!code) return false; - const matchesSearch = code.code.toLowerCase().includes(searchTerm.toLowerCase()) - const matchesType = filterType === "all" || code.parameters.type === filterType - const matchesStatus = - filterStatus === "all" || - (filterStatus === "active" && code.active) || - (filterStatus === "inactive" && !code.active) - return matchesSearch && matchesType && matchesStatus - }) - }, [discounts, searchTerm, filterType, filterStatus]) - - return ( -
- {filters && ( - - - - - Filters - - - -
-
-
- - setSearchTerm(e.target.value)} - className="pl-10" - /> -
-
- - -
-
-
- )} - -
- {filteredCodes.map((discount) => ( - - ))} -
- - {filteredCodes.length === 0 && ( - - -
- -

- {filters ? "No discount codes match your filters." : "No discount codes available."} -

-

- {filters ? "Try adjusting your search or filter criteria." : "Create discount codes to offer special promotions for your event."} -

-
-
-
- )} -
- ) -} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx index cf5aa85..6eda753 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-list.tsx @@ -18,6 +18,7 @@ interface DiscountListProps { onEdit?: (discount: DiscountDTO) => void, filters?: boolean, isReadOnly?: boolean, + isShareable?: boolean, } export function DiscountList({ @@ -28,7 +29,8 @@ export function DiscountList({ onToggleStatus, onEdit, filters = true, - isReadOnly = false + isReadOnly = false, + isShareable = true }: DiscountListProps) { const [searchTerm, setSearchTerm] = useState("") const [filterType, setFilterType] = useState("all") @@ -109,6 +111,7 @@ export function DiscountList({ onToggleStatus={onToggleStatus} onEdit={onEdit} isReadOnly={isReadOnly} + isShareable={isShareable} /> ))}
diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx index 6fa29e2..3d46ed4 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-step.tsx @@ -1,6 +1,6 @@ 'use client'; -import { DiscountList } from "./discount-creation-list"; +import { DiscountList } from "./discount-list"; import { useFieldArray, useFormContext } from "react-hook-form"; import {CreateEventFormData, DiscountDTO, discountSchema, sessionWithSeatingSchema} from "@/lib/validators/event"; import {useEffect, useState} from "react"; @@ -15,6 +15,7 @@ interface DiscountStepProps { export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { const [view, setView] = useState<'list' | 'create' | 'edit'>('list'); const [editingDiscount, setEditingDiscount] = useState(null); + const { control, watch } = useFormContext(); const tiers = watch("tiers"); @@ -113,6 +114,7 @@ export default function DiscountStep({onConfigModeChange}: DiscountStepProps) { onToggleStatus={handleToggleStatus} onEdit={handleGoToEditView} filters={false} + isShareable={false} />
); From dc9d6f8af5cb4556dcb49df1796b0971c82c3174 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Thu, 9 Oct 2025 01:15:20 +0530 Subject: [PATCH 34/51] feat: add discountedTotal field to baseDiscountSchema for improved discount handling --- .../_components/discounts/discount-card.tsx | 133 +++++++++--------- src/lib/validators/event.ts | 1 + 2 files changed, 69 insertions(+), 65 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx index e96fc80..1637d89 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/discounts/discount-card.tsx @@ -73,7 +73,6 @@ export function DiscountCard({ navigator.clipboard.writeText(code).then(() => toast.success(`Code "${code}" copied!`)); } - // Pre-format dates to prevent 'unknown' type errors in JSX const formatDate = (dateString?: string | null) => { if (!dateString) return null; return format(new Date(dateString), 'MMM d, p'); @@ -82,7 +81,6 @@ export function DiscountCard({ const activeFromDate = formatDate(discount.activeFrom); const expiresAtDate = formatDate(discount.expiresAt); - // Use a safe fallback for arrays that might be undefined const applicableSessionIds = discount.applicableSessionIds || []; const sessionsCount = sessions?.length || 0; @@ -92,14 +90,14 @@ export function DiscountCard({ return ( <> - - + +
{getDiscountIcon(discount.parameters.type)}
-
+

{discount.code}

@@ -107,13 +105,13 @@ export function DiscountCard({ {discount.public && ( - + Public )} {!discount.public && ( - + Private )} @@ -133,8 +131,8 @@ export function DiscountCard({ /> )} {isShareable && ( )} {!isReadOnly && onEdit && onDelete && ( onEdit(discount)}> - + Edit { - // Prevent the dropdown from closing - e.preventDefault(); - }} + onSelect={(e) => e.preventDefault()} >
- + Delete
@@ -197,61 +192,69 @@ export function DiscountCard({ )}
- -
-
-
- {discount.maxUsage != null ? ( -
- - Usage Limit: -
-
- + +
+ {/* --- Left Column: Details --- */} +
+ {discount.maxUsage != null ? ( +
+ + Usage Limit: +
+
+ {discount.currentUsage || 0}/{discount.maxUsage} -
-
- ) : ( -
- - Uses: - {discount.currentUsage || 0} -
- )} - - - {activeFromDate && expiresAtDate && ( -
- {activeFromDate && ( -
- - Activates {activeFromDate} -
- )} - {expiresAtDate && ( -
- - Expires {expiresAtDate} -
- )}
- )} +
+ ) : ( +
+ + Uses: + {discount.currentUsage || 0} +
+ )} -
-

- Applicable Tiers: {getTierNames(discount.applicableTierIds)} -

-

- Sessions: {sessionsCount > 0 && applicableSessionIds.length === sessionsCount ? "All Sessions" : `${applicableSessionIds.length} Selected`} -

+ {activeFromDate && expiresAtDate && ( +
+ {activeFromDate && ( +
+ + Activates {activeFromDate} +
+ )} + {expiresAtDate && ( +
+ + Expires {expiresAtDate} +
+ )}
+ )} + +
+

+ Applicable Tiers: {getTierNames(discount.applicableTierIds)} +

+

+ Sessions: {sessionsCount > 0 && applicableSessionIds.length === sessionsCount ? "All Sessions" : `${applicableSessionIds.length} Selected`} +

+ + {/* --- Right Column: Discounted Total (More Compact) --- */} + {discount.discountedTotal > 0 && ( +
+

Total Discounted

+

+ ${discount.discountedTotal.toFixed(2)} +

+
+ )}
diff --git a/src/lib/validators/event.ts b/src/lib/validators/event.ts index 1c06037..de76db7 100644 --- a/src/lib/validators/event.ts +++ b/src/lib/validators/event.ts @@ -47,6 +47,7 @@ const baseDiscountSchema = z.object({ code: z.string().min(1, { message: "Discount code cannot be empty." }).transform(val => val.toUpperCase()), maxUsage: z.number().int().min(1).nullable().optional(), currentUsage: z.number().int().min(0).default(0), + discountedTotal: z.number().min(0).default(0), active: z.boolean().default(true), public: z.boolean().default(false), activeFrom: z.iso.datetime({ offset: true }).nullable(), From ccd95001100b14bd836e1163f6e835ca3c707587 Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Thu, 9 Oct 2025 02:32:39 +0530 Subject: [PATCH 35/51] feat: implement event update actions with cover photo management and validation --- .../event/[eventId]/discounts/page.tsx | 382 +++++++++--------- .../event/[eventId]/sessions/create/page.tsx | 5 +- .../event/_components/SchedulingStep.tsx | 6 +- src/lib/actions/eventUpdateActions.ts | 119 ++++++ 4 files changed, 315 insertions(+), 197 deletions(-) create mode 100644 src/lib/actions/eventUpdateActions.ts diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx index 351876e..16afa06 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/discounts/page.tsx @@ -1,204 +1,204 @@ - "use client"; - - import {useState} from "react"; - import {useEventContext} from "@/providers/EventProvider"; - import {Skeleton} from "@/components/ui/skeleton"; - import {AlertTriangle, Plus, RefreshCcw} from "lucide-react"; - import {Button} from "@/components/ui/button"; - import {createDiscount, DiscountResponse, deleteDiscount, updateDiscount} from "@/lib/actions/discountActions"; - import {toast} from "sonner"; - import {DiscountList} from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; - import {DiscountDTO} from "@/lib/validators/event"; - import { - FullDiscountFormView - } from "@/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view"; - - export default function DiscountManagementPage() { - const {event, isLoading, refetchDiscounts, error, setDiscounts} = useEventContext(); - - const [mode, setMode] = useState<'view' | 'create' | 'edit'>('view'); - const [editingDiscount, setEditingDiscount] = useState(null); - - - const handleOpenCreateForm = () => { - setEditingDiscount(null); - setMode('create'); - }; - - const handleOpenEditForm = (discount: DiscountResponse) => { - setEditingDiscount(discount); - setMode('edit'); - }; - - const handleCancelForm = () => { - setMode('view'); - setEditingDiscount(null); - }; - - - - const handleSaveDiscount = async (data: DiscountDTO) => { - const action = mode === 'create' - ? createDiscount(event!.id, data) - : updateDiscount(event!.id, editingDiscount!.id, data); - - const toastId = toast.loading(`${mode === 'create' ? 'Creating' : 'Updating'} discount...`); - try { - await action; - toast.success(`Discount ${mode === 'create' ? 'created' : 'updated'} successfully.`, {id: toastId}); - setMode('view'); // Return to list view on success - await refetchDiscounts(); - } catch (error) { - console.error(`Failed to ${mode} discount`, error); - toast.error(`Failed to ${mode} discount.`, {id: toastId}); - } - }; - - const handleToggleStatus = async (discountId: string) => { - if (!event) return; - const originalDiscounts = [...(event.discounts || [])]; - const discountIndex = originalDiscounts.findIndex((d) => d.id === discountId); - if (discountIndex === -1) return; - - const discountToUpdate = originalDiscounts[discountIndex]; - const newStatus = !discountToUpdate.active; - - const newDiscounts = [...originalDiscounts]; - newDiscounts[discountIndex] = { ...discountToUpdate, active: newStatus }; - setDiscounts(newDiscounts); - - const toastId = toast.loading("Updating status..."); - try { - await updateDiscount(event.id, discountId, { - ...discountToUpdate, - active: newStatus - }); - toast.success("Status updated.", { id: toastId }); - } catch (err) { - toast.error("Failed to update status.", { id: toastId }); - setDiscounts(originalDiscounts); - console.error(err); - } - }; - - const handleDeleteDiscount = async (discountId: string) => { - if (!event) return; - - // 1. Save original state and create the new state - const originalDiscounts = [...(event.discounts || [])]; - const newDiscounts = originalDiscounts.filter(d => d.id !== discountId); - - // 2. Optimistically update the UI - setDiscounts(newDiscounts); - - // 3. Send the request - const toastId = toast.loading("Deleting discount..."); - try { - await deleteDiscount(event.id, discountId); - toast.success("Discount deleted successfully.", { id: toastId }); - } catch (err) { - // 4. On failure, revert and show error - toast.error("Failed to delete discount.", { id: toastId }); - setDiscounts(originalDiscounts); // Revert! - console.error(err); - } - }; - - if (isLoading) { - return ( -
-
- - - -
-
- ); +"use client"; + +import { useState } from "react"; +import { useEventContext } from "@/providers/EventProvider"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AlertTriangle, Plus, RefreshCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { createDiscount, DiscountResponse, deleteDiscount, updateDiscount } from "@/lib/actions/discountActions"; +import { toast } from "sonner"; +import { DiscountList } from "@/app/manage/organization/[organization_id]/event/_components/discounts/discount-list"; +import { DiscountDTO } from "@/lib/validators/event"; +import { + FullDiscountFormView +} from "@/app/manage/organization/[organization_id]/event/_components/discounts/full-discount-form-view"; + +export default function DiscountManagementPage() { + const { event, isLoading, refetchDiscounts, error, setDiscounts } = useEventContext(); + + const [mode, setMode] = useState<'view' | 'create' | 'edit'>('view'); + const [editingDiscount, setEditingDiscount] = useState(null); + + + const handleOpenCreateForm = () => { + setEditingDiscount(null); + setMode('create'); + }; + + const handleOpenEditForm = (discount: DiscountResponse) => { + setEditingDiscount(discount); + setMode('edit'); + }; + + const handleCancelForm = () => { + setMode('view'); + setEditingDiscount(null); + }; + + + + const handleSaveDiscount = async (data: DiscountDTO) => { + const action = mode === 'create' + ? createDiscount(event!.id, data) + : updateDiscount(event!.id, editingDiscount!.id, data); + + const toastId = toast.loading(`${mode === 'create' ? 'Creating' : 'Updating'} discount...`); + try { + await action; + toast.success(`Discount ${mode === 'create' ? 'created' : 'updated'} successfully.`, { id: toastId }); + setMode('view'); // Return to list view on success + await refetchDiscounts(); + } catch (error) { + console.error(`Failed to ${mode} discount`, error); + toast.error(`Failed to ${mode} discount.`, { id: toastId }); + } + }; + + const handleToggleStatus = async (discountId: string) => { + if (!event) return; + const originalDiscounts = [...(event.discounts || [])]; + const discountIndex = originalDiscounts.findIndex((d) => d.id === discountId); + if (discountIndex === -1) return; + + const discountToUpdate = originalDiscounts[discountIndex]; + const newStatus = !discountToUpdate.active; + + const newDiscounts = [...originalDiscounts]; + newDiscounts[discountIndex] = { ...discountToUpdate, active: newStatus }; + setDiscounts(newDiscounts); + + const toastId = toast.loading("Updating status..."); + try { + await updateDiscount(event.id, discountId, { + ...discountToUpdate, + active: newStatus + }); + toast.success("Status updated.", { id: toastId }); + } catch (err) { + toast.error("Failed to update status.", { id: toastId }); + setDiscounts(originalDiscounts); + console.error(err); + } + }; + + const handleDeleteDiscount = async (discountId: string) => { + if (!event) return; + + // 1. Save original state and create the new state + const originalDiscounts = [...(event.discounts || [])]; + const newDiscounts = originalDiscounts.filter(d => d.id !== discountId); + + // 2. Optimistically update the UI + setDiscounts(newDiscounts); + + // 3. Send the request + const toastId = toast.loading("Deleting discount..."); + try { + await deleteDiscount(event.id, discountId); + toast.success("Discount deleted successfully.", { id: toastId }); + } catch (err) { + // 4. On failure, revert and show error + toast.error("Failed to delete discount.", { id: toastId }); + setDiscounts(originalDiscounts); // Revert! + console.error(err); } + }; - if (!event) { - return ( -
-
- -
-

- Event not found -

+ if (isLoading) { + return ( +
+
+ + +
- ); - } +
+ ); + } - // ✅ CONDITIONAL RENDERING: Show the FullDiscountFormView wrapper - if (mode === 'create' || mode === 'edit') { - return ( -
-
- -
+ if (!event) { + return ( +
+
+
- ); - } +

+ Event not found +

+
+ ); + } - // Default: The List View + // ✅ CONDITIONAL RENDERING: Show the FullDiscountFormView wrapper + if (mode === 'create' || mode === 'edit') { return (
-
-
-

Discount Management

-

- Create and manage discounts for {event.title} -

-
-
- - -
-
- - {error ? ( -
- {error} -
- ) : isLoading ? ( - - ) : event && (!event.discounts || event.discounts.length === 0) ? ( -
-

No discounts found

- -
- ) : ( - - )} +
); } + + // Default: The List View + return ( +
+
+
+
+

Discount Management

+

+ Create and manage discounts for {event.title} +

+
+
+ + +
+
+ + {error ? ( +
+ {error} +
+ ) : isLoading ? ( + + ) : event && (!event.discounts || event.discounts.length === 0) ? ( +
+

No discounts found

+ +
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page.tsx index 10b37d9..4cf5e7d 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page.tsx @@ -100,9 +100,7 @@ export default function CreateSessionPage() { const formData = methods.getValues() const validationResult = await currentSchema.safeParseAsync(formData) - - console.log(validationResult) - +c if (validationResult.success) { setStep((s) => Math.min(totalSteps, s + 1)) } else { @@ -181,6 +179,7 @@ export default function CreateSessionPage() { // Custom props for step components that will be needed to make them work independently const schedulingStepProps = { // Add any props needed for SchedulingStep + currentSessionCount: event.sessions?.length || 0 }; const seatingStepProps = { diff --git a/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx b/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx index 1322351..7d96203 100644 --- a/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx +++ b/src/app/manage/organization/[organization_id]/event/_components/SchedulingStep.tsx @@ -16,7 +16,7 @@ import {useLimits} from "@/providers/LimitProvider"; import {CreateSessionsFormData} from "@/app/manage/organization/[organization_id]/event/[eventId]/sessions/create/page"; // --- Main Scheduling Step Component --- -export function SchedulingStep() { +export function SchedulingStep({currentSessionCount}: { currentSessionCount?: number }) { const {control, formState: {errors}} = useFormContext(); const [isRecurringDialogOpen, setIsRecurringDialogOpen] = useState(false); const [isSingleSessionDialogOpen, setIsSingleSessionDialogOpen] = useState(false); @@ -45,7 +45,7 @@ export function SchedulingStep() { }; // Disable add buttons if we've reached the maximum - const hasReachedLimit = fields.length >= maxSessions; + const hasReachedLimit = fields.length + (currentSessionCount || 0) >= maxSessions; const openSingleSessionDialog = () => { if (hasReachedLimit) { @@ -75,7 +75,7 @@ export function SchedulingStep() {
{maxSessions > 0 && ( - Session limit: {fields.length}/{maxSessions} + Session limit: {(currentSessionCount || 0) > 0 ? `${currentSessionCount! + fields.length}/${maxSessions}` : `${fields.length}/${maxSessions}`} )}
diff --git a/src/lib/actions/eventUpdateActions.ts b/src/lib/actions/eventUpdateActions.ts new file mode 100644 index 0000000..2aaabd7 --- /dev/null +++ b/src/lib/actions/eventUpdateActions.ts @@ -0,0 +1,119 @@ +import { apiFetch } from '@/lib/api'; + +const API_BASE_PATH = '/event-seating/v1/events'; + +/** + * Interface for event response from the API + */ +export interface EventResponseDTO { + id: string; + title: string; + status: string; + organizationId: string; + createdAt: string; + updatedAt: string; +} + +/** + * Request data for updating basic event details + */ +export interface UpdateEventRequest { + title?: string; + description?: string; + overview?: string; +} + +/** + * Updates basic event details (title, description, overview) + * + * @param eventId ID of the event to update + * @param updateData The event data to update + * @returns The updated event details + */ +export const updateEventDetails = ( + eventId: string, + updateData: UpdateEventRequest +): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/update`, { + method: 'PUT', + body: JSON.stringify(updateData), + }); +}; + +/** + * Adds a cover photo to an event + * + * @param eventId ID of the event + * @param file The image file to upload + * @returns The updated event details including the new cover photo + */ +export const addCoverPhoto = ( + eventId: string, + file: File +): Promise => { + const formData = new FormData(); + formData.append('file', file); + + return apiFetch(`${API_BASE_PATH}/${eventId}/update/cover-photos`, { + method: 'POST', + body: formData, + // FormData will be automatically detected by apiFetch and Content-Type header will be omitted + }); +}; + +/** + * Removes a cover photo from an event + * + * @param eventId ID of the event + * @param photoId ID of the photo to remove + * @returns The updated event details + */ +export const removeCoverPhoto = ( + eventId: string, + photoId: string +): Promise => { + return apiFetch(`${API_BASE_PATH}/${eventId}/update/cover-photos/${photoId}`, { + method: 'DELETE', + }); +}; + +/** + * Helper function to handle file upload for cover photos with error handling + * + * @param eventId ID of the event + * @param file The image file to upload + * @returns Promise with the updated event or error + */ +export const uploadEventCoverPhoto = async ( + eventId: string, + file: File +): Promise<{ success: boolean; data?: EventResponseDTO; error?: string }> => { + try { + // Validate file size (e.g., limit to 5MB) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + return { + success: false, + error: `File size exceeds the maximum allowed size (5MB). Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB` + }; + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; + if (!allowedTypes.includes(file.type)) { + return { + success: false, + error: 'Only JPEG, PNG, and WebP images are allowed.' + }; + } + + const result = await addCoverPhoto(eventId, file); + return { success: true, data: result }; + } catch (error) { + console.error('Error uploading cover photo:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'An unknown error occurred while uploading the cover photo' + }; + } +}; \ No newline at end of file From 5b1fc3daed84f41b0dc3c4c8b5172f845d6b23bd Mon Sep 17 00:00:00 2001 From: Dasun Piyumal Date: Thu, 9 Oct 2025 02:57:16 +0530 Subject: [PATCH 36/51] feat: enhance event settings page with cover photo management and form validation --- .../event/[eventId]/settings/page.tsx | 412 +++++++++++++++++- 1 file changed, 409 insertions(+), 3 deletions(-) diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/settings/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/settings/page.tsx index 03d905c..ef9ae46 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/settings/page.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/settings/page.tsx @@ -1,15 +1,421 @@ "use client"; import * as React from "react"; +import { useState, useEffect, useRef } from "react"; +import { useEventContext } from "@/providers/EventProvider"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { UpdateEventRequest, updateEventDetails, uploadEventCoverPhoto, removeCoverPhoto } from "@/lib/actions/eventUpdateActions"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Loader2, ImageUp, X, Trash2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { compressImage } from "@/lib/imageUtils"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"; +import { GeminiMarkdownEditor } from "@/app/manage/organization/[organization_id]/event/_components/GenAIMarkdownEditor"; +import { useOrganization } from "@/providers/OrganizationProvider"; + +// Define validation schema for event updates +const updateEventSchema = z.object({ + title: z.string().min(3, { message: "Title must be at least 3 characters." }).max(255), + description: z.string().optional(), + overview: z.string().optional(), +}); + +type UpdateEventForm = z.infer; export default function EventSettingsPage() { + const { event, refetchEventData, isLoading } = useEventContext(); + const { organization } = useOrganization(); + const [activeTab, setActiveTab] = useState("cover-photos"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [coverFiles, setCoverFiles] = useState([]); + const fileInputRef = useRef(null); + + // Form setup + const form = useForm({ + resolver: zodResolver(updateEventSchema), + defaultValues: { + title: event?.title || "", + description: event?.description || "", + overview: event?.overview || "", + }, + }); + + // Update form when event data changes + useEffect(() => { + if (event) { + form.reset({ + title: event.title, + description: event.description || "", + overview: event.overview || "", + }); + } + }, [event, form]); + + const onSubmit = async (data: UpdateEventForm) => { + if (!event?.id) return; + + setIsSubmitting(true); + + try { + // Prepare the update request + const updateRequest: UpdateEventRequest = { + title: data.title, + description: data.description, + overview: data.overview, + }; + + // Update the event details + await updateEventDetails(event.id, updateRequest); + + // Handle cover photo uploads if any + if (coverFiles.length > 0) { + const uploadPromises = coverFiles.map(file => uploadEventCoverPhoto(event.id, file)); + const results = await Promise.all(uploadPromises); + + // Check if all uploads succeeded + const failedUploads = results.filter(r => !r.success); + if (failedUploads.length > 0) { + toast.error(`${failedUploads.length} images failed to upload.`); + } else { + toast.success("All images uploaded successfully."); + } + } + + // Refresh event data to show updated information + await refetchEventData(); + toast.success("Event details updated successfully."); + setCoverFiles([]); + } catch (error) { + console.error("Failed to update event:", error); + toast.error("Failed to update event details. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + if (!e.target.files?.length) return; + + const selectedFiles = Array.from(e.target.files); + const maxPhotos = 5; + + // Check if adding these files would exceed the maximum allowed + if (selectedFiles.length > maxPhotos) { + toast.error(`You can only upload a maximum of ${maxPhotos} photos.`); + return; + } + + // Define compression options for cover photos + const coverPhotoCompressionOptions = { + maxSizeMB: 1.5, + maxWidthOrHeight: 1920, + useWebWorker: true, + fileType: 'image/jpeg' + }; + + toast.loading('Processing images...'); + + try { + // Process files one by one with compression + const processedFiles = await Promise.all( + selectedFiles.map(async (file) => { + return await compressImage(file, coverPhotoCompressionOptions); + }) + ); + + // Add the compressed files to the state + setCoverFiles(prev => [...prev, ...processedFiles]); + toast.dismiss(); + toast.success(`${processedFiles.length} image(s) added successfully`); + } catch (error) { + console.error('Error processing images:', error); + toast.dismiss(); + toast.error('Error processing images'); + } + }; + + const handleRemoveCoverPhoto = async (photoId: string) => { + if (!event?.id) return; + + try { + await removeCoverPhoto(event.id, photoId); + await refetchEventData(); + toast.success("Cover photo removed successfully."); + } catch (error) { + console.error("Failed to remove cover photo:", error); + toast.error("Failed to remove cover photo. Please try again."); + } + }; + + const removeImageFromUpload = (index: number) => { + setCoverFiles(prev => prev.filter((_, i) => i !== index)); + }; + + if (isLoading || !event) { + return ( +
+ +
+ ); + } + return (

Event Settings

-

- Configure your event settings and preferences here. -

+ + + + Cover Photos + Details + + +
+ + +
+ {/* Event Details Section */} +
+
+

Event Details

+

Update the core information that appears on your event page.

+
+ + ( + + Event Title + + + + The main headline for your event. + + + )}/> + + ( + + Short Description + +