diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx
index f57a03c..687b419 100644
--- a/src/app/(protected)/dashboard/page.tsx
+++ b/src/app/(protected)/dashboard/page.tsx
@@ -12,7 +12,12 @@ import { EventImage } from '@/components/dashboard/EventImage'
import { RevenueChart } from '@/components/dashboard/charts/RevenueChart'
import { PerformanceChart } from '@/components/dashboard/charts/PerformanceChart'
import { EmptyState } from '@/components/EmptyState'
-import { TicketTypeChart } from '@/components/dashboard/charts/TicketTypeChart'
+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: () =>
}
+)
import { DemographicsSection } from '@/components/dashboard/DemographicsSection'
import { useOrganizerAnalytics } from '@/hooks/useOrganizerAnalytics'
import { exportAnalyticsCsv } from '@/lib/exportAnalyticsCsv'
diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx
index 24b5a25..c766f37 100644
--- a/src/app/(public)/page.tsx
+++ b/src/app/(public)/page.tsx
@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import dynamic from "next/dynamic";
import { motion } from "framer-motion";
+import dynamic from "next/dynamic";
import {
Calendar,
Clock,
@@ -25,6 +26,9 @@ const LandingFooter = dynamic(
{ ssr: false, loading: () => }
);
+// Lazy-load below-the-fold newsletter form to reduce initial bundle
+const LazyNewsletterForm = dynamic(() => import("@/components/NewsletterForm"), { ssr: false });
+
const MotionLink = motion(Link);
const fadeUp = {
diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx
index a002294..9a8f357 100644
--- a/src/components/auth/login-form.tsx
+++ b/src/components/auth/login-form.tsx
@@ -13,12 +13,14 @@ import { motion } from "framer-motion";
import { containerVariants, itemVariants, headerVariants } from "@/lib/animations/motionVariants";
import { loginUser } from "@/lib/auth";
import { useRouter, useSearchParams } from "next/navigation";
+import { FcGoogle } from "react-icons/fc";
const loginSchema = z.object({
email: z.email("Please enter a valid email address"),
password: z
.string("Please enter your password")
.min(6, "Password must be at least 6 characters"),
+ rememberMe: z.boolean().optional(),
});
type FormValues = z.infer;
@@ -30,14 +32,16 @@ export default function LoginForm() {
handleSubmit,
formState: { isSubmitting, errors },
control,
+ register,
setError,
- } = useForm({
+ } = useForm({
resolver: zodResolver(loginSchema),
+ defaultValues: { rememberMe: false },
});
const onSubmit = async (data: FormValues) => {
try {
- await loginUser({ email: data.email, password: data.password });
+ await loginUser({ email: data.email, password: data.password, rememberMe: data.rememberMe });
toast.success("Login successful!");
const next = searchParams.get("next");
router.push(next && next.startsWith("/") ? next : "/dashboard");
@@ -79,7 +83,7 @@ export default function LoginForm() {
>
Welcome Back
@@ -112,12 +116,20 @@ export default function LoginForm() {
/>
+
+
+
+
+
-
+
{errors.root && (
{errors.root.message}
@@ -160,6 +172,25 @@ export default function LoginForm() {
Sign Up
+
+ {process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "true" && (
+
+
+
+
+ )}
);
}
diff --git a/src/components/auth/signup-form.tsx b/src/components/auth/signup-form.tsx
index 75f1c02..d7e9e64 100644
--- a/src/components/auth/signup-form.tsx
+++ b/src/components/auth/signup-form.tsx
@@ -69,7 +69,8 @@ export default function SignUpForm() {
}
toast.success("Account created successfully");
-
+ // Show email verification prompt
+ toast.info("Please check your email to verify your account before signing in.", { autoClose: 8000 });
// router.push("/login");
} catch (error: any) {
diff --git a/src/components/shared/Breadcrumb.tsx b/src/components/shared/Breadcrumb.tsx
new file mode 100644
index 0000000..1140ad6
--- /dev/null
+++ b/src/components/shared/Breadcrumb.tsx
@@ -0,0 +1,38 @@
+import Link from "next/link";
+import { ChevronRight } from "lucide-react";
+
+export interface BreadcrumbItem {
+ label: string;
+ href?: string;
+}
+
+interface BreadcrumbProps {
+ items: BreadcrumbItem[];
+ className?: string;
+}
+
+export function Breadcrumb({ items, className = "" }: BreadcrumbProps) {
+ return (
+
+ );
+}
diff --git a/src/components/shared/MobileNavDrawer.tsx b/src/components/shared/MobileNavDrawer.tsx
new file mode 100644
index 0000000..e1a3dfa
--- /dev/null
+++ b/src/components/shared/MobileNavDrawer.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import Link from "next/link";
+import { X } from "lucide-react";
+import { usePathname } from "next/navigation";
+
+interface MobileNavDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+const navLinks = [
+ { href: "/dashboard", label: "Dashboard" },
+ { href: "/events/manage", label: "My Events" },
+ { href: "/events/create", label: "Create Event" },
+ { href: "/verify", label: "Verify Tickets" },
+ { href: "/tickets", label: "My Tickets" },
+];
+
+export function MobileNavDrawer({ isOpen, onClose }: MobileNavDrawerProps) {
+ const pathname = usePathname();
+ const closeRef = useRef(null);
+
+ useEffect(() => {
+ if (isOpen) closeRef.current?.focus();
+ }, [isOpen]);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
+ if (isOpen) document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [isOpen, onClose]);
+
+ // Prevent body scroll when open
+ useEffect(() => {
+ document.body.style.overflow = isOpen ? "hidden" : "";
+ return () => { document.body.style.overflow = ""; };
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/ui/Pagination.tsx b/src/components/ui/Pagination.tsx
new file mode 100644
index 0000000..13c50e8
--- /dev/null
+++ b/src/components/ui/Pagination.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+
+interface PaginationProps {
+ page: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ className?: string;
+}
+
+export function Pagination({ page, totalPages, onPageChange, className = "" }: PaginationProps) {
+ if (totalPages <= 1) return null;
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx
index e519b40..b17fd68 100644
--- a/src/components/ui/Skeleton.tsx
+++ b/src/components/ui/Skeleton.tsx
@@ -5,10 +5,35 @@ interface SkeletonProps {
}
export function Skeleton({ className }: SkeletonProps) {
+ return ;
+}
+
+export function SkeletonCard({ className }: SkeletonProps) {
+ return (
+
+ );
+}
+
+export function SkeletonList({ count = 3, className }: SkeletonProps & { count?: number }) {
return (
-
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
);
}
diff --git a/src/features/verification/page.tsx b/src/features/verification/page.tsx
index 5f2d21c..700e394 100644
--- a/src/features/verification/page.tsx
+++ b/src/features/verification/page.tsx
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
+import { classifyVerificationError } from '@/lib/verificationErrors';
type ScanResult = { status: 'valid' | 'invalid' | 'used'; message: string } | null;
type CsvSummary = { succeeded: number; failed: number; errors: string[] } | null;
@@ -33,9 +34,16 @@ export default function VerificationPage() {
try {
const res = await fetch(`/api/verify/${encodeURIComponent(ticketId.trim())}`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
- setResult({ status: data.status ?? (res.ok ? 'valid' : 'invalid'), message: data.message ?? (res.ok ? 'Ticket is valid.' : 'Invalid ticket.') });
+ if (res.ok) {
+ setResult({ status: data.status ?? 'valid', message: data.message ?? 'Ticket is valid.' });
+ } else {
+ const err = classifyVerificationError(res.status, data.code);
+ const status = err.type === 'already-used' ? 'used' : 'invalid';
+ setResult({ status, message: err.message });
+ }
} catch {
- setResult({ status: 'invalid', message: 'Network error. Please try again.' });
+ const err = classifyVerificationError(null);
+ setResult({ status: 'invalid', message: err.message });
} finally {
setLoading(false);
}
diff --git a/src/lib/animations/motionVariants.ts b/src/lib/animations/motionVariants.ts
index d4fcd8a..3969925 100644
--- a/src/lib/animations/motionVariants.ts
+++ b/src/lib/animations/motionVariants.ts
@@ -10,6 +10,38 @@ export const fadeIn: Variants = {
visible: { opacity: 1 },
};
+export const fadeInDown: Variants = {
+ hidden: { opacity: 0, y: -20 },
+ visible: { opacity: 1, y: 0 },
+};
+
+export const scaleIn: Variants = {
+ hidden: { opacity: 0, scale: 0.95 },
+ visible: { opacity: 1, scale: 1 },
+};
+
+export const slideInLeft: Variants = {
+ hidden: { opacity: 0, x: -40 },
+ visible: { opacity: 1, x: 0 },
+};
+
+export const slideInRight: Variants = {
+ hidden: { opacity: 0, x: 40 },
+ visible: { opacity: 1, x: 0 },
+};
+
+export const staggerContainer: Variants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { staggerChildren: 0.1 },
+ },
+};
+
+export const staggerItem: Variants = {
+ hidden: { opacity: 0, y: 20 },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.4 } },
+};
/** Stagger container — fades in and staggers children by 0.12s */
export const containerVariants: Variants = {
hidden: { opacity: 0 },
diff --git a/src/middleware.ts b/src/middleware.ts
index a042445..70e2186 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,17 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
+import { canAccessVerificationTools } from "@/lib/verificationAccess";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
- // Only guard routes under the (protected) route group.
- // Next.js strips the group segment from the URL, so we match the actual
- // path segments that live inside (protected): /dashboard, /events/*, /tickets, /verify
const isProtected =
pathname.startsWith("/dashboard") ||
pathname.startsWith("/tickets") ||
pathname.startsWith("/verify") ||
- // /events under protected — distinguish from public /events by checking
- // whether the request is for the organiser sub-paths
pathname.startsWith("/events/create") ||
pathname.startsWith("/events/manage");
@@ -25,6 +21,19 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl);
}
+ // Role-based guard for /verify — only staff, organizer, or admin may access
+ if (pathname.startsWith("/verify")) {
+ const roleCookie = request.cookies.get("user_role")?.value as
+ | "attendee"
+ | "organizer"
+ | "staff"
+ | "admin"
+ | null;
+ if (!canAccessVerificationTools(roleCookie ?? null)) {
+ return NextResponse.redirect(new URL("/dashboard", request.url));
+ }
+ }
+
return NextResponse.next();
}
diff --git a/tailwind.config.js b/tailwind.config.js
index d5ff351..1f42d79 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
+ darkMode: "class",
content: [
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",