Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9,823 changes: 8,043 additions & 1,780 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-icons": "^5.5.0",
"react-simple-maps": "^3.0.0",
"react-toastify": "^11.0.5",
"recharts": "^3.7.0",
"swr": "^2.4.1",
Expand Down Expand Up @@ -49,7 +50,6 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vite": "6.4.3",
"vitest": "^3.2.2",
"@vitest/coverage-v8": "^3.2.4"
"vitest": "^3.2.2"
}
}
}
86 changes: 40 additions & 46 deletions src/app/(protected)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@ import { RevenueChart } from '@/components/dashboard/charts/RevenueChart'
import { PerformanceChart } from '@/components/dashboard/charts/PerformanceChart'
import { EmptyState } from '@/components/EmptyState'
import dynamic from 'next/dynamic'
// Code-split Recharts-based chart away from the initial dashboard bundle
const TicketTypeChart = dynamic(
() => import('@/components/dashboard/charts/TicketTypeChart').then((m) => m.TicketTypeChart),
{ ssr: false, loading: () => <div className="h-[220px] animate-pulse rounded-xl bg-white/5" /> }
)
import { DemographicsSection } from '@/components/dashboard/DemographicsSection'
import { LiveCheckInCard } from '@/components/dashboard/LiveCheckInCard'
import { useOrganizerAnalytics } from '@/hooks/useOrganizerAnalytics'
import { exportAnalyticsCsv } from '@/lib/exportAnalyticsCsv'
import { Skeleton } from '@/components/ui/Skeleton'

const TicketTypeChart = dynamic(
() => import('@/components/dashboard/charts/TicketTypeChart').then((m) => m.TicketTypeChart),
{ ssr: false, loading: () => <div className="h-[220px] animate-pulse rounded-xl bg-white/5" /> }
)

const RevenueByTicketTypeChart = dynamic(
() => import('@/components/dashboard/charts/RevenueByTicketTypeChart').then((m) => m.RevenueByTicketTypeChart),
{ ssr: false, loading: () => <div className="h-[220px] animate-pulse rounded-xl bg-white/5" /> }
)

function formatCurrency(n: number) {
return `₦ ${n.toLocaleString('en-NG', { minimumFractionDigits: 0 })}`
}
Expand Down Expand Up @@ -54,7 +60,6 @@ export default function DashboardPage() {
const payoutsQueued = data?.payoutsQueued ?? 0
const nextSettlementDays = data?.nextSettlementDays ?? 0

// Compute week-over-week revenue trend from data.revenue
const revenueTrend = (() => {
const rev = data?.revenue ?? []
if (rev.length < 14) return null
Expand All @@ -64,27 +69,17 @@ export default function DashboardPage() {
return ((currentWeek - lastWeek) / lastWeek) * 100
})()

const eventImages = data?.events?.slice(0, 4).map((e) => ({
const trendText = revenueTrend === null
? 'Insufficient data for trend'
: `Trending by ${Math.abs(revenueTrend).toFixed(1)}% ${revenueTrend >= 0 ? '↗️' : '↘️'} this week`
const trendColor = revenueTrend === null ? 'text-gray-500' : revenueTrend >= 0 ? 'text-emerald-400' : 'text-red-400'

const eventImgs = data?.events?.slice(0, 4).map((e) => ({
src: e.coverImage ?? null,
alt: e.name,
})) ?? [];

const computeWeeklyTrend = () => {
if (!data?.revenue || data.revenue.length < 14) return null;
const lastWeek = data.revenue.slice(-14, -7);
const currentWeek = data.revenue.slice(-7);
const lastTotal = lastWeek.reduce((sum, item) => sum + item.revenue, 0);
const currentTotal = currentWeek.reduce((sum, item) => sum + item.revenue, 0);
if (lastTotal === 0) return null;
return ((currentTotal - lastTotal) / lastTotal) * 100;
};

const weeklyTrend = computeWeeklyTrend();
const trendText = weeklyTrend === null
? 'Insufficient data for trend'
: `Trending by ${Math.abs(weeklyTrend).toFixed(1)}% ${weeklyTrend >= 0 ? '↗️' : '↘️'} this week`;
const trendColor = weeklyTrend === null ? 'text-gray-500' : weeklyTrend >= 0 ? 'text-emerald-400' : 'text-red-400';
})) ?? []

const liveEvent = data?.events?.find(() => data.checkInsLive)

return (
<div className="dark min-h-screen overflow-y-auto flex flex-col bg-[#101428]">
Expand Down Expand Up @@ -114,10 +109,8 @@ export default function DashboardPage() {

<QuickActions />

{/* Loading skeleton */}
{loading && <DashboardSkeleton />}

{/* Empty state — no events yet */}
{!loading && !hasEvents && (
<EmptyState
title="No events yet"
Expand All @@ -129,7 +122,6 @@ export default function DashboardPage() {
/>
)}

{/* Dashboard grid — only shown when events exist */}
{!loading && hasEvents && (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 lg:h-[500px]">
{/* Left Column - Revenue */}
Expand All @@ -150,13 +142,6 @@ export default function DashboardPage() {
<RevenueChart data={revenueData} />
</div>
<p className={`mt-4 text-xs ${trendColor}`}>{trendText}</p>
{revenueTrend === null ? (
<p className="mt-4 text-xs text-gray-500">Insufficient data for trend</p>
) : (
<p className={`mt-4 text-xs ${revenueTrend >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
Trending by {Math.abs(revenueTrend).toFixed(1)}% {revenueTrend >= 0 ? '↗️' : '↘️'} this week
</p>
)}
</Card>

<Card>
Expand All @@ -168,18 +153,15 @@ export default function DashboardPage() {
</Card>
</ScrollColumn>

{/* Middle Column - Attendees */}
{/* Middle Column - Attendees / Check-ins */}
<ScrollColumn animationClass="animate-scroll-down-once" className="gap-0">
<Card>
<p className="text-xs uppercase text-[#21D4FF]">Latest check-ins</p>
<p className="text-sm text-[#21D4FF]">
{data?.checkInsLive
? `Doors open in ${data.doorsOpenInMinutes}m`
: 'No active events'}
</p>
<div className="mt-3 text-xs text-[#4D21FF]">
{data?.checkInsLive ? 'Live updates enabled' : 'Updates paused'}
</div>
<p className="text-xs uppercase text-[#21D4FF] mb-2">Latest check-ins</p>
<LiveCheckInCard
eventId={liveEvent?.id ?? ''}
eventName={liveEvent?.name ?? ''}
isLive={data?.checkInsLive ?? false}
/>
</Card>

<Card>
Expand All @@ -188,7 +170,7 @@ export default function DashboardPage() {
<p className="text-xs text-[#21D4FF]">1.5k from last week</p>
</div>
<div className="grid grid-cols-2 gap-3">
{eventImages.map((image, index) => (
{eventImgs.map((image, index) => (
<EventImage key={index} src={image.src} alt={image.alt} />
))}
</div>
Expand Down Expand Up @@ -217,7 +199,7 @@ export default function DashboardPage() {
</div>
<div className="mt-4 border-t pt-4 border-[#4D21FF]">
<p className="text-xs font-semibold uppercase text-[#21D4FF]">Total Earned</p>
<p className="text-xl font-bold text-[#4D21D4FF]">{formatCurrency(totalEarned)}</p>
<p className="text-xl font-bold text-[#4D21FF]">{formatCurrency(totalEarned)}</p>
<p className="text-xs text-[#21D4FF]">Total amount sent to your bank account</p>
</div>
</Card>
Expand All @@ -241,6 +223,18 @@ export default function DashboardPage() {
</div>
)}

{/* Revenue by Ticket Type */}
{!loading && data?.ticketBreakdown && data.ticketBreakdown.length > 0 && (
<div className="mt-10">
<Card>
<CardHeader title="Revenue by Ticket Type" subtitle="Which ticket categories drive the most revenue" />
<div className="mt-4">
<RevenueByTicketTypeChart data={data.ticketBreakdown} />
</div>
</Card>
</div>
)}

{/* Demographics */}
{!loading && data?.demographics && (
<div className="mt-10">
Expand Down
17 changes: 17 additions & 0 deletions src/components/dashboard/DemographicsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
"use client";

import dynamic from "next/dynamic";
import type { Demographics } from "@/hooks/useOrganizerAnalytics";

interface Props {
demographics: Demographics;
}

// Lazy-load the map so it never SSR-crashes
const GeoHeatmap = dynamic(() => import("./GeoHeatmap"), {
ssr: false,
loading: () => <div className="h-[300px] animate-pulse rounded-xl bg-white/5" />,
});

function DemoGroup({ title, items }: { title: string; items: { label: string; count: number; percentage: number }[] }) {
return (
<div className="rounded-lg bg-white/5 p-4">
Expand Down Expand Up @@ -33,6 +42,14 @@ export function DemographicsSection({ demographics }: Props) {
return (
<section aria-label="Demographic breakdown">
<p className="mb-4 text-sm font-semibold uppercase text-[#21D4FF]">Audience Demographics</p>

{/* Geographic heatmap — shown when region data exists */}
{demographics.region.length > 0 ? (
<div className="mb-6">
<GeoHeatmap regions={demographics.region} />
</div>
) : null}

<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{demographics.region.length > 0 && (
<DemoGroup title="Region" items={demographics.region} />
Expand Down
93 changes: 93 additions & 0 deletions src/components/dashboard/GeoHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";

import { useState } from "react";
import { ComposableMap, Geographies, Geography } from "react-simple-maps";

const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";

interface RegionEntry {
label: string;
count: number;
percentage: number;
}

interface Props {
regions: RegionEntry[];
}

// Map region label to ISO 3166-1 numeric codes (common country names)
function buildCountryMap(regions: RegionEntry[]): Map<string, RegionEntry> {
const map = new Map<string, RegionEntry>();
for (const r of regions) {
map.set(r.label.toLowerCase(), r);
}
return map;
}

function getColor(percentage: number): string {
// Choropleth: light → dark blue scale
const opacity = Math.max(0.1, Math.min(1, percentage / 100));
return `rgba(77, 33, 255, ${opacity})`;
}

interface TooltipState {
x: number;
y: number;
content: string;
}

export default function GeoHeatmap({ regions }: Props) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const regionMap = buildCountryMap(regions);

return (
<div className="relative rounded-xl bg-white/5 p-4">
<p className="mb-2 text-xs font-semibold uppercase text-[#21D4FF]">Geographic Distribution</p>
<ComposableMap
projectionConfig={{ scale: 140 }}
style={{ width: "100%", height: "300px" }}
>
<Geographies geography={GEO_URL}>
{({ geographies }) =>
geographies.map((geo) => {
const name: string = geo.properties.name ?? "";
const entry = regionMap.get(name.toLowerCase());
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={entry ? getColor(entry.percentage) : "rgba(255,255,255,0.05)"}
stroke="rgba(255,255,255,0.1)"
strokeWidth={0.5}
onMouseEnter={(e) => {
if (!entry) return;
setTooltip({
x: e.clientX,
y: e.clientY,
content: `${name}: ${entry.count} tickets (${entry.percentage}%)`,
});
}}
onMouseLeave={() => setTooltip(null)}
style={{
hover: { fill: "#21D4FF", outline: "none" },
pressed: { outline: "none" },
default: { outline: "none" },
}}
/>
);
})
}
</Geographies>
</ComposableMap>

{tooltip && (
<div
className="pointer-events-none fixed z-50 rounded bg-[#1a2040] border border-[#4D21FF] px-3 py-2 text-xs text-[#21D4FF] shadow-lg"
style={{ left: tooltip.x + 12, top: tooltip.y - 28 }}
>
{tooltip.content}
</div>
)}
</div>
);
}
37 changes: 37 additions & 0 deletions src/components/dashboard/LiveCheckInCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { useCheckInCounter } from "@/hooks/useCheckInCounter";

interface Props {
eventId: string;
eventName: string;
isLive: boolean;
}

export function LiveCheckInCard({ eventId, eventName, isLive }: Props) {
const { checkInCount } = useCheckInCounter(eventId, isLive);

if (!isLive) {
return (
<p className="text-sm text-[#21D4FF]">
No active events — check-in counter paused
</p>
);
}

return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{/* Pulsing green dot */}
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
</span>
<span className="text-sm font-semibold text-emerald-400">
🚪 Live Check-ins: {checkInCount ?? "…"}
</span>
</div>
<p className="text-xs text-[#21D4FF] pl-4">{eventName}</p>
</div>
);
}
Loading
Loading