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
7 changes: 6 additions & 1 deletion src/app/(protected)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div className="h-[220px] animate-pulse rounded-xl bg-white/5" /> }
)
import { DemographicsSection } from '@/components/dashboard/DemographicsSection'
import { useOrganizerAnalytics } from '@/hooks/useOrganizerAnalytics'
import { exportAnalyticsCsv } from '@/lib/exportAnalyticsCsv'
Expand Down
4 changes: 4 additions & 0 deletions src/app/(public)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +26,9 @@ const LandingFooter = dynamic(
{ ssr: false, loading: () => <div className="bg-[#050a1f] py-16 h-48 animate-pulse" /> }
);

// 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 = {
Expand Down
47 changes: 39 additions & 8 deletions src/components/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof loginSchema>;
Expand All @@ -30,14 +32,16 @@ export default function LoginForm() {
handleSubmit,
formState: { isSubmitting, errors },
control,
register,
setError,
} = useForm({
} = useForm<FormValues>({
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");
Expand Down Expand Up @@ -79,7 +83,7 @@ export default function LoginForm() {
>
<motion.h2
className="text-3xl md:text-4xl text-center font-bold text-gray-900 mb-8"
variants={headerVariants}
variants={itemVariants}
>
Welcome Back
</motion.h2>
Expand Down Expand Up @@ -112,12 +116,20 @@ export default function LoginForm() {
/>
</motion.div>

<motion.div variants={itemVariants} className="flex items-center gap-2">
<input
id="rememberMe"
type="checkbox"
{...register("rememberMe")}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="rememberMe" className="text-sm text-gray-700 select-none cursor-pointer">
Remember me
</label>
</motion.div>

<motion.div variants={itemVariants}>
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} transition={{ duration: 0.2 }}>
{errors.root && (
<p role="alert" className="mb-3 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">
{errors.root.message}
Expand Down Expand Up @@ -160,6 +172,25 @@ export default function LoginForm() {
Sign Up
</Link>
</motion.p>

{process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "true" && (
<motion.div variants={itemVariants} className="mt-4">
<div className="flex items-center gap-4 my-4">
<div className="flex-1 h-px bg-gray-300" />
<span className="text-sm text-gray-500">or</span>
<div className="flex-1 h-px bg-gray-300" />
</div>
<Button
type="button"
variant="outline"
className="w-full py-3 flex items-center justify-center gap-2"
onClick={() => { window.location.href = "/api/auth/google"; }}
>
<FcGoogle size={20} />
<span className="text-sm font-medium">Continue with Google</span>
</Button>
</motion.div>
)}
</motion.div>
);
}
3 changes: 2 additions & 1 deletion src/components/auth/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
if (result?.errors && typeof result.errors === "object") {
Object.entries(result.errors).forEach(([field, message]) => {
// react-hook-form field error injection
// @ts-ignore

Check failure on line 60 in src/components/auth/signup-form.tsx

View workflow job for this annotation

GitHub Actions / build-and-check (18.x)

Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free
setError(field as keyof FormValues, {
type: "server",
message: String(message),
Expand All @@ -69,10 +69,11 @@
}

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) {

Check failure on line 76 in src/components/auth/signup-form.tsx

View workflow job for this annotation

GitHub Actions / build-and-check (18.x)

Unexpected any. Specify a different type
toast.error(error.message || "Something went wrong");
}
};
Expand All @@ -83,7 +84,7 @@
try {
// Redirect to Google OAuth flow
window.location.href = "/api/auth/google";
} catch (error: any) {

Check failure on line 87 in src/components/auth/signup-form.tsx

View workflow job for this annotation

GitHub Actions / build-and-check (18.x)

Unexpected any. Specify a different type
toast.error("Google sign-up failed. Please try again or use email registration.");
}
};
Expand All @@ -92,7 +93,7 @@
try {
// Redirect to wallet connection flow
window.location.href = "/api/auth/wallet";
} catch (error: any) {

Check failure on line 96 in src/components/auth/signup-form.tsx

View workflow job for this annotation

GitHub Actions / build-and-check (18.x)

Unexpected any. Specify a different type
toast.error("Wallet sign-up failed. Please ensure your wallet is connected and try again.");
}
};
Expand Down
38 changes: 38 additions & 0 deletions src/components/shared/Breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav aria-label="Breadcrumb" className={className}>
<ol className="flex flex-wrap items-center gap-1 text-sm text-white/60">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} className="flex items-center gap-1">
{index > 0 && <ChevronRight size={14} aria-hidden="true" className="text-white/30" />}
{isLast || !item.href ? (
<span aria-current={isLast ? "page" : undefined} className={isLast ? "text-white font-medium" : ""}>
{item.label}
</span>
) : (
<Link href={item.href} className="hover:text-white transition-colors">
{item.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}
89 changes: 89 additions & 0 deletions src/components/shared/MobileNavDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(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 (
<>
<div
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
aria-hidden="true"
onClick={onClose}
/>
<nav
role="dialog"
aria-modal="true"
aria-label="Mobile navigation"
className="fixed inset-y-0 left-0 z-50 w-72 bg-[#0a0f24] border-r border-white/10 flex flex-col p-6 lg:hidden"
>
<div className="flex items-center justify-between mb-8">
<span className="text-xl font-semibold text-white">VeriTix</span>
<button
ref={closeRef}
type="button"
onClick={onClose}
aria-label="Close navigation"
className="rounded-lg p-2 text-white/60 hover:text-white hover:bg-white/10 transition"
>
<X size={20} />
</button>
</div>

<ul className="space-y-1 flex-1">
{navLinks.map(({ href, label }) => (
<li key={href}>
<Link
href={href}
onClick={onClose}
className={`flex items-center rounded-lg px-4 py-3 text-sm font-medium transition-colors ${
pathname === href
? "bg-[#4d21ff]/20 text-white"
: "text-white/70 hover:bg-white/5 hover:text-white"
}`}
>
{label}
</Link>
</li>
))}
</ul>
</nav>
</>
);
}
40 changes: 40 additions & 0 deletions src/components/ui/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav aria-label="Pagination" className={`flex items-center justify-center gap-2 ${className}`}>
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
className="rounded-md border border-white/15 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
Previous
</button>

<span className="text-sm text-white/70" aria-current="page">
Page {page} of {totalPages}
</span>

<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Next page"
className="rounded-md border border-white/15 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
Next
</button>
</nav>
);
}
33 changes: 29 additions & 4 deletions src/components/ui/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,35 @@ interface SkeletonProps {
}

export function Skeleton({ className }: SkeletonProps) {
return <div className={cn("animate-pulse rounded bg-white/10", className)} />;
}

export function SkeletonCard({ className }: SkeletonProps) {
return (
<div className={cn("rounded-xl border border-white/10 bg-white/5 p-6 space-y-3 animate-pulse", className)}>
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<div className="flex gap-3 pt-2">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
</div>
</div>
);
}

export function SkeletonList({ count = 3, className }: SkeletonProps & { count?: number }) {
return (
<div
className={cn("animate-pulse rounded-xl bg-white/10", className)}
aria-hidden="true"
/>
<div className={cn("space-y-4", className)}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 p-4 animate-pulse">
<Skeleton className="h-12 w-12 rounded-lg shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
);
}
12 changes: 10 additions & 2 deletions src/features/verification/page.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading