diff --git a/package-lock.json b/package-lock.json index 6884ce5..792a620 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@google-analytics/data": "^5.2.0", "@hookform/resolvers": "^5.2.1", + "@icons-pack/react-simple-icons": "^13.8.0", "@next/third-parties": "^15.5.2", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", @@ -478,6 +479,15 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@icons-pack/react-simple-icons": { + "version": "13.8.0", + "resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.8.0.tgz", + "integrity": "sha512-iZrhL1fSklfCCVn68IYHaAoKfcby3RakUTn2tRPyHBkhr2tkYqeQbjJWf+NizIYBzKBn2IarDJXmTdXd6CuEfw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.13 || ^17 || ^18 || ^19" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", diff --git a/package.json b/package.json index a57c3d7..9f1a93e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@google-analytics/data": "^5.2.0", "@hookform/resolvers": "^5.2.1", + "@icons-pack/react-simple-icons": "^13.8.0", "@next/third-parties": "^15.5.2", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", 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/(home-app)/events/[event_id]/layout.tsx b/src/app/(home-app)/events/[event_id]/layout.tsx index b384a3f..60626c5 100644 --- a/src/app/(home-app)/events/[event_id]/layout.tsx +++ b/src/app/(home-app)/events/[event_id]/layout.tsx @@ -1,16 +1,16 @@ -import React, {Suspense} from 'react'; -import {EventHero, ReviewEventHeroSkeleton} from "@/app/(home-app)/events/[event_id]/_components/EventHero"; -import {Separator} from "@/components/ui/separator"; -import {getEventSummery, getEventTotalViews} from "@/lib/actions/public/server/eventActions"; -import {EventTracker} from "@/app/(home-app)/events/[event_id]/_components/EventTracker"; -import {EventOverview} from "@/app/manage/_components/review/EventOverview"; +import React, { Suspense } from 'react'; +import { EventHero, ReviewEventHeroSkeleton } from "@/app/(home-app)/events/[event_id]/_components/EventHero"; +import { Separator } from "@/components/ui/separator"; +import { getEventSummery, getEventTotalViews } from "@/lib/actions/public/server/eventActions"; +import { EventOverview } from "@/app/manage/_components/review/EventOverview"; +import { EventTracker } from './_components/EventTracker'; -export default async function Layout({params, children}: { +export default async function Layout({ params, children }: { params: Promise<{ event_id: string }> children: React.ReactNode; }) { - const {event_id} = await params; + const { event_id } = await params; const eventSummery = await getEventSummery(event_id); const viewsData = await getEventTotalViews(event_id); @@ -18,18 +18,18 @@ export default async function Layout({params, children}: {
- - }> + }> + - - + viewCount={viewsData.success ? viewsData.viewCount : undefined} /> + + - + {children}
diff --git a/src/app/(home-app)/page.tsx b/src/app/(home-app)/page.tsx index 0ea5c2f..3e0d499 100644 --- a/src/app/(home-app)/page.tsx +++ b/src/app/(home-app)/page.tsx @@ -5,71 +5,69 @@ import CategorySection from "@/app/(home-app)/_components/CategorySection"; import {EventThumbnailDTO} from "@/types/event"; import {sriLankaLocations} from "@/app/(home-app)/_utils/locations"; import {LocationCard} from "@/app/(home-app)/_components/LocationCard"; -import {ArrowRight, Calendar, MapPin, Users} from "lucide-react"; +import {ArrowRight, Calendar, MapPin, Ticket} from "lucide-react"; import Link from 'next/link'; import {useAuth} from '@/providers/AuthProvider'; - -const trendingEvents: EventThumbnailDTO[] = [ - { - id: '1', - title: 'Colombo Music Fest 2025', - coverPhotoUrl: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?q=80&w=2070&auto=format=fit=crop', - organizationName: 'Vibe Events', - categoryName: 'Music', - earliestSession: { - startTime: '2025-09-15T18:00:00Z', - venueName: 'Galle Face Green', - city: 'Colombo' - }, - startingPrice: 25.00, - discounts: null - }, - { - id: '2', - title: 'Kandy Esala Perahera Viewing', - coverPhotoUrl: 'https://images.unsplash.com/photo-1566766188646-5d0310191714?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', - organizationName: 'Cultural SL', - categoryName: 'Culture', - earliestSession: { - startTime: '2025-08-28T19:00:00Z', - venueName: 'Temple of the Tooth', - city: 'Kandy' - }, - startingPrice: 15.00, - discounts: null - }, - { - id: '3', - title: 'Galle Literary Festival', - coverPhotoUrl: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?q=80&w=1973&auto=format=fit=crop', - organizationName: 'Sri Lanka Arts', - categoryName: 'Arts & Literature', - earliestSession: { - startTime: '2025-10-10T10:00:00Z', - venueName: 'Galle Fort', - city: 'Galle' - }, - startingPrice: null, - discounts: null - }, - { - id: '4', - title: 'Startup Summit Sri Lanka', - coverPhotoUrl: 'https://images.unsplash.com/photo-1542744173-8e7e53415bb0?q=80&w=2070&auto=format=fit=crop', - organizationName: 'TechHub LK', - categoryName: 'Tech', - earliestSession: { - startTime: '2025-11-05T09:00:00Z', - venueName: 'BMICH', - city: 'Colombo' - }, - startingPrice: 50.00, - discounts: null - }, -]; +import { useEffect, useState } from 'react'; +import {getTrendingEvents, getTotalSessionsCount, getTotalTicketsSold} from '@/lib/actions/public/eventActions'; +import { Skeleton } from '@/components/ui/skeleton'; +import CounterAnimation from "@/components/ui/counter-animation"; export default function HomePage() { const {isAuthenticated, keycloak} = useAuth(); + const [trendingEvents, setTrendingEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [totalSessionsCount, setTotalSessionsCount] = useState(0); + const [totalTicketsSold, setTotalTicketsSold] = useState(0); + const [loadingSessionsCount, setLoadingSessionsCount] = useState(true); + const [loadingTicketsCount, setLoadingTicketsCount] = useState(true); + + useEffect(() => { + const fetchTrendingEvents = async () => { + try { + setLoading(true); + const events = await getTrendingEvents(3); + setTrendingEvents(events); + setError(null); + } catch (err) { + console.error("Failed to fetch trending events:", err); + setError("Failed to load trending events. Please try again later."); + } finally { + setLoading(false); + } + }; + + const fetchTotalSessionsCount = async () => { + try { + setLoadingSessionsCount(true); + const count = await getTotalSessionsCount(); + setTotalSessionsCount(count); + } catch (err) { + console.error("Failed to fetch total sessions count:", err); + setTotalSessionsCount(0); + } finally { + setLoadingSessionsCount(false); + } + }; + + const fetchTotalTicketsSold = async () => { + try { + setLoadingTicketsCount(true); + const count = await getTotalTicketsSold(); + setTotalTicketsSold(count); + } catch (err) { + console.error("Failed to fetch total tickets sold:", err); + setTotalTicketsSold(0); + } finally { + setLoadingTicketsCount(false); + } + }; + + fetchTrendingEvents(); + fetchTotalSessionsCount(); + fetchTotalTicketsSold(); + }, []); return (
@@ -79,7 +77,7 @@ export default function HomePage() {

Discover - Amazing Events + Amazing Events

Find and book extraordinary experiences happening around you @@ -92,7 +90,20 @@ export default function HomePage() { className="flex items-center justify-center w-12 h-12 bg-primary/10 rounded-full mx-auto mb-3">

-
1000+
+
+ {loadingSessionsCount ? ( + + ) : ( + <> + + + )} +
Events
@@ -106,10 +117,23 @@ export default function HomePage() {
- + +
+
+ {loadingTicketsCount ? ( + + ) : ( + <> + + + )}
-
50K+
-
Attendees
+
Tickets Sold
@@ -150,10 +174,65 @@ export default function HomePage() { Don't miss out on the most popular events happening right now

-
- {trendingEvents.map((event) => ( - - ))} + + {error && ( +
+

{error}

+ +
+ )} + +
+ {loading ? ( + // Skeleton loading UI + Array(3).fill(0).map((_, index) => ( +
+ + + + + +
+ )) + ) : trendingEvents.length > 0 ? ( + // Display actual events + trendingEvents.map((event: EventThumbnailDTO) => ( + + )) + ) : ( + // No events found +
+

No trending events found at the moment.

+
+ )} +
+ + {/* View All Link */} +
+ + +
diff --git a/src/app/manage/_components/review/ReviewSessions.tsx b/src/app/manage/_components/review/ReviewSessions.tsx index 19a4584..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 {SessionParsed, 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: SessionParsed[]; + sessions: SessionDTO[]; tiers: TierFormData[]; } @@ -38,7 +38,7 @@ export const ReviewSessions: React.FC = ({sessions, tiers}) }; interface SessionAccordionItemProps { - session: SessionParsed; + session: SessionDTO; tiers: TierFormData[]; index: number; } @@ -94,7 +94,7 @@ const SessionAccordionItem: React.FC = ({session, ind }; interface SessionDetailsProps { - session: SessionParsed; + session: SessionDTO; } const SessionDetails: React.FC = ({session}) => { diff --git a/src/app/manage/_components/review/ReviewTicketTiers.tsx b/src/app/manage/_components/review/ReviewTicketTiers.tsx index b0dc4a4..d53c8b5 100644 --- a/src/app/manage/_components/review/ReviewTicketTiers.tsx +++ b/src/app/manage/_components/review/ReviewTicketTiers.tsx @@ -38,7 +38,6 @@ export const TicketTierCard: React.FC = ({tier}) => { {tier.name}
LKR {tier.price.toFixed(2)} - {/**/}
diff --git a/src/app/manage/_components/review/SeatingInformation.tsx b/src/app/manage/_components/review/SeatingInformation.tsx index b52f97e..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 { SessionParsed, 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: SessionParsed; + session: SessionDTO; tiers: TierFormData[]; } diff --git a/src/app/manage/_components/review/SeatingLayout.tsx b/src/app/manage/_components/review/SeatingLayout.tsx index a68d4e1..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 {SessionParsed, 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: SessionParsed; + session: SessionDTO; tiers: TierFormData[]; } diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/[sessionId]/page.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/[sessionId]/page.tsx deleted file mode 100644 index f74530a..0000000 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/[sessionId]/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client" - -import {useEffect, useState} from "react"; -import {SessionAnalytics} from "@/types/eventAnalytics"; -import {SessionAnalyticsView} from "../_components/SessionAnalyticsView"; -import {Skeleton} from "@/components/ui/skeleton"; -import {getSessionAnalytics} from "@/lib/actions/public/analyticsActions"; -import {useParams} from "next/navigation"; - -export default function SessionAnalyticsPage() { - const [sessionAnalytics, setSessionAnalytics] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const params = useParams(); - const eventId = params.eventId as string; - const sessionId = params.sessionId as string; - - useEffect(() => { - const fetchData = async () => { - try { - setIsLoading(true); - const data = await getSessionAnalytics(eventId, sessionId); - setSessionAnalytics(data); - } catch (err) { - setError("Failed to load session analytics."); - console.error(err); - } finally { - setIsLoading(false); - } - }; - fetchData(); - }, [eventId, sessionId]); - - if (error) { - return
{error}
; - } - - if (isLoading || !sessionAnalytics) { - return ( -
- - -
- {Array.from({length: 4}).map((_, i) => )} -
-
- - -
-
- ); - } - - return ; -} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/DailySalesChart.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/DailySalesChart.tsx new file mode 100644 index 0000000..1073629 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/DailySalesChart.tsx @@ -0,0 +1,163 @@ +"use client" + +import React from 'react'; +import { DailySalesMetrics } from '@/lib/actions/analyticsActions'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card'; +import { formatCurrency } from '@/lib/utils'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts'; +import { TrendingUp } from "lucide-react"; + +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +interface DailySalesChartProps { + data: DailySalesMetrics[]; +} + +export const DailySalesChart: React.FC = ({ data }) => { + // Format the data for the chart + const formattedData = data.map(item => ({ + date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + revenue: item.revenue, + tickets: item.tickets_sold + })); + + // Calculate trend if there's enough data + const calculateTrend = () => { + if (data.length < 2) return { percentage: 0, increasing: true }; + + // Use the last 7 days for trend calculation if available, otherwise last 2 + const trendData = data.length > 7 ? data.slice(-7) : data.slice(-2); + const startRevenue = trendData[0].revenue; + const endRevenue = trendData[trendData.length - 1].revenue; + + const difference = endRevenue - startRevenue; + const percentage = startRevenue !== 0 + ? (difference / startRevenue) * 100 + : (endRevenue > 0 ? 100 : 0); // Handle division by zero + + return { + percentage: Math.abs(percentage), + increasing: difference >= 0 + }; + }; + + const trend = calculateTrend(); + + // Get the date range for display + const getDateRange = () => { + if (data.length === 0) return "No data available"; + if (data.length === 1) return new Date(data[0].date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); + + const firstDate = new Date(data[0].date); + const lastDate = new Date(data[data.length - 1].date); + + return `${firstDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${lastDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; + }; + + const chartConfig = { + revenue: { + label: "Revenue", + color: "var(--color-chart-1)", + }, + tickets: { + label: "Tickets Sold", + color: "var(--color-chart-2)", + }, + } satisfies ChartConfig; + + return ( + + + Daily Sales & Revenue + {getDateRange()} + + + + + + value.slice(0, 3)} // Show abbreviated month + /> + `${formatCurrency(value, 'LKR').slice(0, -3)}`} // Shorten currency + /> + + { + const datePoint = data.find(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) === label); + return datePoint ? new Date(datePoint.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : label; + }} + formatter={(value, name) => { + if (name === 'revenue') { + return formatCurrency(value as number, 'LKR', 'en-LK'); + } + return value.toLocaleString(); + }} + /> + } + /> + + + + + + +
+
+ {data.length >= 2 && ( +
+ {trend.increasing ? 'Trending up' : 'Trending down'} by {trend.percentage.toFixed(1)}% {' '} + +
+ )} +
+ Showing daily sales and revenue over the event's sales period +
+
+
+
+
+ ); +}; diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/EventAnalyticsView.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/EventAnalyticsView.tsx index d13393a..8baa5a3 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/EventAnalyticsView.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/EventAnalyticsView.tsx @@ -12,33 +12,65 @@ import {AnalyticsCard} from "./AnalyticsCard"; import { TierDistributionChart } from "@/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/TierDistribution"; +import { DailySalesChart } from "./DailySalesChart"; +import { DailySalesMetrics, TierSalesMetrics } from "@/lib/actions/analyticsActions"; interface EventAnalyticsViewProps { analytics: EventAnalytics; + revenueAnalytics?: { + total_revenue: number; + total_before_discounts: number; + total_tickets_sold: number; + daily_sales: DailySalesMetrics[]; + sales_by_tier?: TierSalesMetrics[]; + }; isGaLoading?: boolean; + isRevenueLoading?: boolean; } -export const EventAnalyticsView: React.FC = ({analytics, isGaLoading = false}) => { +export const EventAnalyticsView: React.FC = ({ + analytics, + revenueAnalytics, + isGaLoading = false, + isRevenueLoading = false +}) => { + // Calculate average revenue per ticket + const avgRevenuePerTicket = revenueAnalytics && revenueAnalytics.total_tickets_sold > 0 + ? revenueAnalytics.total_revenue / revenueAnalytics.total_tickets_sold + : 0; + + // Calculate discount savings + const totalDiscountSavings = revenueAnalytics + ? revenueAnalytics.total_before_discounts - revenueAnalytics.total_revenue + : 0; + + const total_before_discounts = revenueAnalytics?.total_before_discounts || analytics.totalRevenue + totalDiscountSavings; + return (
{/* Top Level KPI Cards */} -
- {/* Revenue Performance Card */} +
+ {/* Revenue & Discount Card */} 0 + ? `${((totalDiscountSavings / total_before_discounts) * 100).toFixed(1)}% discount savings` + : "No discounts applied"} icon={
} + isLoading={isRevenueLoading} /> {/* Sales Performance Card */} = ({analytics
} + isLoading={isRevenueLoading} /> {/* Audience Engagement Card */} @@ -63,44 +96,38 @@ export const EventAnalyticsView: React.FC = ({analytics
{/* Detailed Chart Grid */} -
+
+ {/* Daily Sales Chart - Spanning full width */} + {revenueAnalytics?.daily_sales && revenueAnalytics.daily_sales.length > 0 && ( +
+ {isRevenueLoading ? ( + + ) : ( + + )} +
+ )} + + {/* Tier Charts */} - -
+ -
+ {/* Google Analytics Charts */} {isGaLoading ? ( <> + - ) : ( <> {analytics.viewsTimeSeries && } {analytics.deviceBreakdown && } - - )} -
- - {/* GA Insights Grid */} -
- {isGaLoading ? ( - <> - - - - ) : ( - <> {analytics.trafficSources && } - {analytics.audienceGeography && - - } + {analytics.audienceGeography && } - ) - } + )}
- ) - ; -}; \ No newline at end of file + ); +}; diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionAnalyticsView.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionAnalyticsView.tsx index d79edbe..53fd48f 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionAnalyticsView.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionAnalyticsView.tsx @@ -1,29 +1,53 @@ import {SessionAnalytics} from "@/types/eventAnalytics"; import {formatCurrency, formatDate, formatDateTimeShort, formatISODuration} from "@/lib/utils"; import {AnalyticsCard} from "./AnalyticsCard"; -import {ArrowLeft, Clock, DollarSign, Ticket} from "lucide-react"; +import {Clock, DollarSign, Ticket} from "lucide-react"; import {TierSalesChart} from "./TierSalesChart"; import {BlockOccupancyChart} from "./BlockOccupancyChart"; import {SessionStatusBadge} from "@/components/SessionStatusBadge"; -import Link from "next/link"; import {usePathname} from 'next/navigation'; import {SeatStatusChart} from "./SeatStatusChart"; +import {DailySalesChart} from "./DailySalesChart"; +import {Skeleton} from "@/components/ui/skeleton"; import { TierDistributionChart } from "@/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/TierDistribution"; -export const SessionAnalyticsView: React.FC<{ analytics: SessionAnalytics }> = ({analytics}) => { +export const SessionAnalyticsView: React.FC<{ + analytics: SessionAnalytics, + sessionAnalytics?: { + total_revenue: number; + total_before_discounts: number; + total_tickets_sold: number; + daily_sales: Array<{date: string; revenue: number; tickets_sold: number}>; + sales_by_tier?: Array<{ + tier_id: string; + tier_name: string; + tier_color: string; + tickets_sold: number; + revenue: number; + }>; + }, + isLoading?: boolean +}> = ({analytics, sessionAnalytics, isLoading = false}) => { const pathname = usePathname(); const eventAnalyticsPath = pathname.substring(0, pathname.lastIndexOf('/')); + + // Calculate average revenue per ticket + const avgRevenuePerTicket = analytics.ticketsSold > 0 + ? analytics.sessionRevenue / analytics.ticketsSold + : 0; + + // Calculate discount savings if we have session analytics data + const totalDiscountSavings = sessionAnalytics + ? sessionAnalytics.total_before_discounts - sessionAnalytics.total_revenue + : 0; + + const total_before_discounts = sessionAnalytics?.total_before_discounts || analytics.sessionRevenue + totalDiscountSavings; return ( -
+
- - - Back to Event Overview -

{analytics.eventTitle}

@@ -33,45 +57,68 @@ export const SessionAnalyticsView: React.FC<{ analytics: SessionAnalytics }> = (

- {/* ++ Metric Cards consolidated into a 3-column layout ++ */} -
+ {/* Top Level KPI Cards */} +
+ {/* Revenue & Discount Card */} 0 + ? `${((totalDiscountSavings / total_before_discounts) * 100).toFixed(1)}% discount savings` + : "No discounts applied"} icon={
- +
} + isLoading={isLoading} /> + + {/* Sales Performance Card */} - +
} - + isLoading={isLoading} /> + + {/* Sales Window Card */} - +
} />
+ {/* Daily Sales Chart */} + {sessionAnalytics?.daily_sales && sessionAnalytics.daily_sales.length > 0 && ( +
+ {isLoading ? ( + + ) : ( +
+ +
+ )} +
+ )} + {/* Charts */}
- + diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionPerformanceCard.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionPerformanceCard.tsx new file mode 100644 index 0000000..9e846b2 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionPerformanceCard.tsx @@ -0,0 +1,142 @@ +"use client" + +import React from 'react'; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { format, parseISO } from "date-fns"; +import { formatCurrency } from "@/lib/utils"; +import { ArrowUpRightIcon, ClockIcon, MapPinIcon, UsersIcon } from "lucide-react"; +import { SessionSummary } from "@/types/eventAnalytics"; +import { SessionSummary as OrderSessionSummary } from "@/lib/actions/analyticsActions"; +import { useRouter } from "next/navigation"; +import { SessionDetailDTO } from "@/lib/validators/event"; +import { SessionStatusBadge } from "@/components/SessionStatusBadge"; +import Link from "next/link"; + +interface SessionPerformanceCardProps { + session: SessionSummary; + sessionRevenue?: OrderSessionSummary; + sessionMetadata?: SessionDetailDTO; + organizationId: string; + eventId: string; +} + +export const SessionPerformanceCard = ({ + session, + sessionRevenue, + sessionMetadata, + organizationId, + eventId +}: SessionPerformanceCardProps) => { + const router = useRouter(); + + // Basic session info + const sessionId = session.sessionId; + const startTime = parseISO(session.startTime); + const endTime = parseISO(session.endTime); + const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60); // Duration in minutes + + // Revenue info + const revenue = session.sessionRevenue; + const totalRevenue = sessionRevenue?.total_revenue || revenue; + const beforeDiscounts = sessionRevenue?.total_before_discounts; + const discountAmount = beforeDiscounts ? beforeDiscounts - totalRevenue : 0; + + // Capacity info + const capacity = session.sessionCapacity; + const ticketsSold = session.ticketsSold; + const sellOutPercentage = session.sellOutPercentage; + + // Session metadata (from event context) + const isOnline = sessionMetadata?.sessionType === "ONLINE"; + const location = isOnline + ? "Online Event" + : sessionMetadata?.venueDetails?.name || "Venue not specified"; + + const handleViewDetails = () => { + router.push(`/manage/organization/${organizationId}/event/${eventId}/sessions/${sessionId}/analytics`); + }; + + return ( + + +
+
+ +
+ {format(startTime, "EEE, MMM d")} + + {format(startTime, "h:mm a")} - {format(endTime, "h:mm a")} +
+
+ + + +
+
+ +
+
+

Session Info

+
+
+ + {location} +
+
+ + {Math.floor(duration / 60)}h {duration % 60}m +
+
+ + {capacity} capacity +
+
+
+ +
+

Sales Performance

+
+
+ Revenue + {formatCurrency(totalRevenue, 'LKR', 'en-LK')} +
+ + {beforeDiscounts && ( +
+ Discounts + -{formatCurrency(discountAmount, 'LKR', 'en-LK')} +
+ )} + +
+ Tickets sold + {ticketsSold} / {capacity} +
+
+
+
+ + {/* Sell-out progress bar */} +
+
+ Sell-out Progress + {sellOutPercentage.toFixed(0)}% +
+
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionPerformanceGrid.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionPerformanceGrid.tsx new file mode 100644 index 0000000..bac9940 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionPerformanceGrid.tsx @@ -0,0 +1,83 @@ +"use client" + +import React, { useEffect, useState } from 'react'; +import { SessionSummary } from "@/types/eventAnalytics"; +import { SessionPerformanceCard } from "./SessionPerformanceCard"; +import { SessionSummary as OrderSessionSummary, getSessionsRevenueAnalytics } from "@/lib/actions/analyticsActions"; +import { useParams } from "next/navigation"; +import { useEventContext } from "@/providers/EventProvider"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface SessionPerformanceGridProps { + sessions: SessionSummary[]; + isLoading: boolean; +} + +export const SessionPerformanceGrid = ({ + sessions, + isLoading +}: SessionPerformanceGridProps) => { + const [sessionRevenueData, setSessionRevenueData] = useState([]); + const [isRevenueLoading, setIsRevenueLoading] = useState(true); + const params = useParams(); + const { event } = useEventContext(); + + const organizationId = params.organization_id as string; + const eventId = params.eventId as string; + + // Fetch revenue data for sessions + useEffect(() => { + const fetchRevenueData = async () => { + if (eventId) { + try { + setIsRevenueLoading(true); + const data = await getSessionsRevenueAnalytics(eventId); + setSessionRevenueData(data.sessions); + } catch (error) { + console.error("Error fetching session revenue data:", error); + } finally { + setIsRevenueLoading(false); + } + } + }; + + fetchRevenueData(); + }, [eventId]); + + // Match session revenue data with session metadata from event context + const getSessionRevenue = (sessionId: string) => { + return sessionRevenueData.find(s => s.session_id === sessionId); + }; + + const getSessionMetadata = (sessionId: string) => { + return event?.sessions?.find(s => s.id === sessionId); + }; + + if (isLoading) { + return ( +
+ {[1, 2, 3, 4, 5, 6].map((_, index) => ( + + ))} +
+ ); + } + + return ( +
+

Session Performance

+
+ {sessions.map(session => ( + + ))} +
+
+ ); +}; \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionTableColumnFactory.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionTableColumnFactory.tsx new file mode 100644 index 0000000..546b951 --- /dev/null +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionTableColumnFactory.tsx @@ -0,0 +1,86 @@ +"use client" + +import {ColumnDef} from "@tanstack/react-table"; +import {SessionSummary} from "@/types/eventAnalytics"; +import {format, parseISO} from "date-fns"; +import {formatCurrency} from "@/lib/utils"; +import {ArrowUpDown} from "lucide-react"; +import {Button} from "@/components/ui/button"; +import Link from "next/link"; +import {SessionStatusBadge} from "@/components/SessionStatusBadge"; +import {useParams} from "next/navigation"; + +export function useSessionColumns(): ColumnDef[] { + // Get organization and event ID from params + const params = useParams(); + const organizationId = params.organization_id as string; + const eventId = params.eventId as string; + + return [ + { + accessorKey: "startTime", + header: "Session Date", + cell: ({row}) => { + const session = row.original; + // Using params from the context to build the absolute URL + return ( + + {format(parseISO(session.startTime), "PPp")} + + ); + }, + }, + { + accessorKey: "sessionStatus", + header: "Status", + cell: ({row}) => , + }, + { + accessorKey: "ticketsSold", + header: ({column}) => ( + + ), + cell: ({row}) => { + const {ticketsSold, sessionCapacity} = row.original; + return
{`${ticketsSold} / ${sessionCapacity}`}
; + } + }, + { + accessorKey: "sessionRevenue", + header: ({column}) => ( +
+ +
+ ), + cell: ({row}) =>
{formatCurrency(row.original.sessionRevenue, 'LKR', 'en-LK')}
, + }, + { + accessorKey: "sellOutPercentage", + header: "Sell-Out Progress", + cell: ({row}) => { + const percentage = row.original.sellOutPercentage; + return ( +
+
+
+
+ {percentage.toFixed(0)}% +
+ ); + } + }, + ]; +} \ No newline at end of file diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionTableColumns.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionTableColumns.tsx index 1054300..d3b1846 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionTableColumns.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/SessionTableColumns.tsx @@ -9,6 +9,9 @@ import {Button} from "@/components/ui/button"; import Link from "next/link"; import {SessionStatusBadge} from "@/components/SessionStatusBadge"; +// This file is deprecated - see SessionTableColumnFactory instead +// Keeping this file to avoid breaking existing imports + export const columns: ColumnDef[] = [ { accessorKey: "startTime", @@ -16,7 +19,7 @@ export const columns: ColumnDef[] = [ cell: ({row}) => { const session = row.original; return ( - {format(parseISO(session.startTime), "PPp")} diff --git a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/TierSalesChart.tsx b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/TierSalesChart.tsx index 0012c28..fef9d0a 100644 --- a/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/TierSalesChart.tsx +++ b/src/app/manage/organization/[organization_id]/event/[eventId]/analytics/_components/TierSalesChart.tsx @@ -2,6 +2,7 @@ import React, {useState, useMemo} from "react"; import {TierSales} from "@/types/eventAnalytics"; +import { TierSalesMetrics } from "@/lib/actions/analyticsActions"; import { Card, CardContent, @@ -20,10 +21,34 @@ import {formatCurrency} from "@/lib/utils"; import {ToggleGroup, ToggleGroupItem} from "@/components/ui/toggle-group"; import {DollarSign, Ticket} from "lucide-react"; -export const TierSalesChart: React.FC<{ data: TierSales[] }> = ({data}) => { +export const TierSalesChart: React.FC<{ data: TierSales[] | TierSalesMetrics[] }> = ({data}) => { const [mode, setMode] = useState<"revenue" | "tickets">("revenue"); - const chartConfig = data.reduce( + // Process data to normalize between TierSales and TierSalesMetrics + const processedData = useMemo(() => { + return data.map(tier => { + // Check if it's a TierSalesMetrics object by checking for tier_id property + if ('tier_id' in tier) { + return { + tierId: tier.tier_id, + tierName: tier.tier_name, + tierColor: tier.tier_color, + ticketsSold: tier.tickets_sold, + revenue: tier.revenue + }; + } + // It's already in TierSales format + return { + tierId: tier.tierId, + tierName: tier.tierName, + tierColor: tier.tierColor, + ticketsSold: tier.ticketsSold, + revenue: tier.totalRevenue + }; + }); + }, [data]); + + const chartConfig = processedData.reduce( (acc, tier) => { acc[tier.tierName] = { label: tier.tierName, @@ -35,12 +60,12 @@ export const TierSalesChart: React.FC<{ data: TierSales[] }> = ({data}) => { ); const totalValue = useMemo(() => { - return data.reduce( + return processedData.reduce( (acc, curr) => - acc + (mode === "revenue" ? curr.totalRevenue : curr.ticketsSold), + acc + (mode === "revenue" ? curr.revenue : curr.ticketsSold), 0 ); - }, [data, mode]); + }, [processedData, mode]); return ( @@ -89,13 +114,13 @@ export const TierSalesChart: React.FC<{ data: TierSales[] }> = ({data}) => { content={} /> - {data.map((entry) => ( + {processedData.map((entry) => ( ))}