first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:52:58 +05:30
commit 34ea15a54d
246 changed files with 18660 additions and 0 deletions

6
frontend/.env.example Normal file
View file

@ -0,0 +1,6 @@
# Leave empty in development to use Vite proxy (/api → localhost:8000).
# For production builds, set the public API origin (no trailing slash).
VITE_API_URL=
# Optional: gate scanner key for admin verify UI (must match backend TICKET_VERIFY_API_KEY)
# VITE_TICKET_VERIFY_API_KEY=

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

40
frontend/README.md Normal file
View file

@ -0,0 +1,40 @@
# Desi Clans — Frontend
React + Vite foundation for the public site and (later) admin UI.
## Stack
- React (JSX), Vite, Tailwind CSS v3, React Router, Axios, GSAP, Lucide React
## Setup
```bash
cd frontend
cp .env.example .env # optional; empty VITE_API_URL uses dev proxy
npm install
npm run dev
```
App: [http://localhost:5173](http://localhost:5173)
## Scripts
| Command | Description |
|---------|-------------|
| `npm run dev` | Development server with HMR |
| `npm run build` | Production build to `dist/` |
| `npm run preview` | Preview production build |
| `npm run lint` | Oxlint |
## Structure
See [../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for the target `src/` layout.
## Design system
Reusable primitives live in `src/components/` (barrel: `src/components/index.js`).
- Home (mock marketing): [http://localhost:5173/](http://localhost:5173/)
- Event detail (mock): [http://localhost:5173/events/rang-barse-palace-grounds-2026](http://localhost:5173/events/rang-barse-palace-grounds-2026)
- Design system catalog: [http://localhost:5173/system](http://localhost:5173/system)

31
frontend/index.html Normal file
View file

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Desi Clans — premium Indian cultural events in Bengaluru: Holi, Garba, Dandiya and more."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://images.unsplash.com" crossorigin />
<link rel="preconnect" href="https://plus.unsplash.com" crossorigin />
<link
rel="preload"
as="image"
href="https://plus.unsplash.com/premium_photo-1697729606469-027395aadb6f?auto=format&amp;fit=crop&amp;w=1600&amp;q=75"
fetchpriority="high"
/>
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;1,9..40,400&family=Fraunces:opsz,wght@9..144,600&display=swap"
rel="stylesheet"
/>
<title>Desi Clans</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4231
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

33
frontend/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"axios": "^1.20.0",
"gsap": "^3.15.0",
"lucide-react": "^1.52.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"three": "^0.180.0"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.6.1",
"oxlint": "^1.81.0",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"vite": "^8.3.0",
"vitest": "^3.2.4"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

47
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,47 @@
import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { ProtectedRoute } from './components/auth/ProtectedRoute'
import { RouteFallback } from './components/common/RouteFallback'
import { AppLayout } from './layouts/AppLayout'
import { MarketingLayout } from './layouts/MarketingLayout'
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
const EventDetailsPage = lazy(() =>
import('./pages/EventDetailsPage').then((m) => ({ default: m.EventDetailsPage })),
)
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
const RegisterPage = lazy(() =>
import('./pages/RegisterPage').then((m) => ({ default: m.RegisterPage })),
)
const AccountPage = lazy(() => import('./pages/AccountPage').then((m) => ({ default: m.AccountPage })))
const TicketPage = lazy(() => import('./pages/TicketPage').then((m) => ({ default: m.TicketPage })))
const DesignSystemPage = lazy(() =>
import('./pages/DesignSystemPage').then((m) => ({ default: m.DesignSystemPage })),
)
const NotFoundPage = lazy(() => import('./pages/NotFoundPage').then((m) => ({ default: m.NotFoundPage })))
const AdminRoutes = lazy(() => import('./admin/AdminRoutes').then((m) => ({ default: m.AdminRoutes })))
export default function App() {
return (
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route element={<MarketingLayout />}>
<Route index element={<HomePage />} />
<Route path="events/:slug" element={<EventDetailsPage />} />
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route path="account" element={<AccountPage />} />
<Route path="tickets/:ticketId" element={<TicketPage />} />
</Route>
</Route>
<Route path="admin/*" element={<AdminRoutes />} />
<Route element={<AppLayout />}>
<Route path="system" element={<DesignSystemPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</Suspense>
)
}

View file

@ -0,0 +1,71 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { adminApi, normalizeAdminError } from './api'
import { clearAdminAccessToken, getAdminAccessToken, setAdminAccessToken } from './token'
const AdminAuthContext = createContext(null)
export function AdminAuthProvider({ children }) {
const [admin, setAdmin] = useState(null)
const [status, setStatus] = useState('loading')
const refresh = useCallback(async () => {
const token = getAdminAccessToken()
if (!token) {
setAdmin(null)
setStatus('anonymous')
return
}
setStatus('loading')
try {
const { data } = await adminApi.get('/api/admin/auth/me')
setAdmin(data)
setStatus('authenticated')
} catch {
clearAdminAccessToken()
setAdmin(null)
setStatus('anonymous')
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
const login = useCallback(async ({ email, password }) => {
try {
const { data } = await adminApi.post('/api/admin/auth/login', { email, password })
setAdminAccessToken(data.access_token)
await refresh()
} catch (error) {
throw normalizeAdminError(error)
}
}, [refresh])
const logout = useCallback(() => {
clearAdminAccessToken()
setAdmin(null)
setStatus('anonymous')
}, [])
const value = useMemo(
() => ({
admin,
status,
isAuthenticated: status === 'authenticated' && Boolean(admin),
isLoading: status === 'loading',
login,
logout,
refresh,
}),
[admin, status, login, logout, refresh],
)
return <AdminAuthContext.Provider value={value}>{children}</AdminAuthContext.Provider>
}
export function useAdminAuth() {
const ctx = useContext(AdminAuthContext)
if (!ctx) throw new Error('useAdminAuth must be used within AdminAuthProvider')
return ctx
}

View file

@ -0,0 +1,19 @@
import { Navigate, Outlet } from 'react-router-dom'
import { Spinner } from '../components/ui/Spinner'
import { useAdminAuth } from './AdminAuthContext'
export function AdminProtectedRoute() {
const { isAuthenticated, isLoading } = useAdminAuth()
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Loading admin session" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />
}
return <Outlet />
}

View file

@ -0,0 +1,36 @@
import { Route, Routes } from 'react-router-dom'
import { AdminAuthProvider } from './AdminAuthContext'
import { AdminProtectedRoute } from './AdminProtectedRoute'
import { AdminLayout } from './components/AdminLayout'
import { AdminDashboardPage } from './pages/AdminDashboardPage'
import { AdminEventsPage } from './pages/AdminEventsPage'
import { AdminLoginPage } from './pages/AdminLoginPage'
import { AdminOrdersPage } from './pages/AdminOrdersPage'
import { AdminPaymentsPage } from './pages/AdminPaymentsPage'
import { AdminStatsPage } from './pages/AdminStatsPage'
import { AdminTicketTypesPage } from './pages/AdminTicketTypesPage'
import { AdminVenuesPage } from './pages/AdminVenuesPage'
import { AdminVerifyPage } from './pages/AdminVerifyPage'
export function AdminRoutes() {
return (
<AdminAuthProvider>
<Routes>
<Route path="login" element={<AdminLoginPage />} />
<Route element={<AdminProtectedRoute />}>
<Route element={<AdminLayout />}>
<Route index element={<AdminDashboardPage />} />
<Route path="events" element={<AdminEventsPage />} />
<Route path="venues" element={<AdminVenuesPage />} />
<Route path="ticket-types" element={<AdminTicketTypesPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="payments" element={<AdminPaymentsPage />} />
<Route path="verify" element={<AdminVerifyPage />} />
<Route path="stats" element={<AdminStatsPage />} />
</Route>
</Route>
</Routes>
</AdminAuthProvider>
)
}

28
frontend/src/admin/api.js Normal file
View file

@ -0,0 +1,28 @@
import axios from 'axios'
import { getAdminAccessToken } from './token'
const baseURL = import.meta.env.VITE_API_URL ?? ''
export const adminApi = axios.create({
baseURL,
headers: { Accept: 'application/json' },
timeout: 20000,
})
adminApi.interceptors.request.use((config) => {
const token = getAdminAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export function normalizeAdminError(error) {
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail
const message = typeof detail === 'string' ? detail : 'Request failed'
return new Error(message)
}
return error instanceof Error ? error : new Error('Request failed')
}

View file

@ -0,0 +1,12 @@
export function AdminField({ label, children, className = '' }) {
return (
<label className={`block text-body-sm ${className}`}>
<span className="text-label uppercase text-ink-muted">{label}</span>
<div className="mt-1">{children}</div>
</label>
)
}
export function adminInputClassName(extra = '') {
return `w-full min-h-10 border border-border bg-surface px-3 text-body focus:border-ink focus:outline-none ${extra}`
}

View file

@ -0,0 +1,54 @@
import { NavLink, Outlet } from 'react-router-dom'
import { useAdminAuth } from '../AdminAuthContext'
import { Button } from '../../components/ui/Button'
const links = [
{ to: '/admin', label: 'Dashboard', end: true },
{ to: '/admin/events', label: 'Events' },
{ to: '/admin/venues', label: 'Venues' },
{ to: '/admin/ticket-types', label: 'Ticket types' },
{ to: '/admin/orders', label: 'Orders' },
{ to: '/admin/payments', label: 'Payments' },
{ to: '/admin/verify', label: 'Verify' },
{ to: '/admin/stats', label: 'Event stats' },
]
export function AdminLayout() {
const { admin, logout } = useAdminAuth()
return (
<div className="min-h-screen bg-surface-muted text-ink">
<header className="border-b border-border bg-surface">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4 md:px-8">
<div>
<p className="font-display text-xl">Desi Clans Admin</p>
<p className="text-body-sm text-ink-muted">{admin?.email} · {admin?.role}</p>
</div>
<Button type="button" variant="secondary" size="sm" onClick={logout}>
Sign out
</Button>
</div>
</header>
<div className="mx-auto grid max-w-7xl gap-8 px-4 py-8 md:grid-cols-[220px_1fr] md:px-8">
<nav className="flex flex-col gap-1 text-body-sm">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.end}
className={({ isActive }) =>
`rounded-sm px-3 py-2 ${isActive ? 'bg-ink text-surface' : 'text-ink-muted hover:bg-surface hover:text-ink'}`
}
>
{link.label}
</NavLink>
))}
</nav>
<main className="min-w-0">
<Outlet />
</main>
</div>
</div>
)
}

View file

@ -0,0 +1,11 @@
export function AdminPageHeader({ title, description, actions }) {
return (
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="font-display text-2xl text-ink">{title}</h1>
{description ? <p className="mt-1 text-body-sm text-ink-muted">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
)
}

View file

@ -0,0 +1,29 @@
export function AdminTable({ columns, rows, emptyMessage = 'No rows' }) {
if (!rows?.length) {
return <p className="text-body-sm text-ink-muted">{emptyMessage}</p>
}
return (
<div className="overflow-x-auto border border-border bg-surface">
<table className="min-w-full text-left text-body-sm">
<thead className="border-b border-border bg-surface-muted">
<tr>
{columns.map((col) => (
<th key={col.key} className="px-4 py-3 font-medium text-ink-muted">{col.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={row.id ?? index} className="border-b border-border last:border-0">
{columns.map((col) => (
<td key={col.key} className="px-4 py-3 align-top">
{col.render ? col.render(row) : row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View file

@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { formatInrFromPaise } from '../../utils/format'
import { adminApi, normalizeAdminError } from '../api'
import { AdminPageHeader } from '../components/AdminPageHeader'
function Metric({ label, value }) {
return (
<div className="border border-border bg-surface p-5">
<p className="text-label uppercase text-ink-muted">{label}</p>
<p className="mt-2 font-display text-2xl">{value}</p>
</div>
)
}
export function AdminDashboardPage() {
const [metrics, setMetrics] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
adminApi
.get('/api/admin/dashboard/metrics')
.then((res) => setMetrics(res.data))
.catch((err) => setError(normalizeAdminError(err).message))
}, [])
return (
<>
<AdminPageHeader title="Dashboard" description="Operational snapshot for Bengaluru editions." />
{error ? <p className="text-accent">{error}</p> : null}
{metrics ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Metric label="Total revenue" value={formatInrFromPaise(metrics.total_revenue_paise)} />
<Metric label="Tickets sold" value={metrics.tickets_sold} />
<Metric label="Tickets remaining" value={metrics.tickets_remaining} />
<Metric label="Orders (paid)" value={`${metrics.orders_paid} / ${metrics.orders_total}`} />
<Metric label="Pending orders" value={metrics.orders_pending} />
<Metric label="Attendance (used)" value={metrics.attendance} />
<Metric label="Upcoming events" value={metrics.upcoming_events} />
</div>
) : null}
</>
)
}

View file

@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import { adminApi, normalizeAdminError } from '../api'
import { AdminField, adminInputClassName } from '../components/AdminField'
import { AdminPageHeader } from '../components/AdminPageHeader'
import { AdminTable } from '../components/AdminTable'
import { Button } from '../../components/ui/Button'
import { formatEventDate } from '../../utils/format'
export function AdminEventsPage() {
const [rows, setRows] = useState([])
const [venues, setVenues] = useState([])
const [error, setError] = useState('')
const [form, setForm] = useState({
slug: '',
title: '',
category: 'garba',
venue_id: '',
starts_at: '',
status: 'draft',
})
function load() {
Promise.all([adminApi.get('/api/admin/events'), adminApi.get('/api/admin/venues')])
.then(([events, v]) => {
setRows(events.data)
setVenues(v.data)
})
.catch((err) => setError(normalizeAdminError(err).message))
}
useEffect(() => {
load()
}, [])
async function createEvent(e) {
e.preventDefault()
setError('')
try {
await adminApi.post('/api/admin/events', {
...form,
venue_id: Number(form.venue_id),
starts_at: new Date(form.starts_at).toISOString(),
is_featured: false,
})
load()
} catch (err) {
setError(normalizeAdminError(err).message)
}
}
return (
<>
<AdminPageHeader title="Events" description="Create and manage catalog editions." />
{error ? <p className="mb-4 text-accent">{error}</p> : null}
<form onSubmit={createEvent} className="mb-8 grid gap-3 border border-border bg-surface p-4 md:grid-cols-2">
<AdminField label="Slug"><input className={adminInputClassName()} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required /></AdminField>
<AdminField label="Title"><input className={adminInputClassName()} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required /></AdminField>
<AdminField label="Category">
<select className={adminInputClassName()} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
<option value="holi">holi</option>
<option value="garba">garba</option>
<option value="dandiya">dandiya</option>
<option value="special">special</option>
</select>
</AdminField>
<AdminField label="Venue">
<select className={adminInputClassName()} value={form.venue_id} onChange={(e) => setForm({ ...form, venue_id: e.target.value })} required>
<option value="">Select venue</option>
{venues.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</AdminField>
<AdminField label="Starts at"><input type="datetime-local" className={adminInputClassName()} value={form.starts_at} onChange={(e) => setForm({ ...form, starts_at: e.target.value })} required /></AdminField>
<AdminField label="Status">
<select className={adminInputClassName()} value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
<option value="draft">draft</option>
<option value="published">published</option>
<option value="cancelled">cancelled</option>
</select>
</AdminField>
<div className="md:col-span-2"><Button type="submit">Add event</Button></div>
</form>
<AdminTable
columns={[
{ key: 'title', label: 'Title' },
{ key: 'slug', label: 'Slug' },
{ key: 'status', label: 'Status' },
{ key: 'starts_at', label: 'Date', render: (r) => formatEventDate(r.starts_at) },
]}
rows={rows}
/>
</>
)
}

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '../../components/ui/Button'
import { useAdminAuth } from '../AdminAuthContext'
import { AdminField, adminInputClassName } from '../components/AdminField'
export function AdminLoginPage() {
const { login, isAuthenticated } = useAdminAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
useEffect(() => {
if (isAuthenticated) navigate('/admin', { replace: true })
}, [isAuthenticated, navigate])
async function onSubmit(e) {
e.preventDefault()
setError('')
try {
await login({ email, password })
navigate('/admin', { replace: true })
} catch (err) {
setError(err.message)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-surface-muted px-4">
<form onSubmit={onSubmit} className="w-full max-w-md border border-border bg-surface p-8 shadow-editorial">
<h1 className="font-display text-2xl">Admin sign in</h1>
<p className="mt-2 text-body-sm text-ink-muted">Staff access only — separate from attendee accounts.</p>
<div className="mt-6 space-y-4">
<AdminField label="Email">
<input type="email" required className={adminInputClassName()} value={email} onChange={(e) => setEmail(e.target.value)} />
</AdminField>
<AdminField label="Password">
<input type="password" required className={adminInputClassName()} value={password} onChange={(e) => setPassword(e.target.value)} />
</AdminField>
{error ? <p className="text-body-sm text-accent" role="alert">{error}</p> : null}
<Button type="submit" className="w-full">Sign in</Button>
</div>
</form>
</div>
)
}

View file

@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { formatInrFromPaise, formatEventDate } from '../../utils/format'
import { adminApi, normalizeAdminError } from '../api'
import { AdminPageHeader } from '../components/AdminPageHeader'
import { AdminTable } from '../components/AdminTable'
import { adminInputClassName } from '../components/AdminField'
export function AdminOrdersPage() {
const [rows, setRows] = useState([])
const [status, setStatus] = useState('')
const [error, setError] = useState('')
useEffect(() => {
const params = status ? { status } : {}
adminApi
.get('/api/admin/orders', { params })
.then((res) => setRows(res.data))
.catch((err) => setError(normalizeAdminError(err).message))
}, [status])
return (
<>
<AdminPageHeader title="Orders" description="All checkout orders." />
<div className="mb-4 max-w-xs">
<select className={adminInputClassName()} value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
<option value="paid">paid</option>
<option value="pending">pending</option>
<option value="cancelled">cancelled</option>
<option value="failed">failed</option>
</select>
</div>
{error ? <p className="text-accent">{error}</p> : null}
<AdminTable
columns={[
{ key: 'id', label: 'ID' },
{ key: 'event_id', label: 'Event' },
{ key: 'status', label: 'Status' },
{ key: 'total_paise', label: 'Total', render: (r) => formatInrFromPaise(r.total_paise) },
{ key: 'created_at', label: 'Created', render: (r) => formatEventDate(r.created_at) },
]}
rows={rows}
/>
</>
)
}

View file

@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
import { formatInrFromPaise, formatEventDate } from '../../utils/format'
import { adminApi, normalizeAdminError } from '../api'
import { AdminPageHeader } from '../components/AdminPageHeader'
import { AdminTable } from '../components/AdminTable'
export function AdminPaymentsPage() {
const [rows, setRows] = useState([])
const [error, setError] = useState('')
useEffect(() => {
adminApi.get('/api/admin/payments').then((res) => setRows(res.data)).catch((err) => setError(normalizeAdminError(err).message))
}, [])
return (
<>
<AdminPageHeader title="Payments" description="Provider references tied to orders." />
{error ? <p className="text-accent">{error}</p> : null}
<AdminTable
columns={[
{ key: 'order_id', label: 'Order' },
{ key: 'status', label: 'Status' },
{ key: 'total_paise', label: 'Amount', render: (r) => formatInrFromPaise(r.total_paise) },
{ key: 'provider_payment_id', label: 'Payment ID' },
{ key: 'paid_at', label: 'Paid', render: (r) => (r.paid_at ? formatEventDate(r.paid_at) : '—') },
]}
rows={rows}
/>
</>
)
}

View file

@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import { formatInrFromPaise, formatEventDate } from '../../utils/format'
import { adminApi, normalizeAdminError } from '../api'
import { AdminPageHeader } from '../components/AdminPageHeader'
import { AdminTable } from '../components/AdminTable'
export function AdminStatsPage() {
const [rows, setRows] = useState([])
const [error, setError] = useState('')
useEffect(() => {
adminApi.get('/api/admin/stats/events').then((res) => setRows(res.data)).catch((err) => setError(normalizeAdminError(err).message))
}, [])
return (
<>
<AdminPageHeader title="Event statistics" description="Per-edition sales and attendance." />
{error ? <p className="text-accent">{error}</p> : null}
<AdminTable
columns={[
{ key: 'event_title', label: 'Event' },
{ key: 'starts_at', label: 'Date', render: (r) => formatEventDate(r.starts_at) },
{ key: 'tickets_sold', label: 'Sold' },
{ key: 'tickets_remaining', label: 'Remaining' },
{ key: 'revenue_paise', label: 'Revenue', render: (r) => formatInrFromPaise(r.revenue_paise) },
{ key: 'attendance', label: 'Attendance' },
]}
rows={rows}
/>
</>
)
}

View file

@ -0,0 +1,67 @@
import { useEffect, useState } from 'react'
import { Button } from '../../components/ui/Button'
import { formatInrFromPaise } from '../../utils/format'
import { adminApi, normalizeAdminError } from '../api'
import { AdminField, adminInputClassName } from '../components/AdminField'
import { AdminPageHeader } from '../components/AdminPageHeader'
import { AdminTable } from '../components/AdminTable'
export function AdminTicketTypesPage() {
const [rows, setRows] = useState([])
const [events, setEvents] = useState([])
const [error, setError] = useState('')
const [form, setForm] = useState({ event_id: '', name: '', price_paise: '', quantity_total: '' })
function load() {
Promise.all([adminApi.get('/api/admin/ticket-types'), adminApi.get('/api/admin/events')])
.then(([tiers, ev]) => { setRows(tiers.data); setEvents(ev.data) })
.catch((err) => setError(normalizeAdminError(err).message))
}
useEffect(() => { load() }, [])
async function onSubmit(e) {
e.preventDefault()
try {
await adminApi.post('/api/admin/ticket-types', {
event_id: Number(form.event_id),
name: form.name,
price_paise: Number(form.price_paise),
quantity_total: Number(form.quantity_total),
})
load()
} catch (err) {
setError(normalizeAdminError(err).message)
}
}
return (
<>
<AdminPageHeader title="Ticket types & inventory" description="Pricing and capacity per event." />
{error ? <p className="mb-4 text-accent">{error}</p> : null}
<form onSubmit={onSubmit} className="mb-8 grid gap-3 border border-border bg-surface p-4 md:grid-cols-2">
<AdminField label="Event">
<select className={adminInputClassName()} value={form.event_id} onChange={(e) => setForm({ ...form, event_id: e.target.value })} required>
<option value="">Select event</option>
{events.map((ev) => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
</AdminField>
<AdminField label="Name"><input className={adminInputClassName()} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></AdminField>
<AdminField label="Price (paise)"><input type="number" className={adminInputClassName()} value={form.price_paise} onChange={(e) => setForm({ ...form, price_paise: e.target.value })} required /></AdminField>
<AdminField label="Quantity total"><input type="number" className={adminInputClassName()} value={form.quantity_total} onChange={(e) => setForm({ ...form, quantity_total: e.target.value })} required /></AdminField>
<div className="md:col-span-2"><Button type="submit">Add ticket type</Button></div>
</form>
<AdminTable
columns={[
{ key: 'name', label: 'Tier' },
{ key: 'event_id', label: 'Event ID' },
{ key: 'price_paise', label: 'Price', render: (r) => formatInrFromPaise(r.price_paise) },
{ key: 'remaining', label: 'Remaining', render: (r) => `${r.remaining} / ${r.quantity_total}` },
{ key: 'status', label: 'Status' },
]}
rows={rows}
/>
</>
)
}

View file

@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { Button } from '../../components/ui/Button'
import { adminApi, normalizeAdminError } from '../api'
import { AdminField, adminInputClassName } from '../components/AdminField'
import { AdminPageHeader } from '../components/AdminPageHeader'
import { AdminTable } from '../components/AdminTable'
export function AdminVenuesPage() {
const [rows, setRows] = useState([])
const [error, setError] = useState('')
const [form, setForm] = useState({ slug: '', name: '', area: '', address: '', city: 'Bengaluru' })
function load() {
adminApi.get('/api/admin/venues').then((res) => setRows(res.data)).catch((err) => setError(normalizeAdminError(err).message))
}
useEffect(() => { load() }, [])
async function onSubmit(e) {
e.preventDefault()
try {
await adminApi.post('/api/admin/venues', form)
setForm({ slug: '', name: '', area: '', address: '', city: 'Bengaluru' })
load()
} catch (err) {
setError(normalizeAdminError(err).message)
}
}
return (
<>
<AdminPageHeader title="Venues" description="Bengaluru venue profiles." />
{error ? <p className="mb-4 text-accent">{error}</p> : null}
<form onSubmit={onSubmit} className="mb-8 grid gap-3 border border-border bg-surface p-4 md:grid-cols-2">
<AdminField label="Slug"><input className={adminInputClassName()} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required /></AdminField>
<AdminField label="Name"><input className={adminInputClassName()} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></AdminField>
<AdminField label="Area"><input className={adminInputClassName()} value={form.area} onChange={(e) => setForm({ ...form, area: e.target.value })} required /></AdminField>
<AdminField label="Address"><input className={adminInputClassName()} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} required /></AdminField>
<div className="md:col-span-2"><Button type="submit">Add venue</Button></div>
</form>
<AdminTable columns={[{ key: 'name', label: 'Name' }, { key: 'area', label: 'Area' }, { key: 'slug', label: 'Slug' }]} rows={rows} />
</>
)
}

View file

@ -0,0 +1,48 @@
import { useState } from 'react'
import { Button } from '../../components/ui/Button'
import { adminApi, normalizeAdminError } from '../api'
import { AdminField, adminInputClassName } from '../components/AdminField'
import { AdminPageHeader } from '../components/AdminPageHeader'
export function AdminVerifyPage() {
const [qrToken, setQrToken] = useState('')
const [result, setResult] = useState(null)
const [error, setError] = useState('')
async function onVerify(e) {
e.preventDefault()
setError('')
setResult(null)
try {
const checkInKey = import.meta.env.VITE_TICKET_VERIFY_API_KEY
const { data } = await adminApi.post(
'/api/tickets/verify',
{ qr_token: qrToken.trim() },
checkInKey ? { headers: { 'X-Check-In-Key': checkInKey } } : undefined,
)
setResult(data)
} catch (err) {
setError(normalizeAdminError(err).message)
}
}
return (
<>
<AdminPageHeader title="Ticket verification" description="Scan or paste QR token — validated on the server." />
<form onSubmit={onVerify} className="max-w-xl space-y-4 border border-border bg-surface p-6">
<AdminField label="QR token">
<textarea className={adminInputClassName('min-h-24')} value={qrToken} onChange={(e) => setQrToken(e.target.value)} required />
</AdminField>
<Button type="submit">Verify entry</Button>
</form>
{error ? <p className="mt-4 text-accent">{error}</p> : null}
{result ? (
<div className={`mt-6 border p-4 ${result.valid ? 'border-green-700 bg-green-50' : 'border-accent bg-surface-muted'}`}>
<p className="font-display text-lg">{result.message}</p>
<p className="mt-2 text-body-sm">Ticket {result.ticket_code} · {result.event_title}</p>
</div>
) : null}
</>
)
}

View file

@ -0,0 +1,26 @@
const KEY = 'desiclans_admin_token'
let memory = null
export function getAdminAccessToken() {
if (memory) return memory
try {
return localStorage.getItem(KEY)
} catch {
return null
}
}
export function setAdminAccessToken(token) {
memory = token || null
try {
if (token) localStorage.setItem(KEY, token)
else localStorage.removeItem(KEY)
} catch {
// ignore
}
}
export function clearAdminAccessToken() {
setAdminAccessToken(null)
}

View file

@ -0,0 +1,3 @@
/** @deprecated Import `loadGsap` or `prefersReducedMotion` from dedicated modules. */
export { loadGsap } from './loadGsap'
export { prefersReducedMotion } from '../utils/motion'

View file

@ -0,0 +1,22 @@
import { prefersReducedMotion } from '../utils/motion'
let gsapPromise
/**
* Loads GSAP + ScrollTrigger on demand (separate chunk from initial route).
*/
export function loadGsap() {
if (prefersReducedMotion()) {
return Promise.resolve(null)
}
if (!gsapPromise) {
gsapPromise = Promise.all([import('gsap'), import('gsap/ScrollTrigger')]).then(
([gsapModule, scrollTriggerModule]) => {
const gsap = gsapModule.default
gsap.registerPlugin(scrollTriggerModule.ScrollTrigger)
return gsap
},
)
}
return gsapPromise
}

View file

@ -0,0 +1,96 @@
import { useEffect } from 'react'
import { loadGsap } from './loadGsap'
import { prefersReducedMotion } from '../utils/motion'
/**
* Cinematic but restrained motion for event detail pages.
*/
export function useEventDetailAnimations({ heroImageRef, enabled = true }) {
useEffect(() => {
if (!enabled || prefersReducedMotion()) {
return undefined
}
let cancelled = false
const triggers = []
loadGsap().then((gsap) => {
if (cancelled || !gsap) {
return
}
const heroImage = heroImageRef?.current
if (heroImage) {
gsap.fromTo(
heroImage,
{ scale: 1.05 },
{
scale: 1,
duration: 1.05,
ease: 'power2.out',
},
)
const parallax = gsap.to(heroImage, {
yPercent: 8,
ease: 'none',
scrollTrigger: {
trigger: heroImage.closest('section'),
start: 'top top',
end: 'bottom top',
scrub: 0.45,
},
})
if (parallax.scrollTrigger) {
triggers.push(parallax.scrollTrigger)
}
}
const revealItems = document.querySelectorAll('[data-event-reveal]')
if (revealItems.length) {
gsap.set(revealItems, { opacity: 0, y: 18 })
const tween = gsap.to(revealItems, {
opacity: 1,
y: 0,
duration: 0.65,
stagger: 0.08,
ease: 'power2.out',
scrollTrigger: {
trigger: revealItems[0],
start: 'top 92%',
toggleActions: 'play none none none',
},
})
if (tween.scrollTrigger) {
triggers.push(tween.scrollTrigger)
}
}
const ticketPanel = document.querySelector('[data-event-tickets]')
if (ticketPanel) {
gsap.set(ticketPanel, { opacity: 0, y: 24 })
const tween = gsap.to(ticketPanel, {
opacity: 1,
y: 0,
duration: 0.8,
ease: 'power2.out',
delay: 0.05,
scrollTrigger: {
trigger: ticketPanel,
start: 'top 90%',
toggleActions: 'play none none none',
},
})
if (tween.scrollTrigger) {
triggers.push(tween.scrollTrigger)
}
}
})
return () => {
cancelled = true
triggers.forEach((trigger) => trigger.kill())
}
}, [heroImageRef, enabled])
}

View file

@ -0,0 +1,95 @@
import { useEffect } from 'react'
import { loadGsap } from './loadGsap'
import { prefersReducedMotion } from '../utils/motion'
function attachReveal(gsap, element, triggers) {
gsap.set(element, { opacity: 0, y: 20 })
const tween = gsap.to(element, {
opacity: 1,
y: 0,
duration: 0.75,
ease: 'power2.out',
scrollTrigger: {
trigger: element,
start: 'top 90%',
toggleActions: 'play none none none',
},
})
if (tween.scrollTrigger) {
triggers.push(tween.scrollTrigger)
}
}
/**
* Subtle scroll reveals and hero parallax for the marketing homepage.
* @param {{ heroImageRef: React.RefObject<HTMLElement | null> }} options
*/
export function useHomeScrollAnimations({ heroImageRef }) {
useEffect(() => {
if (prefersReducedMotion()) {
return undefined
}
let cancelled = false
const triggers = []
loadGsap().then((gsap) => {
if (cancelled || !gsap) {
return
}
document.querySelectorAll('[data-reveal-group]').forEach((group) => {
const children = group.querySelectorAll('[data-reveal-item]')
if (!children.length) {
return
}
gsap.set(children, { opacity: 0, y: 22 })
const tween = gsap.to(children, {
opacity: 1,
y: 0,
duration: 0.7,
stagger: 0.1,
ease: 'power2.out',
scrollTrigger: {
trigger: group,
start: 'top 88%',
toggleActions: 'play none none none',
},
})
if (tween.scrollTrigger) {
triggers.push(tween.scrollTrigger)
}
})
document.querySelectorAll('[data-reveal]').forEach((element) => {
if (element.closest('[data-reveal-group]')) {
return
}
attachReveal(gsap, element, triggers)
})
const heroImage = heroImageRef?.current
if (heroImage) {
const parallax = gsap.to(heroImage, {
yPercent: 10,
ease: 'none',
scrollTrigger: {
trigger: heroImage.closest('section'),
start: 'top top',
end: 'bottom top',
scrub: 0.5,
},
})
if (parallax.scrollTrigger) {
triggers.push(parallax.scrollTrigger)
}
}
})
return () => {
cancelled = true
triggers.forEach((trigger) => trigger.kill())
}
}, [heroImageRef])
}

View file

@ -0,0 +1 @@
export { apiClient, fetchHealth, ApiError, normalizeApiError } from '../services/api'

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,19 @@
import { Container } from '../ui/Container'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
export function AuthFormLayout({ title, description, children }) {
return (
<Container className="py-section md:py-section-lg">
<div className="mx-auto max-w-md border border-border bg-surface-elevated px-6 py-10 md:px-10 md:py-12">
<Eyebrow>Account</Eyebrow>
<Heading level={1} className="mt-3 text-display-lg">{title}</Heading>
{description ? (
<Text tone="muted" className="mt-3">{description}</Text>
) : null}
<div className="mt-8">{children}</div>
</div>
</Container>
)
}

View file

@ -0,0 +1,24 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '../../context/AuthContext'
import { Container } from '../ui/Container'
import { Spinner } from '../ui/Spinner'
export function ProtectedRoute({ redirectTo = '/login' }) {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
if (isLoading) {
return (
<Container className="flex min-h-[50vh] items-center justify-center py-section">
<Spinner label="Checking your session" />
</Container>
)
}
if (!isAuthenticated) {
return <Navigate to={redirectTo} replace state={{ from: location.pathname }} />
}
return <Outlet />
}

View file

@ -0,0 +1,100 @@
import { memo } from 'react'
import { Link } from 'react-router-dom'
import { ArrowUpRight } from 'lucide-react'
import { cn } from '../../utils/cn'
import { buildSrcSet, withImageWidth } from '../../utils/mediaUrl'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
function EventLink({ to, href, className, children, ...props }) {
if (to) {
return (
<Link to={to} className={className} {...props}>
{children}
</Link>
)
}
return (
<a href={href ?? '#'} className={className} {...props}>
{children}
</a>
)
}
/**
* Editorial event tile — sharp geometry, image-led, minimal chrome.
*/
function EventCardComponent({
title,
meta,
venue,
imageUrl,
imageAlt = '',
badge,
badgeVariant = 'default',
to,
href = '#',
featured = false,
className,
}) {
const destination = to ?? href
return (
<article
className={cn(
'group relative flex flex-col',
featured && 'md:col-span-2 md:grid md:grid-cols-2 md:gap-8',
className,
)}
>
<EventLink
to={to}
href={href}
className="relative mb-5 block overflow-hidden rounded-2xl border border-border bg-ink shadow-sm aspect-[4/5] md:aspect-[3/4] transition-shadow duration-slow ease-smooth group-hover:shadow-card"
>
{imageUrl ? (
<img
src={withImageWidth(imageUrl, 720)}
srcSet={buildSrcSet(imageUrl, [480, 720, 960])}
alt={imageAlt}
className="img-cover-event transition-transform duration-slow ease-smooth group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
loading="lazy"
decoding="async"
sizes="(min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw"
/>
) : (
<div className="flex h-full items-end bg-surface-muted p-6">
<span className="text-caption uppercase text-ink-faint">Image forthcoming</span>
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-scrim-bottom opacity-80" />
{badge ? (
<div className="absolute left-4 top-4">
<Badge variant={badgeVariant}>{badge}</Badge>
</div>
) : null}
</EventLink>
<div className="flex flex-1 flex-col">
{meta ? (
<p className="text-caption uppercase text-ink-muted">{meta}</p>
) : null}
<h3 className="mt-2 font-display text-display-md text-balance transition-colors duration-medium">
<EventLink to={to} href={href} className="hover:text-accent">
{title}
</EventLink>
</h3>
{venue ? <p className="mt-2 text-body-sm text-ink-muted">{venue}</p> : null}
<div className="mt-6">
<Button to={to} href={to ? undefined : destination} variant="ghost" size="sm" className="px-0 hover:bg-transparent">
View edition
<ArrowUpRight className="h-4 w-4" aria-hidden />
</Button>
</div>
</div>
</article>
)
}
export const EventCard = memo(EventCardComponent)

View file

@ -0,0 +1,55 @@
import { AlertCircle } from 'lucide-react'
import { Button } from '../ui/Button'
import { EmptyState } from '../ui/EmptyState'
import { Skeleton, SkeletonCard } from '../ui/Skeleton'
import { Spinner } from '../ui/Spinner'
export function EventsGridSkeleton() {
return (
<div className="grid gap-12 md:grid-cols-2 xl:grid-cols-3" aria-busy="true" aria-label="Loading events">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)
}
export function VenuesListSkeleton() {
return (
<ul className="divide-y divide-border border-y border-border bg-surface-elevated" aria-busy="true">
{[0, 1, 2].map((key) => (
<li key={key} className="px-4 py-6 md:px-8">
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-2 h-4 w-32" />
</li>
))}
</ul>
)
}
export function ApiErrorState({ title = 'Could not load data', error, onRetry }) {
const message =
error?.message ?? 'The server is unavailable right now. Please try again in a moment.'
return (
<EmptyState
icon={<AlertCircle className="h-8 w-8" />}
title={title}
description={message}
actionLabel="Try again"
onAction={onRetry}
/>
)
}
export function EventDetailSkeleton() {
return (
<div className="py-section" aria-busy="true" aria-label="Loading event">
<Skeleton className="min-h-[50vh] w-full rounded-none" />
<div className="mx-auto mt-10 max-w-content px-gutter md:px-gutter-md">
<Spinner className="text-accent" />
</div>
</div>
)
}

View file

@ -0,0 +1,6 @@
/** Minimal route suspense shell — reserves space without layout shift. */
export function RouteFallback() {
return (
<div className="min-h-[40vh] bg-surface" aria-busy="true" aria-live="polite" />
)
}

View file

@ -0,0 +1,22 @@
import { SectionHeader } from '../ui/Section'
import { Text } from '../typography/Text'
export function EventDescription({ paragraphs = [] }) {
if (!paragraphs.length) {
return null
}
return (
<section aria-labelledby="event-description-heading" className="py-10 md:py-12">
<SectionHeader eyebrow="About" title="The edition" />
<div id="event-description-heading" className="sr-only">About this event</div>
<div className="max-w-prose space-y-5">
{paragraphs.map((paragraph) => (
<Text key={paragraph.slice(0, 32)} className="text-lg text-ink/90">
{paragraph}
</Text>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,43 @@
import { buildSrcSet, withImageWidth } from '../../utils/mediaUrl'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
export function EventDetailGallery({ gallery = [] }) {
if (!gallery.length) {
return (
<section className="border-t border-border py-10 md:py-12">
<Eyebrow>Gallery</Eyebrow>
<Heading level={2} className="mt-3">Moments</Heading>
<Text tone="muted" className="mt-4">Photography from this edition will be published closer to the date.</Text>
</section>
)
}
return (
<section aria-labelledby="gallery-heading" className="border-t border-border py-10 md:py-12">
<Eyebrow id="gallery-heading">Gallery</Eyebrow>
<Heading level={2} className="mt-3">From the floor</Heading>
<div className="mt-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{gallery.map((image, index) => (
<figure
key={image.src}
className={index === 0 ? 'sm:col-span-2 sm:row-span-2' : ''}
>
<div className="overflow-hidden rounded-2xl border border-border bg-ink aspect-[4/3] sm:aspect-auto sm:min-h-[220px] sm:h-full">
<img
src={withImageWidth(image.src, 960)}
srcSet={buildSrcSet(image.src, [480, 720, 960, 1280])}
alt={image.alt}
className="img-cover-event transition-transform duration-slow ease-smooth hover:scale-[1.02] motion-reduce:hover:scale-100"
loading="lazy"
decoding="async"
sizes="(min-width: 1024px) 33vw, 50vw"
/>
</div>
</figure>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,18 @@
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
export function EventDetailHeader({ title, city, eventType }) {
const typeLabel = eventType?.replace(/_/g, ' ')
return (
<header className="-mt-8 border-b border-border pb-8 md:-mt-12 md:pb-10">
<Text size="sm" tone="muted" className="uppercase tracking-[0.12em] text-accent">
{city}
{typeLabel ? ` · ${typeLabel}` : ''}
</Text>
<Heading level={1} className="mt-3 text-balance">
{title}
</Heading>
</header>
)
}

View file

@ -0,0 +1,50 @@
import { cn } from '../../utils/cn'
import { Badge } from '../ui/Badge'
import { Container } from '../ui/Container'
export function EventDetailHero({
coverImage,
coverImageAlt,
badge,
badgeVariant,
title,
heroImageRef,
}) {
return (
<section aria-label="Event hero" className="relative min-h-[62vh] overflow-hidden bg-ink md:min-h-[72vh]">
{coverImage ? (
<img
ref={heroImageRef}
src={coverImage}
alt={coverImageAlt || title}
className="absolute inset-0 h-[108%] w-full object-cover object-[center_32%] will-change-transform motion-reduce:will-change-auto"
fetchPriority="high"
decoding="async"
sizes="100vw"
width={1920}
height={1080}
/>
) : (
<div
className={cn(
'absolute inset-0 bg-surface-muted',
'bg-[radial-gradient(circle_at_30%_20%,_#F5E8DF_0%,_transparent_45%),_linear-gradient(160deg,_#1a1814_0%,_#0F0E0C_55%)]',
)}
role="img"
aria-label={coverImageAlt || 'Event artwork forthcoming'}
/>
)}
<div className="absolute inset-0 bg-ink/45" aria-hidden />
<div className="absolute inset-0 bg-scrim-bottom" aria-hidden />
<Container className="relative z-10 flex min-h-[62vh] flex-col justify-end pb-10 pt-28 md:min-h-[72vh] md:pb-14">
{badge ? (
<Badge variant={badgeVariant} className="w-fit">
{badge}
</Badge>
) : null}
</Container>
</section>
)
}

View file

@ -0,0 +1,32 @@
import { ChevronDown } from 'lucide-react'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
export function EventFaq({ faq = [] }) {
if (!faq.length) {
return null
}
return (
<section aria-labelledby="faq-heading" className="border-t border-border py-10 md:py-12">
<Eyebrow id="faq-heading">FAQ</Eyebrow>
<Heading level={2} className="mt-3">Good to know</Heading>
<div className="mt-8 max-w-3xl divide-y divide-border border-y border-border">
{faq.map((item) => (
<details key={item.question} className="group py-4">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-left font-medium text-ink marker:content-none">
<span>{item.question}</span>
<ChevronDown
className="h-5 w-5 shrink-0 text-ink-muted transition-transform group-open:rotate-180"
aria-hidden
/>
</summary>
<Text tone="muted" className="mt-3 pr-8">{item.answer}</Text>
</details>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,24 @@
import { Text } from '../typography/Text'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
export function EventHighlights({ highlights = [] }) {
if (!highlights.length) {
return null
}
return (
<section aria-labelledby="highlights-heading" className="border-t border-border py-10 md:py-12">
<Eyebrow id="highlights-heading">Experience</Eyebrow>
<Heading level={2} className="mt-3">Highlights</Heading>
<ul className="mt-8 grid gap-6 md:grid-cols-3">
{highlights.map((item) => (
<li key={item.title} className="border-l-2 border-accent pl-5">
<p className="font-display text-xl text-ink">{item.title}</p>
<Text tone="muted" size="sm" className="mt-2">{item.body}</Text>
</li>
))}
</ul>
</section>
)
}

View file

@ -0,0 +1,24 @@
import { formatInrFromPaise } from '../../utils/format'
import { Button } from '../ui/Button'
export function EventMobileTicketBar({ lowestAvailableInr }) {
const priceLabel =
lowestAvailableInr === undefined
? 'Tickets'
: lowestAvailableInr === 0
? 'Free passes'
: `From ${formatInrFromPaise(lowestAvailableInr)}`
return (
<div
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface-elevated/95 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-[0_-8px_30px_-12px_rgba(15,14,12,0.15)] backdrop-blur-md lg:hidden"
role="region"
aria-label="Quick ticket actions"
>
<div className="mx-auto flex max-w-content items-center justify-between gap-4 px-gutter">
<p className="text-label uppercase text-ink-muted">{priceLabel}</p>
<Button href="#tickets" size="sm">Get tickets</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,35 @@
import { Calendar, Clock, DoorOpen, MapPin } from 'lucide-react'
import { cn } from '../../utils/cn'
const items = [
{ key: 'date', icon: Calendar, label: 'Date' },
{ key: 'time', icon: Clock, label: 'Time' },
{ key: 'gates', icon: DoorOpen, label: 'Entry' },
{ key: 'venue', icon: MapPin, label: 'Venue' },
]
export function EventScheduleLocation({ dateLabel, timeLabel, gatesLabel, venueName, venueArea }) {
const values = {
date: dateLabel,
time: timeLabel,
gates: gatesLabel,
venue: `${venueName}${venueArea ? ` · ${venueArea}` : ''}`,
}
return (
<dl className="grid gap-4 border-y border-border py-8 sm:grid-cols-2">
{items.map(({ key, icon: Icon, label }) => (
<div key={key} className="flex gap-4">
<span className={cn('mt-0.5 text-accent')} aria-hidden>
<Icon className="h-5 w-5" />
</span>
<div>
<dt className="text-label uppercase text-ink-muted">{label}</dt>
<dd className="mt-1 text-body text-ink">{values[key]}</dd>
</div>
</div>
))}
</dl>
)
}

View file

@ -0,0 +1,35 @@
import { Button } from '../ui/Button'
import { Container } from '../ui/Container'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
export function EventTicketCta({ title }) {
return (
<section id="ticket-cta" className="scroll-mt-24 border-t border-border-strong bg-ink py-section md:py-section-lg">
<Container>
<div className="flex flex-col gap-8 md:flex-row md:items-center md:justify-between">
<div className="max-w-xl">
<Eyebrow className="text-gold-muted">Ready?</Eyebrow>
<Heading level={2} className="mt-4 text-balance text-surface">
Reserve your place
</Heading>
<Text className="mt-2 text-surface/60 text-body-sm">{title}</Text>
<Text className="mt-4 text-surface/70">
Ticket checkout and QR passes arrive in the next release. For now, save your preferred
tier above — we will notify list members first.
</Text>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button href="#tickets" variant="primary">
View ticket tiers
</Button>
<Button href="/#cta" variant="inverse">
Join the list
</Button>
</div>
</div>
</Container>
</section>
)
}

View file

@ -0,0 +1,66 @@
import { formatInrFromPaise } from '../../utils/format'
import { cn } from '../../utils/cn'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
const statusCopy = {
available: { label: 'Available', variant: 'success' },
sold_out: { label: 'Sold out', variant: 'soldOut' },
coming_soon: { label: 'Soon', variant: 'default' },
}
export function EventTicketTiers({ ticketTiers = [], eventId, onSelectTier, checkoutError, isCheckoutBusy, className }) {
return (
<section
id="tickets"
aria-labelledby="tickets-heading"
className={cn('scroll-mt-28', className)}
>
<Eyebrow id="tickets-heading">Tickets</Eyebrow>
<Heading level={2} className="mt-3">Choose your pass</Heading>
<Text tone="muted" size="sm" className="mt-2">
Secure checkout — payment is confirmed on our servers before tickets are issued.
</Text>
{checkoutError ? (
<p className="mt-3 text-body-sm text-accent" role="alert">{checkoutError}</p>
) : null}
<ul className="mt-6 space-y-4">
{ticketTiers.map((tier) => {
const status = statusCopy[tier.status] ?? statusCopy.available
const disabled = tier.status !== 'available'
return (
<li
key={tier.id}
className="rounded-2xl border border-border bg-surface-elevated p-5 shadow-sm transition-[border-color,box-shadow,transform] duration-medium ease-smooth hover:border-border-strong hover:shadow-card motion-safe:hover:-translate-y-0.5"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-display text-xl text-ink">{tier.name}</p>
<Text tone="muted" size="sm" className="mt-1">{tier.description}</Text>
</div>
<div className="text-right">
<p className="font-display text-2xl text-accent">{formatInrFromPaise(tier.priceInr)}</p>
<Badge variant={status.variant} className="mt-2">{status.label}</Badge>
</div>
</div>
<Button
type="button"
className="mt-5 w-full"
variant={disabled ? 'secondary' : 'primary'}
disabled={disabled || !eventId || isCheckoutBusy}
onClick={() => onSelectTier?.(tier)}
>
{isCheckoutBusy ? 'Processing…' : tier.status === 'sold_out' ? 'Sold out' : 'Buy pass'}
</Button>
</li>
)
})}
</ul>
</section>
)
}

View file

@ -0,0 +1,43 @@
import { MapPin, Navigation } from 'lucide-react'
import { Button } from '../ui/Button'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
export function EventVenueInfo({ venue }) {
if (!venue) {
return null
}
return (
<section aria-labelledby="venue-heading" className="border-t border-border bg-surface-muted py-10 md:py-12">
<div className="max-w-3xl">
<Eyebrow id="venue-heading">Venue</Eyebrow>
<Heading level={2} className="mt-3">{venue.name}</Heading>
<Text tone="muted" size="sm" className="mt-2">{venue.area}</Text>
<address className="mt-4 not-italic text-body text-ink-muted">
<MapPin className="mr-2 inline h-4 w-4 text-accent" aria-hidden />
{venue.address}
</address>
<Text className="mt-6">{venue.about}</Text>
<Text tone="muted" size="sm" className="mt-4">
<Navigation className="mr-2 inline h-4 w-4" aria-hidden />
{venue.gettingThere}
</Text>
{venue.mapUrl ? (
<Button
href={venue.mapUrl}
variant="secondary"
size="sm"
className="mt-8"
target="_blank"
rel="noreferrer"
>
Open in maps
</Button>
) : null}
</div>
</section>
)
}

View file

@ -0,0 +1,11 @@
export { EventDescription } from './EventDescription'
export { EventDetailGallery } from './EventDetailGallery'
export { EventDetailHeader } from './EventDetailHeader'
export { EventDetailHero } from './EventDetailHero'
export { EventFaq } from './EventFaq'
export { EventHighlights } from './EventHighlights'
export { EventMobileTicketBar } from './EventMobileTicketBar'
export { EventScheduleLocation } from './EventScheduleLocation'
export { EventTicketCta } from './EventTicketCta'
export { EventTicketTiers } from './EventTicketTiers'
export { EventVenueInfo } from './EventVenueInfo'

View file

@ -0,0 +1,38 @@
import { brandStory } from '../../data/homeMock'
import { SectionSplit } from '../layout/SectionSplit'
import { Section, SectionHeader } from '../ui/Section'
import { Card } from '../ui/Card'
import { Text } from '../typography/Text'
export function BrandStorySection() {
return (
<Section id="story" spacing="lg">
<div data-reveal>
<SectionHeader
eyebrow={brandStory.eyebrow}
title={brandStory.title}
/>
</div>
<div data-reveal>
<SectionSplit
main={
<div className="space-y-6">
{brandStory.paragraphs.map((paragraph) => (
<Text key={paragraph.slice(0, 24)} className="max-w-prose text-lg">
{paragraph}
</Text>
))}
</div>
}
aside={
<Card variant="editorial" padding="lg" className="bg-surface-muted">
<blockquote className="font-display text-2xl leading-snug text-ink">
“{brandStory.pullQuote}”
</blockquote>
</Card>
}
/>
</div>
</Section>
)
}

View file

@ -0,0 +1,48 @@
import { categories } from '../../data/homeMock'
import { Container } from '../ui/Container'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
export function CategoriesSection() {
return (
<section id="categories" className="bg-ink py-section text-surface md:py-section-lg">
<Container>
<div data-reveal className="max-w-editorial">
<Eyebrow className="text-gold-muted">Culture</Eyebrow>
<Heading level={2} className="mt-4 text-surface">
Categories we curate
</Heading>
<Text className="mt-4 text-surface/70">
Not a grab-bag of listings — each format gets its own production language.
</Text>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{categories.map((category) => (
<a
key={category.id}
href={`#events`}
data-reveal
className="group relative flex min-h-[280px] flex-col justify-end overflow-hidden rounded-2xl border border-white/10 shadow-sm transition-[box-shadow,transform] duration-slow ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ink motion-safe:hover:-translate-y-0.5 motion-safe:hover:shadow-card"
>
<img
src={category.imageUrl}
alt={category.imageAlt}
className="absolute inset-0 h-full w-full object-cover object-[center_30%] transition-transform duration-slow ease-smooth group-hover:scale-[1.04] motion-reduce:transition-none"
loading="lazy"
decoding="async"
sizes="(min-width: 1024px) 25vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-ink via-ink/35 to-transparent" aria-hidden />
<div className="relative p-5">
<p className="font-display text-2xl">{category.name}</p>
<p className="mt-2 text-body-sm text-surface/75">{category.tagline}</p>
</div>
</a>
))}
</div>
</Container>
</section>
)
}

View file

@ -0,0 +1,81 @@
import { ArrowUpRight } from 'lucide-react'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import { Container } from '../ui/Container'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
import { buildSrcSet, withImageWidth } from '../../utils/mediaUrl'
import { ApiErrorState } from '../common/RequestState'
import { Skeleton } from '../ui/Skeleton'
export function FeaturedEventSection({ featured, isLoading, isError, error, onRetry }) {
if (isLoading) {
return (
<section id="featured" className="border-y border-border bg-surface-muted py-section md:py-section-lg">
<Container>
<Skeleton className="aspect-[16/10] w-full" />
</Container>
</section>
)
}
if (isError) {
return (
<section id="featured" className="border-y border-border bg-surface-muted py-section">
<Container>
<ApiErrorState title="Could not load featured edition" error={error} onRetry={onRetry} />
</Container>
</section>
)
}
if (!featured) {
return null
}
return (
<section id="featured" className="border-y border-border bg-surface-muted py-section md:py-section-lg">
<Container>
<div data-reveal className="grid gap-10 lg:grid-cols-12 lg:gap-12 lg:items-center">
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl border border-border bg-ink shadow-sm lg:col-span-7 lg:aspect-[16/10]">
{featured.imageUrl ? (
<img
src={withImageWidth(featured.imageUrl, 1200)}
srcSet={buildSrcSet(featured.imageUrl, [640, 960, 1200, 1600])}
alt={featured.imageAlt}
className="img-cover-event"
loading="lazy"
decoding="async"
sizes="(min-width: 1024px) 58vw, 100vw"
/>
) : (
<div className="flex h-full items-end bg-surface-muted p-6">
<span className="text-caption uppercase text-ink-faint">Image forthcoming</span>
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-scrim-bottom opacity-70" aria-hidden />
<div className="absolute left-4 top-4 md:left-6 md:top-6">
<Badge variant={featured.badgeVariant}>{featured.badge}</Badge>
</div>
</div>
<div className="lg:col-span-5">
<Eyebrow>Featured edition</Eyebrow>
<Heading level={2} className="mt-4">{featured.title}</Heading>
<Text tone="muted" size="sm" className="mt-3 uppercase tracking-wide">{featured.meta}</Text>
<Text tone="muted" className="mt-2">{featured.venue}</Text>
{featured.description ? (
<Text className="mt-6 max-w-prose">{featured.description}</Text>
) : null}
<Button to={`/events/${featured.slug}`} className="mt-8">
View edition
<ArrowUpRight className="h-4 w-4" aria-hidden />
</Button>
</div>
</div>
</Container>
</section>
)
}

View file

@ -0,0 +1,62 @@
import { Button } from '../ui/Button'
import { Container } from '../ui/Container'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
export function FinalCtaSection() {
const onSubmit = (event) => {
event.preventDefault()
}
return (
<section id="cta" className="bg-ink py-section md:py-section-lg">
<Container>
<div
data-reveal
className="grid gap-10 border border-white/10 bg-white/[0.03] p-8 md:grid-cols-2 md:items-center md:gap-12 md:p-12 lg:p-14"
>
<div>
<Eyebrow className="text-gold-muted">Stay close</Eyebrow>
<Heading level={2} className="mt-4 text-surface">
First access to new editions
</Heading>
<Text className="mt-4 text-surface/75">
Join the Bengaluru list for Holi drops, Garba weekends, and venue announcements. No
spam — just culture.
</Text>
</div>
<form className="space-y-4" onSubmit={onSubmit} noValidate>
<label className="block">
<span className="text-label uppercase text-surface/60">Email</span>
<input
type="email"
name="email"
autoComplete="email"
placeholder="you@example.com"
className="mt-2 min-h-11 w-full rounded-sm border border-white/20 bg-ink px-4 text-surface placeholder:text-surface/35 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/40"
required
/>
</label>
<label className="block">
<span className="text-label uppercase text-surface/60">Neighbourhood (optional)</span>
<input
type="text"
name="neighborhood"
placeholder="HSR, Whitefield, Indiranagar…"
className="mt-2 min-h-11 w-full rounded-sm border border-white/20 bg-ink px-4 text-surface placeholder:text-surface/35 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
</label>
<Button type="submit" className="w-full md:w-auto">
Join the list
</Button>
<Text as="p" size="sm" className="text-surface/45">
Mock form — backend subscribe API will connect in a later phase.
</Text>
</form>
</div>
</Container>
</section>
)
}

View file

@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react'
import { loadGsap } from '../../animations/loadGsap'
import { prefersReducedMotion } from '../../utils/motion'
import { Container } from '../ui/Container'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
const spanClass = {
tall: 'md:row-span-2',
wide: 'md:col-span-2',
square: '',
}
export function GallerySection({ galleryImages = [] }) {
const trackRef = useRef(null)
useEffect(() => {
if (prefersReducedMotion() || !trackRef.current || !galleryImages.length) {
return
}
let tween
loadGsap().then((gsap) => {
if (!gsap || !trackRef.current) {
return
}
const items = trackRef.current.querySelectorAll('[data-gallery-item]')
tween = gsap.fromTo(
items,
{ opacity: 0.6, scale: 0.98 },
{
opacity: 1,
scale: 1,
duration: 0.8,
stagger: 0.08,
ease: 'power2.out',
scrollTrigger: {
trigger: trackRef.current,
start: 'top 80%',
},
},
)
})
return () => {
tween?.scrollTrigger?.kill()
tween?.kill()
}
}, [galleryImages])
if (!galleryImages.length) {
return null
}
return (
<section id="gallery" className="overflow-hidden py-section md:py-section-lg">
<Container>
<div data-reveal className="max-w-editorial">
<Eyebrow>Immersive</Eyebrow>
<Heading level={2} className="mt-4">From the floor</Heading>
<Text tone="muted" className="mt-4">
Colour, crowd, and choreography — the texture of Bengaluru’s festival season.
</Text>
</div>
</Container>
<div
ref={trackRef}
className="mt-10 flex gap-4 overflow-x-auto px-gutter pb-2 snap-x snap-mandatory scrollbar-none md:mt-14 md:grid md:max-w-[100rem] md:grid-cols-4 md:grid-rows-2 md:gap-4 md:overflow-visible md:px-gutter-md md:snap-none lg:mx-auto lg:px-gutter-lg"
>
{galleryImages.map((image) => (
<figure
key={`${image.src}-${image.alt}`}
data-gallery-item
className={`relative min-w-[78%] shrink-0 snap-center overflow-hidden rounded-2xl border border-border bg-ink shadow-sm aspect-[4/5] sm:min-w-[55%] md:min-w-0 md:aspect-auto md:min-h-[220px] md:h-full ${spanClass[image.span] ?? ''}`}
>
<img
src={image.src}
alt={image.alt}
className="img-cover-event"
loading="lazy"
decoding="async"
sizes="(min-width: 768px) 25vw, 78vw"
/>
</figure>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,102 @@
import { lazy, Suspense, useEffect, useRef } from 'react'
import { useHeroAtmosphereEligible } from '../../webgl/hero-atmosphere'
import { ChevronDown } from 'lucide-react'
import { loadGsap } from '../../animations/loadGsap'
import { heroContent } from '../../data/homeMock'
import { buildSrcSet, withImageWidth } from '../../utils/mediaUrl'
import { prefersReducedMotion } from '../../utils/motion'
import { Button } from '../ui/Button'
import { Container } from '../ui/Container'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
const HeroAtmosphereLayer = lazy(() =>
import('../../webgl/hero-atmosphere/HeroAtmosphereLayer.jsx').then((module) => ({
default: module.HeroAtmosphereLayer,
})),
)
export function HomeHero({ heroImageRef }) {
const copyRef = useRef(null)
const showAtmosphere = useHeroAtmosphereEligible()
useEffect(() => {
if (prefersReducedMotion() || !copyRef.current) {
return
}
loadGsap().then((gsap) => {
if (!gsap || !copyRef.current) {
return
}
const targets = copyRef.current.querySelectorAll('[data-hero-item]')
gsap.fromTo(
targets,
{ y: 28, opacity: 0.92 },
{ y: 0, opacity: 1, duration: 0.95, stagger: 0.1, ease: 'power2.out', delay: 0.08 },
)
})
}, [])
const heroSrcSet = buildSrcSet(heroContent.image, heroContent.imageSrcSetWidths ?? [640, 1280, 1920])
const heroSrc = heroContent.image.includes('plus.unsplash.com')
? heroContent.image
: withImageWidth(heroContent.image, 1280, 70)
return (
<section
aria-label="Introduction"
className="relative flex min-h-[100svh] items-end overflow-hidden bg-ink pb-16 pt-28 md:pb-24 md:pt-32"
>
<div className="absolute inset-0 overflow-hidden">
<img
ref={heroImageRef}
src={heroSrc}
srcSet={heroSrcSet}
alt={heroContent.imageAlt}
className="h-[115%] w-full object-cover object-[center_42%] will-change-transform motion-reduce:will-change-auto"
width={1920}
height={1080}
fetchPriority="high"
decoding="async"
sizes="100vw"
/>
{showAtmosphere ? (
<Suspense fallback={null}>
<HeroAtmosphereLayer />
</Suspense>
) : null}
<div className="absolute inset-0 bg-ink/55" aria-hidden />
<div className="absolute inset-0 bg-scrim-bottom" aria-hidden />
</div>
<Container className="relative z-10">
<div ref={copyRef} className="max-w-3xl">
<div data-hero-item>
<Eyebrow className="text-gold-muted">{heroContent.eyebrow}</Eyebrow>
</div>
<Heading level={1} className="mt-5 text-surface" data-hero-item>
{heroContent.title}
</Heading>
<Text tone="inverse" className="mt-6 max-w-2xl text-surface/80" data-hero-item>
{heroContent.subtitle}
</Text>
<div className="mt-10 flex flex-wrap gap-3" data-hero-item>
<Button href="#events">Explore events</Button>
<Button href="#cta" variant="inverse">Join the list</Button>
</div>
</div>
</Container>
<a
href="#events"
className="absolute bottom-6 left-1/2 z-10 hidden -translate-x-1/2 text-surface/70 transition-colors hover:text-surface md:inline-flex min-h-11 min-w-11 items-center justify-center"
aria-label="Scroll to upcoming events"
>
<ChevronDown className="h-6 w-6 motion-safe:animate-bounce" aria-hidden />
</a>
</section>
)
}

View file

@ -0,0 +1,80 @@
import { ExternalLink, Mail } from 'lucide-react'
import { Container } from '../ui/Container'
import { Text } from '../typography/Text'
const exploreLinks = [
{ href: '#events', label: 'Upcoming events' },
{ href: '#categories', label: 'Cultural calendar' },
{ href: '#venues', label: 'Bengaluru venues' },
{ href: '#gallery', label: 'Gallery' },
]
export function MarketingFooter() {
return (
<footer className="border-t border-white/10 bg-ink text-surface">
<Container className="py-14 md:py-16">
<div className="grid gap-12 md:grid-cols-2 lg:grid-cols-4">
<div className="lg:col-span-2">
<p className="font-display text-3xl tracking-tight">Desi Clans</p>
<Text as="p" className="mt-4 max-w-md text-surface/70">
Premium Indian cultural events in Bengaluru. Holi, Garba, Dandiya and curated nights —
photographed like memory, produced with care.
</Text>
</div>
<div>
<p className="text-label uppercase text-surface/50">Explore</p>
<ul className="mt-4 space-y-3">
{exploreLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="text-body-sm text-surface/80 transition-colors hover:text-surface"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
<div>
<p className="text-label uppercase text-surface/50">Connect</p>
<ul className="mt-4 space-y-3">
<li>
<a
href="https://instagram.com"
className="inline-flex min-h-11 items-center gap-2 text-body-sm text-surface/80 hover:text-surface"
rel="noreferrer"
target="_blank"
>
<ExternalLink className="h-4 w-4" aria-hidden />
Instagram
</a>
</li>
<li>
<a
href="mailto:hello@desiclans.in"
className="inline-flex min-h-11 items-center gap-2 text-body-sm text-surface/80 hover:text-surface"
>
<Mail className="h-4 w-4" aria-hidden />
hello@desiclans.in
</a>
</li>
</ul>
</div>
</div>
<div className="mt-12 flex flex-col gap-4 border-t border-white/10 pt-8 md:flex-row md:items-center md:justify-between">
<Text as="p" size="sm" className="text-surface/45">
© {new Date().getFullYear()} Desi Clans · Bengaluru, Karnataka
</Text>
<Text as="p" size="sm" className="text-surface/45">
No concerts. Culture only.
</Text>
</div>
</Container>
</footer>
)
}

View file

@ -0,0 +1,43 @@
import { EventCard } from '../cards/EventCard'
import { ApiErrorState, EventsGridSkeleton } from '../common/RequestState'
import { EmptyState } from '../ui/EmptyState'
import { Section, SectionHeader } from '../ui/Section'
export function UpcomingEventsSection({ cards = [], isLoading, isError, isEmpty, error, onRetry }) {
return (
<Section id="events" spacing="lg">
<div data-reveal>
<SectionHeader
eyebrow="Calendar"
title="Upcoming in Bengaluru"
description="Live editions from Desi Clans — curated cultural weekends across the city."
/>
</div>
{isLoading ? <EventsGridSkeleton /> : null}
{isError ? (
<ApiErrorState title="Could not load events" error={error} onRetry={onRetry} />
) : null}
{isEmpty ? (
<EmptyState
title="No upcoming editions"
description="New Holi, Garba, and Dandiya dates will appear here when they are published."
actionLabel="Refresh"
onAction={onRetry}
/>
) : null}
{!isLoading && !isError && !isEmpty ? (
<div className="grid gap-10 md:gap-12 md:grid-cols-2 xl:grid-cols-3" data-reveal-group>
{cards.map((event) => (
<div key={event.slug} data-reveal-item>
<EventCard {...event} />
</div>
))}
</div>
) : null}
</Section>
)
}

View file

@ -0,0 +1,56 @@
import { MapPin } from 'lucide-react'
import { ApiErrorState, VenuesListSkeleton } from '../common/RequestState'
import { EmptyState } from '../ui/EmptyState'
import { Section, SectionHeader } from '../ui/Section'
import { Text } from '../typography/Text'
export function VenuesSection({ venues = [], isLoading, isError, isEmpty, error, onRetry }) {
return (
<Section id="venues" tone="muted" spacing="lg">
<div data-reveal>
<SectionHeader
eyebrow="Bengaluru"
title="Venues we love"
description="From Palace Grounds scale to neighbourhood intimacy — where the city celebrates."
/>
</div>
{isLoading ? <VenuesListSkeleton /> : null}
{isError ? <ApiErrorState title="Could not load venues" error={error} onRetry={onRetry} /> : null}
{isEmpty ? (
<EmptyState
title="No venues listed"
description="Venue profiles will appear here as new editions are confirmed."
actionLabel="Refresh"
onAction={onRetry}
/>
) : null}
{!isLoading && !isError && !isEmpty ? (
<ul
className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-sm"
data-reveal-group
>
{venues.map((venue) => (
<li key={venue.id ?? venue.name} data-reveal-item>
<div className="flex flex-col gap-4 px-4 py-6 transition-colors duration-medium hover:bg-surface-muted/50 sm:flex-row sm:items-center sm:justify-between sm:px-6 md:px-8">
<div className="flex gap-4">
<span className="mt-1 text-accent" aria-hidden>
<MapPin className="h-5 w-5" />
</span>
<div>
<p className="font-display text-xl text-ink">{venue.name}</p>
<Text tone="muted" size="sm" className="mt-1">{venue.area}</Text>
</div>
</div>
<Text tone="muted" size="sm" className="max-w-md sm:text-right">{venue.note}</Text>
</div>
</li>
))}
</ul>
) : null}
</Section>
)
}

View file

@ -0,0 +1,16 @@
export { EventCard } from './cards/EventCard'
export { SectionSplit } from './layout/SectionSplit'
export { Navbar } from './navigation/Navbar'
export { NavLink } from './navigation/NavLink'
export { SiteFooter } from './navigation/SiteFooter'
export { Eyebrow } from './typography/Eyebrow'
export { Heading } from './typography/Heading'
export { Text } from './typography/Text'
export { Badge } from './ui/Badge'
export { Button } from './ui/Button'
export { Card } from './ui/Card'
export { Container } from './ui/Container'
export { EmptyState } from './ui/EmptyState'
export { Section, SectionHeader } from './ui/Section'
export { Skeleton, SkeletonCard, SkeletonText } from './ui/Skeleton'
export { Spinner } from './ui/Spinner'

View file

@ -0,0 +1,19 @@
import { cn } from '../../utils/cn'
/**
* Editorial two-column layout: narrative + supporting content.
*/
export function SectionSplit({ className, main, aside, reverse = false }) {
return (
<div
className={cn(
'grid gap-10 lg:grid-cols-12 lg:gap-16',
reverse && 'lg:[&>*:first-child]:order-2',
className,
)}
>
<div className="lg:col-span-7">{main}</div>
<div className="lg:col-span-5 lg:pt-2">{aside}</div>
</div>
)
}

View file

@ -0,0 +1,195 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import { cn } from '../../utils/cn'
import { useAuth } from '../../context/AuthContext'
import { Button } from '../ui/Button'
import { Container } from '../ui/Container'
const navItems = [
{ href: '/#events', label: 'Events' },
{ href: '#categories', label: 'Culture' },
{ href: '#venues', label: 'Venues' },
{ href: '#gallery', label: 'Gallery' },
{ href: '#story', label: 'Story' },
]
function NavAnchor({ href, children, className, onClick }) {
return (
<a
href={href}
className={cn(
'nav-link-editorial text-label uppercase tracking-[0.1em] transition-colors duration-medium',
className,
)}
onClick={onClick}
>
{children}
</a>
)
}
export function MarketingNavbar() {
const [open, setOpen] = useState(false)
const [solid, setSolid] = useState(false)
const { isAuthenticated, logout } = useAuth()
const navigate = useNavigate()
useEffect(() => {
let ticking = false
const update = () => {
setSolid(window.scrollY > 48)
ticking = false
}
const onScroll = () => {
if (ticking) {
return
}
ticking = true
requestAnimationFrame(update)
}
update()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
const linkTone = solid
? 'text-ink-muted hover:text-ink'
: 'text-surface/80 hover:text-surface'
return (
<header
className={cn(
'fixed inset-x-0 top-0 z-50 transition-[background-color,border-color,box-shadow] duration-medium ease-smooth',
solid
? 'border-b border-border bg-surface/92 backdrop-blur-md shadow-sm'
: 'bg-gradient-to-b from-ink/55 via-ink/20 to-transparent',
)}
>
<Container>
<div className="flex h-16 items-center justify-between gap-4 md:h-[4.5rem]">
<Link
to="/"
className={cn(
'font-display text-xl tracking-tight md:text-2xl',
solid ? 'text-ink' : 'text-surface',
)}
>
Desi Clans
</Link>
<nav className="hidden items-center gap-7 lg:flex" aria-label="Primary">
{navItems.map((item) => (
<NavAnchor key={item.href} href={item.href} className={linkTone}>
{item.label}
</NavAnchor>
))}
{isAuthenticated ? (
<>
<Button to="/account" size="sm" variant={solid ? 'secondary' : 'inverse'}>
Account
</Button>
<Button
type="button"
size="sm"
variant={solid ? 'primary' : 'inverse'}
onClick={() => {
logout()
navigate('/')
}}
>
Sign out
</Button>
</>
) : (
<>
<Button to="/login" size="sm" variant={solid ? 'secondary' : 'inverse'}>
Sign in
</Button>
<Button href="#cta" size="sm" variant={solid ? 'primary' : 'inverse'}>
Join the list
</Button>
</>
)}
</nav>
<div className="flex items-center gap-2 lg:hidden">
<Button
href="#cta"
size="sm"
variant={solid ? 'primary' : 'inverse'}
className="hidden xs:inline-flex"
>
Join
</Button>
<button
type="button"
className={cn(
'inline-flex min-h-11 min-w-11 items-center justify-center rounded-lg border transition-colors duration-medium',
solid
? 'border-border text-ink'
: 'border-surface/30 text-surface',
)}
aria-expanded={open}
aria-controls="marketing-mobile-nav"
aria-label={open ? 'Close menu' : 'Open menu'}
onClick={() => setOpen((value) => !value)}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
</Container>
<div
id="marketing-mobile-nav"
className={cn(
'border-t border-border bg-surface lg:hidden',
open ? 'block' : 'hidden',
)}
>
<Container className="flex flex-col gap-1 py-4">
{navItems.map((item) => (
<NavAnchor
key={item.href}
href={item.href}
className="min-h-11 py-3 text-ink-muted hover:text-ink"
onClick={() => setOpen(false)}
>
{item.label}
</NavAnchor>
))}
{isAuthenticated ? (
<>
<Button to="/account" className="mt-2 w-full" onClick={() => setOpen(false)}>
Account
</Button>
<Button
type="button"
variant="secondary"
className="w-full"
onClick={() => {
logout()
setOpen(false)
navigate('/')
}}
>
Sign out
</Button>
</>
) : (
<>
<Button to="/login" className="mt-2 w-full" onClick={() => setOpen(false)}>
Sign in
</Button>
<Button href="#cta" className="w-full" onClick={() => setOpen(false)}>
Join the list
</Button>
</>
)}
</Container>
</div>
</header>
)
}

View file

@ -0,0 +1,20 @@
import { NavLink as RouterNavLink } from 'react-router-dom'
import { cn } from '../../utils/cn'
export function NavLink({ className, children, ...props }) {
return (
<RouterNavLink
className={({ isActive }) =>
cn(
'text-label uppercase text-ink-muted transition-colors hover:text-ink',
isActive && 'text-ink',
className,
)
}
{...props}
>
{children}
</RouterNavLink>
)
}

View file

@ -0,0 +1,76 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import { cn } from '../../utils/cn'
import { Button } from '../ui/Button'
import { Container } from '../ui/Container'
import { NavLink } from './NavLink'
const defaultLinks = [
{ to: '/system', label: 'Design system' },
]
export function Navbar({ links = defaultLinks, cta }) {
const [open, setOpen] = useState(false)
return (
<header className="sticky top-0 z-40 border-b border-border bg-surface/95 backdrop-blur-sm">
<Container>
<div className="flex h-16 items-center justify-between gap-6 md:h-[4.5rem]">
<Link
to="/"
className="font-display text-xl tracking-tight text-ink md:text-2xl"
>
Desi Clans
</Link>
<nav className="hidden items-center gap-8 md:flex" aria-label="Primary">
{links.map((link) => (
<NavLink key={link.to} to={link.to}>
{link.label}
</NavLink>
))}
{cta}
</nav>
<div className="flex items-center gap-2 md:hidden">
{cta ? <div className="hidden xs:block">{cta}</div> : null}
<button
type="button"
className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-border text-ink"
aria-expanded={open}
aria-controls="mobile-nav"
aria-label={open ? 'Close menu' : 'Open menu'}
onClick={() => setOpen((value) => !value)}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
</Container>
<div
id="mobile-nav"
className={cn(
'border-t border-border bg-surface-elevated md:hidden',
open ? 'block' : 'hidden',
)}
>
<Container className="flex flex-col gap-1 py-4">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
className="min-h-11 px-1 py-3"
onClick={() => setOpen(false)}
>
{link.label}
</NavLink>
))}
{cta ? <div className="pt-3">{cta}</div> : null}
</Container>
</div>
</header>
)
}

View file

@ -0,0 +1,20 @@
import { Container } from '../ui/Container'
import { Text } from '../typography/Text'
export function SiteFooter() {
return (
<footer className="border-t border-border-strong bg-ink text-surface">
<Container className="flex flex-col gap-6 py-10 md:flex-row md:items-end md:justify-between md:py-12">
<div>
<p className="font-display text-2xl tracking-tight">Desi Clans</p>
<Text as="p" tone="inverse" size="sm" className="mt-2 max-w-sm text-surface/70">
Premium Indian cultural events in Bengaluru — Holi, Garba, Dandiya & curated nights.
</Text>
</div>
<Text as="p" tone="inverse" size="sm" className="text-surface/50">
© {new Date().getFullYear()} Desi Clans
</Text>
</Container>
</footer>
)
}

View file

@ -0,0 +1,78 @@
import { formatEventDate, formatEventTimeRange } from '../../utils/format'
import { Badge } from '../ui/Badge'
import { Eyebrow } from '../typography/Eyebrow'
import { Heading } from '../typography/Heading'
import { Text } from '../typography/Text'
const statusBadge = {
valid: { label: 'Valid', variant: 'success' },
used: { label: 'Used', variant: 'default' },
cancelled: { label: 'Cancelled', variant: 'soldOut' },
}
export function DigitalTicket({ ticket }) {
const badge = statusBadge[ticket.status] ?? statusBadge.valid
const dateLabel = formatEventDate(ticket.event_starts_at)
const timeLabel = formatEventTimeRange(ticket.event_starts_at, ticket.event_ends_at)
return (
<article className="mx-auto max-w-md overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-lift">
<div className="h-1 bg-gradient-to-r from-gold via-accent to-festival" aria-hidden />
<header className="bg-ink px-6 py-8 text-surface md:px-8">
<Eyebrow className="text-gold-muted">Desi Clans</Eyebrow>
<p className="mt-2 font-display text-2xl tracking-tight">Digital pass</p>
<div className="mt-4 flex items-center justify-between gap-3">
<Badge variant={badge.variant}>{badge.label}</Badge>
<span className="text-caption uppercase text-surface/70">{ticket.code}</span>
</div>
</header>
<div className="border-b border-border px-6 py-8 md:px-8">
<Heading level={2} className="text-balance">{ticket.event_title}</Heading>
<Text tone="muted" className="mt-2">{dateLabel}</Text>
<Text tone="muted" size="sm" className="mt-1">{timeLabel}</Text>
<Text className="mt-4">
{ticket.venue_name}
{ticket.venue_area ? ` · ${ticket.venue_area}` : ''}
</Text>
<Text tone="muted" size="sm">{ticket.venue_city}</Text>
</div>
<div className="grid gap-6 px-6 py-8 md:grid-cols-2 md:px-8">
<div>
<p className="text-label uppercase text-ink-muted">Attendee</p>
<p className="mt-1 font-display text-lg text-ink">{ticket.attendee_name}</p>
<p className="text-body-sm text-ink-muted">{ticket.attendee_email}</p>
<p className="mt-6 text-label uppercase text-ink-muted">Pass type</p>
<p className="mt-1 text-body text-ink">{ticket.ticket_type_name}</p>
<p className="mt-6 text-label uppercase text-ink-muted">Ticket ID</p>
<p className="mt-1 font-mono text-sm text-ink">{ticket.code}</p>
</div>
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-surface-muted/60 p-4 transition-colors duration-medium">
{ticket.status === 'valid' ? (
<img
src={`data:image/png;base64,${ticket.qr_image_base64}`}
alt="QR code for venue entry"
className="h-40 w-40 bg-white p-2"
width={160}
height={160}
/>
) : (
<div className="flex h-40 w-40 items-center justify-center bg-surface px-4 text-center text-body-sm text-ink-muted">
QR unavailable — ticket {ticket.status}
</div>
)}
<Text tone="muted" size="sm" className="mt-3 text-center">
Show this code at the gate. Staff scan verifies on the server.
</Text>
</div>
</div>
{ticket.used_at ? (
<footer className="border-t border-border bg-surface-muted px-6 py-4 text-center text-body-sm text-ink-muted md:px-8">
Checked in {formatEventDate(ticket.used_at)}
</footer>
) : null}
</article>
)
}

View file

@ -0,0 +1,15 @@
import { cn } from '../../utils/cn'
export function Eyebrow({ className, children, ...props }) {
return (
<p
className={cn(
'text-label uppercase text-accent tracking-[0.12em]',
className,
)}
{...props}
>
{children}
</p>
)
}

View file

@ -0,0 +1,16 @@
import { cn } from '../../utils/cn'
const levelStyles = {
1: 'font-display text-display-xl text-ink',
2: 'font-display text-display-lg text-ink',
3: 'font-display text-display-md text-ink',
}
export function Heading({ level = 2, as, className, children, ...props }) {
const Tag = as ?? `h${level}`
return (
<Tag className={cn(levelStyles[level] ?? levelStyles[2], className)} {...props}>
{children}
</Tag>
)
}

View file

@ -0,0 +1,24 @@
import { cn } from '../../utils/cn'
const tones = {
default: 'text-ink',
muted: 'text-ink-muted',
faint: 'text-ink-faint',
inverse: 'text-surface',
accent: 'text-accent',
}
const sizes = {
body: 'text-body',
sm: 'text-body-sm',
caption: 'text-caption uppercase',
}
export function Text({ as = 'p', tone = 'default', size = 'body', className, children, ...props }) {
const Tag = as
return (
<Tag className={cn(sizes[size], tones[tone], className)} {...props}>
{children}
</Tag>
)
}

View file

@ -0,0 +1,25 @@
import { cn } from '../../utils/cn'
const variants = {
default: 'border-border bg-surface-muted text-ink-muted',
featured: 'border-gold-muted bg-surface text-ink',
accent: 'border-accent-subtle bg-accent-subtle text-accent-hover',
festival: 'border-festival-subtle bg-festival-subtle text-festival',
soldOut: 'border-border bg-surface text-ink-faint',
success: 'border-success-subtle bg-success-subtle text-success',
}
export function Badge({ variant = 'default', className, children, ...props }) {
return (
<span
className={cn(
'inline-flex items-center rounded-sm border px-2.5 py-1 text-caption font-medium normal-case tracking-wide',
variants[variant],
className,
)}
{...props}
>
{children}
</span>
)
}

View file

@ -0,0 +1,67 @@
import { forwardRef } from 'react'
import { Link } from 'react-router-dom'
import { cn } from '../../utils/cn'
const variants = {
primary:
'bg-accent text-white hover:bg-accent-hover border border-transparent shadow-sm hover:shadow-card motion-safe:hover:-translate-y-px',
secondary:
'bg-transparent text-ink border border-border-strong hover:border-ink hover:bg-surface-muted motion-safe:hover:-translate-y-px',
ghost: 'bg-transparent text-ink hover:bg-surface-muted border border-transparent',
dark: 'bg-surface text-ink hover:bg-surface/90 border border-transparent shadow-sm',
inverse:
'bg-transparent text-surface border border-surface/30 hover:border-surface hover:bg-white/5 motion-safe:hover:-translate-y-px',
}
const sizes = {
sm: 'min-h-10 px-4 text-label',
md: 'min-h-11 px-6 text-label',
lg: 'min-h-12 px-8 text-label',
}
export const Button = forwardRef(function Button(
{
variant = 'primary',
size = 'md',
className,
children,
to,
href,
type = 'button',
disabled,
...props
},
ref,
) {
const classes = cn(
'inline-flex items-center justify-center gap-2 rounded-lg uppercase transition-[color,background-color,border-color,box-shadow,transform] duration-medium ease-smooth',
'active:scale-[0.98] motion-reduce:active:scale-100',
'disabled:pointer-events-none disabled:opacity-45 disabled:shadow-none disabled:translate-y-0',
variants[variant],
sizes[size],
className,
)
if (to) {
return (
<Link ref={ref} to={to} className={classes} {...props}>
{children}
</Link>
)
}
if (href) {
return (
<a ref={ref} href={href} className={classes} {...props}>
{children}
</a>
)
}
return (
<button ref={ref} type={type} className={classes} disabled={disabled} {...props}>
{children}
</button>
)
})

View file

@ -0,0 +1,39 @@
import { cn } from '../../utils/cn'
const variants = {
border: 'border border-border bg-surface-elevated shadow-editorial',
flat: 'border border-border bg-surface-elevated',
editorial: 'border-l-2 border-accent bg-surface-elevated pl-6 shadow-none',
media: 'border border-border bg-ink text-surface overflow-hidden',
}
const paddings = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8 md:p-10',
}
export function Card({
as = 'article',
variant = 'border',
padding = 'md',
className,
children,
...props
}) {
const Tag = as
return (
<Tag
className={cn(
'rounded-sm',
variants[variant],
paddings[padding],
className,
)}
{...props}
>
{children}
</Tag>
)
}

View file

@ -0,0 +1,24 @@
import { cn } from '../../utils/cn'
const widths = {
default: 'max-w-content',
narrow: 'max-w-editorial',
prose: 'max-w-prose',
full: 'max-w-none',
}
export function Container({ as = 'div', width = 'default', className, children, ...props }) {
const Tag = as
return (
<Tag
className={cn(
'mx-auto w-full px-gutter sm:px-gutter-md lg:px-gutter-lg',
widths[width],
className,
)}
{...props}
>
{children}
</Tag>
)
}

View file

@ -0,0 +1,45 @@
import { cn } from '../../utils/cn'
import { Button } from './Button'
export function EmptyState({
icon,
title,
description,
actionLabel,
onAction,
actionTo,
actionVariant = 'secondary',
className,
}) {
return (
<div
className={cn(
'flex flex-col items-start border border-dashed border-border-strong bg-surface-muted/50 px-6 py-12 md:px-10 md:py-14',
className,
)}
>
{icon ? (
<div className="mb-5 text-ink-muted" aria-hidden>
{icon}
</div>
) : null}
{title ? <h3 className="font-display text-display-md text-ink">{title}</h3> : null}
{description ? (
<p className="mt-2 max-w-prose text-body-sm text-ink-muted">{description}</p>
) : null}
{actionLabel ? (
<div className="mt-8">
{actionTo ? (
<Button to={actionTo} variant={actionVariant} size="sm">
{actionLabel}
</Button>
) : (
<Button type="button" variant={actionVariant} size="sm" onClick={onAction}>
{actionLabel}
</Button>
)}
</div>
) : null}
</div>
)
}

View file

@ -0,0 +1,63 @@
import { cn } from '../../utils/cn'
import { Container } from './Container'
const tones = {
surface: 'bg-surface text-ink',
elevated: 'bg-surface-elevated text-ink border-y border-border',
ink: 'bg-ink text-surface',
muted: 'bg-surface-muted text-ink',
}
const spacings = {
sm: 'py-10 md:py-14',
md: 'py-section md:py-20',
lg: 'py-section md:py-section-lg',
}
export function Section({
tone = 'surface',
spacing = 'md',
containerWidth = 'default',
className,
containerClassName,
children,
...props
}) {
return (
<section className={cn(tones[tone], spacings[spacing], className)} {...props}>
<Container width={containerWidth} className={containerClassName}>
{children}
</Container>
</section>
)
}
export function SectionHeader({
eyebrow,
title,
description,
align = 'left',
action,
className,
}) {
return (
<div
className={cn(
'mb-10 md:mb-12 flex flex-col gap-4 md:flex-row md:items-end md:justify-between',
align === 'center' && 'md:flex-col md:items-center text-center',
className,
)}
>
<div className={cn('max-w-editorial space-y-3', align === 'center' && 'mx-auto')}>
{eyebrow ? <p className="text-label uppercase tracking-[0.12em] text-accent">{eyebrow}</p> : null}
{title ? (
<h2 className="font-display text-display-lg text-balance">{title}</h2>
) : null}
{description ? (
<p className="text-body text-ink-muted max-w-prose">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
)
}

View file

@ -0,0 +1,29 @@
import { cn } from '../../utils/cn'
export function Skeleton({ className, ...props }) {
return <div className={cn('ds-shimmer rounded-sm', className)} aria-hidden {...props} />
}
export function SkeletonText({ lines = 3, className }) {
return (
<div className={cn('space-y-3', className)} aria-hidden>
{Array.from({ length: lines }).map((_, index) => (
<Skeleton
key={index}
className={cn('h-4', index === lines - 1 ? 'w-4/5' : 'w-full')}
/>
))}
</div>
)
}
export function SkeletonCard({ className }) {
return (
<div className={cn('space-y-4', className)} aria-busy="true" aria-label="Loading content">
<Skeleton className="aspect-[4/5] w-full" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-7 w-4/5" />
<Skeleton className="h-4 w-full" />
</div>
)
}

View file

@ -0,0 +1,22 @@
import { cn } from '../../utils/cn'
const sizes = {
sm: 'h-4 w-4 border-2',
md: 'h-6 w-6 border-2',
lg: 'h-9 w-9 border-[3px]',
}
export function Spinner({ size = 'md', className, label = 'Loading' }) {
return (
<div className={cn('inline-flex items-center gap-3', className)} role="status">
<span
className={cn(
'inline-block animate-spin rounded-full border-current border-r-transparent',
sizes[size],
)}
aria-hidden
/>
<span className="sr-only">{label}</span>
</div>
)
}

View file

@ -0,0 +1,88 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import {
clearAccessToken,
fetchCurrentUser,
getAccessToken,
login as loginRequest,
logout as logoutSession,
register as registerRequest,
} from '../services/auth'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [status, setStatus] = useState('loading')
const refreshUser = useCallback(async () => {
const token = getAccessToken()
if (!token) {
setUser(null)
setStatus('anonymous')
return null
}
setStatus('loading')
try {
const profile = await fetchCurrentUser()
setUser(profile)
setStatus('authenticated')
return profile
} catch {
clearAccessToken()
setUser(null)
setStatus('anonymous')
return null
}
}, [])
useEffect(() => {
refreshUser()
}, [refreshUser])
const login = useCallback(
async (credentials) => {
await loginRequest(credentials)
return refreshUser()
},
[refreshUser],
)
const register = useCallback(
async (payload) => {
await registerRequest(payload)
return refreshUser()
},
[refreshUser],
)
const logout = useCallback(() => {
logoutSession()
setUser(null)
setStatus('anonymous')
}, [])
const value = useMemo(
() => ({
user,
status,
isAuthenticated: status === 'authenticated' && Boolean(user),
isLoading: status === 'loading',
login,
register,
logout,
refreshUser,
}),
[user, status, login, register, logout, refreshUser],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}

View file

@ -0,0 +1,251 @@
const img = (id, w = 1400) =>
`https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=75`
/** @typedef {'holi' | 'garba' | 'dandiya' | 'bhajan_clubbing' | 'special'} EventType */
/**
* @typedef {Object} MockEvent
* @property {string} slug
* @property {string} title
* @property {EventType} eventType
* @property {string} badge
* @property {string} badgeVariant
* @property {string | null} coverImage
* @property {string} coverImageAlt
* @property {string} city
* @property {string} dateLabel
* @property {string} timeLabel
* @property {string} gatesLabel
* @property {{ name: string, area: string, address: string, mapUrl: string, about: string, gettingThere: string }} venue
* @property {string[]} description
* @property {{ title: string, body: string }[]} highlights
* @property {{ src: string, alt: string }[]} gallery
* @property {{ id: string, name: string, priceInr: number, description: string, status: 'available' | 'sold_out' | 'coming_soon' }[]} ticketTiers
* @property {{ question: string, answer: string }[]} faq
*/
/** @type {Record<string, MockEvent>} */
const eventsBySlug = {
'rang-barse-palace-grounds-2026': {
slug: 'rang-barse-palace-grounds-2026',
title: 'Rang Barse — Palace Grounds',
eventType: 'holi',
badge: 'Featured · Holi',
badgeVariant: 'featured',
coverImage: img('photo-1582510003544-4d00b7f74220', 1920),
coverImageAlt: 'Festival crowd celebrating under open sky with colour in the air',
city: 'Bengaluru',
dateLabel: 'Sunday, 9 March 2026',
timeLabel: '10:00 AM – 6:00 PM',
gatesLabel: 'Gates open 9:30 AM',
venue: {
name: 'Palace Grounds',
area: 'Vasanth Nagar',
address: 'Palace Grounds, Jayamahal, Bengaluru, Karnataka 560006',
mapUrl: 'https://maps.google.com/?q=Palace+Grounds+Bengaluru',
about:
'One of the city’s largest open canvases for daylight Holi — wide lawns, structured colour zones, and room to breathe.',
gettingThere:
'Cab drop at Jayamahal gate. Limited parking — arrive early or use Metro + short ride.',
},
description: [
'Rang Barse is a daylight Holi edition built for scale without losing warmth: live dhol pockets, artisanal gulal stations, and food curated for long afternoons on the grass.',
'Families, college crews, and first-time Holi visitors share the same promise — safe colour play, clear timelines, and production that respects the festival rather than drowning it in noise.',
],
highlights: [
{ title: 'Artisanal colour zones', body: 'Skin-safe gulal with trained stewards — no random bag dumps.' },
{ title: 'Live dhol courtyards', body: 'Rotating percussion ensembles across the main lawn.' },
{ title: 'Food that matches the day', body: 'Regional snacks, hydration bars, and seated shade decks.' },
],
gallery: [
{ src: img('photo-1524492412937-b28074a5d7da', 1200), alt: 'Cloud of pink Holi powder above dancers' },
{ src: img('photo-1529156069898-49953e39b3ac', 1000), alt: 'Friends laughing with colour on their faces' },
{ src: img('photo-1582510003544-4d00b7f74220', 1000), alt: 'Wide shot of outdoor festival crowd' },
],
ticketTiers: [
{ id: 'ga', name: 'General admission', priceInr: 1499, description: 'Full-day access, colour kit included.', status: 'available' },
{ id: 'vip', name: 'Shade deck', priceInr: 2999, description: 'Reserved seating, priority entry, meal credit.', status: 'available' },
{ id: 'group', name: 'Group of 5', priceInr: 6499, description: 'Single QR for five guests — enter together.', status: 'coming_soon' },
],
faq: [
{ question: 'Are colours included?', answer: 'Yes — one sealed kit per GA ticket at check-in. Extras available on site.' },
{ question: 'Is this all ages?', answer: 'Yes. A supervised kids zone operates until 4 PM.' },
{ question: 'Re-entry allowed?', answer: 'Yes with wristband scan — keep your band intact.' },
],
},
'navratri-nights-ktpo': {
slug: 'navratri-nights-ktpo',
title: 'Navratri Nights',
eventType: 'garba',
badge: 'Garba',
badgeVariant: 'festival',
coverImage: img('photo-1516450360457-62c5678aefa8', 1920),
coverImageAlt: 'Garba dancers in traditional dress under evening lights',
city: 'Bengaluru',
dateLabel: 'Friday, 3 October 2026',
timeLabel: '7:00 PM – 12:30 AM',
gatesLabel: 'Doors 6:30 PM',
venue: {
name: 'KTPO Convention Centre',
area: 'Whitefield',
address: 'KTPO, Whitefield Main Rd, Bengaluru, Karnataka 560066',
mapUrl: 'https://maps.google.com/?q=KTPO+Whitefield',
about: 'Convention-hall scale with a circular floor plan engineered for Garba flow.',
gettingThere: 'Whitefield Metro + 10 min cab. Venue parking available with prepaid pass.',
},
description: [
'Nine nights energy compressed into a single Whitefield weekend — live folk vocalists, traditional garba segments, and a late-night DJ set that never forgets the raas.',
'Dress code is celebration: chaniya choli, kediyu, or contemporary Indian — just bring comfortable footwear for the floor.',
],
highlights: [
{ title: 'Circular floor design', body: 'No dead corners — sightlines to the mandap from every ring.' },
{ title: 'Live folk + curated DJ', body: 'Sets transition with live percussion bridges.' },
{ title: 'Chai & fasting-friendly food', body: 'Counters open through midnight.' },
],
gallery: [
{ src: img('photo-1516450360457-62c5678aefa8', 1100), alt: 'Garba performers in coordinated movement' },
{ src: img('photo-1516450360457-62c5678aefa8', 1100), alt: 'Evening garba celebration' },
],
ticketTiers: [
{ id: 'floor', name: 'Dance floor', priceInr: 999, description: 'Standing access to main circle.', status: 'available' },
{ id: 'balcony', name: 'Balcony lounge', priceInr: 1899, description: 'Elevated seating, table service.', status: 'sold_out' },
],
faq: [
{ question: 'Are dandiya sticks provided?', answer: 'Pairs available for purchase at check-in; bring your own if you prefer.' },
{ question: 'Dress code?', answer: 'Traditional Indian festive wear encouraged, not mandatory.' },
],
},
'hsr-dandiya-social': {
slug: 'hsr-dandiya-social',
title: 'HSR Dandiya Social',
eventType: 'dandiya',
badge: 'Dandiya',
badgeVariant: 'accent',
coverImage: img('photo-1533174072545-7a4b6ad7a6c3', 1920),
coverImageAlt: 'Lanterns and lights at an evening cultural gathering',
city: 'Bengaluru',
dateLabel: 'Saturday, 11 October 2026',
timeLabel: '8:00 PM – 1:00 AM',
gatesLabel: 'Doors 7:45 PM',
venue: {
name: 'HSR Layout Club Grounds',
area: 'HSR Layout',
address: '27th Main Rd, HSR Layout, Bengaluru, Karnataka 560102',
mapUrl: 'https://maps.google.com/?q=HSR+Layout+Bengaluru',
about: 'Neighbourhood scale — intimate lighting, familiar faces, high energy Dandiya.',
gettingThere: 'Street parking on adjacent blocks; ride-share drop at 27th Main arch.',
},
description: [
'A social Dandiya built for South Bengaluru — smaller floor, louder laughter, and playlists that know when to slow down for classics.',
],
highlights: [
{ title: 'Resident Dandiya callers', body: 'Guided segments for beginners every hour.' },
{ title: 'Midnight chai wallah', body: 'Cardamom chai and hot jalebis from 11 PM.' },
],
gallery: [
{ src: img('photo-1492684223066-81342ee5ff30', 1000), alt: 'Festival lights over dance floor' },
],
ticketTiers: [
{ id: 'early', name: 'Early bird', priceInr: 799, description: 'Limited batch — full night access.', status: 'available' },
{ id: 'door', name: 'Door', priceInr: 1099, description: 'If available after early bird sells.', status: 'coming_soon' },
],
faq: [
{ question: 'Is this couples-only?', answer: 'Not at all — solo dancers and groups are welcome.' },
],
},
'bhajan-under-the-trees': {
slug: 'bhajan-under-the-trees',
title: 'Bhajan Under the Trees',
eventType: 'special',
badge: 'Bhajan clubbing',
badgeVariant: 'default',
coverImage: img('photo-1470229722913-7c0e2dbbafd3', 1920),
coverImageAlt: 'Warm stage lighting at an intimate outdoor gathering',
city: 'Bengaluru',
dateLabel: 'Thursday, 16 October 2026',
timeLabel: '7:30 PM – 11:00 PM',
gatesLabel: 'Gates 7:00 PM',
venue: {
name: 'Indiranagar Park Lawn',
area: 'Indiranagar',
address: '100 Feet Rd, Indiranagar, Bengaluru, Karnataka 560038',
mapUrl: 'https://maps.google.com/?q=Indiranagar+Bengaluru',
about: 'Boutique open-air edition — string lights, low stage, devotional-meets-club energy.',
gettingThere: 'Metro: Indiranagar station. No public parking on lawn — use 100 Feet Rd garages.',
},
description: [
'An evening of call-and-response bhajans woven with subtle electronic textures — respectful, danceable, and unlike any nightclub listing.',
],
highlights: [
{ title: 'Acoustic-first mix', body: 'Live harmonium and tabla with restrained production.' },
{ title: 'Seated + standing zones', body: 'Choose calm listening or floor participation.' },
],
gallery: [
{ src: img('photo-1506157786151-b8491531f063', 1000), alt: 'Audience facing warmly lit stage' },
{ src: img('photo-1470229722913-7c0e2dbbafd3', 1000), alt: 'Ambient evening performance lighting' },
],
ticketTiers: [
{ id: 'standard', name: 'Standard', priceInr: 699, description: 'Standing / seated lawn access.', status: 'available' },
],
faq: [
{ question: 'Is photography allowed?', answer: 'Phones welcome without flash during live sets.' },
],
},
'urban-garba-xperience-ktpo-whitefield-navratri-weekend-edition-2026': {
slug: 'urban-garba-xperience-ktpo-whitefield-navratri-weekend-edition-2026',
title:
'Urban Garba Xperience — KTPO Whitefield Navratri Weekend Edition with Live Folk Ensemble & Midnight Raas',
eventType: 'garba',
badge: 'Garba · Preview',
badgeVariant: 'festival',
coverImage: null,
coverImageAlt: '',
city: 'Bengaluru',
dateLabel: 'Saturday, 4 October 2026',
timeLabel: '6:00 PM – 1:00 AM',
gatesLabel: 'Doors 5:30 PM',
venue: {
name: 'KTPO Convention Centre',
area: 'Whitefield',
address: 'KTPO, Whitefield Main Rd, Bengaluru, Karnataka 560066',
mapUrl: 'https://maps.google.com/?q=KTPO+Whitefield',
about: 'Placeholder edition for layout QA — cover image intentionally omitted.',
gettingThere: 'Same as Navratri Nights — Metro Whitefield + cab.',
},
description: [
'This mock edition stress-tests long titles, missing hero imagery, and compact ticket copy on small screens.',
],
highlights: [
{ title: 'Layout stress test', body: 'Long headline wrapping and empty cover fallback.' },
],
gallery: [],
ticketTiers: [
{ id: 'qa', name: 'Preview pass', priceInr: 0, description: 'Complimentary mock tier for UI testing.', status: 'available' },
],
faq: [
{ question: 'Why is there no cover image?', answer: 'To verify the premium fallback state in development.' },
],
},
}
export function getEventBySlug(slug) {
if (!slug) {
return null
}
return eventsBySlug[slug] ?? null
}
export function listMockEventSlugs() {
return Object.keys(eventsBySlug)
}
export function formatInr(amount) {
if (amount === 0) {
return 'Free'
}
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
}).format(amount)
}

View file

@ -0,0 +1,162 @@
const img = (id, w = 1400, q = 75) =>
`https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=${q}`
/** Home hero — keep in sync with `index.html` preload link. */
export const HERO_IMAGE_URL =
'https://plus.unsplash.com/premium_photo-1697729606469-027395aadb6f?auto=format&fit=crop&w=1920&q=80'
export const heroPreloadImageUrl =
'https://plus.unsplash.com/premium_photo-1697729606469-027395aadb6f?auto=format&fit=crop&w=1600&q=75'
export const heroContent = {
eyebrow: 'Bengaluru · Indian cultural calendar',
title: 'Where the city celebrates in colour and rhythm',
subtitle:
'Curated Holi, Garba, Dandiya and devotional nights across Palace Grounds, Whitefield, HSR and beyond — editorial experiences, not concert listings.',
image: HERO_IMAGE_URL,
imageAlt: 'Taj Mahal and Indian heritage architecture under a bright blue sky',
}
export const featuredEvent = {
slug: 'rang-barse-palace-grounds-2026',
title: 'Rang Barse — Palace Grounds',
meta: 'Sunday, 9 March 2026 · Gates 10 AM',
venue: 'Palace Grounds, Bengaluru',
description:
'A daylight Holi edition with live dhol, artisanal colours, and curated food courts — designed for families and crews who want scale without chaos.',
badge: 'Featured edition',
badgeVariant: 'featured',
imageUrl: img('photo-1582510003544-4d00b7f74220', 1600),
imageAlt: 'Festival crowd dancing under open sky at a cultural celebration',
}
export const upcomingEvents = [
{
slug: 'navratri-nights-ktpo',
title: 'Navratri Nights',
meta: 'Fri, 3 Oct · Garba',
venue: 'KTPO Convention Centre, Whitefield',
badge: 'Garba',
badgeVariant: 'festival',
imageUrl: img('photo-1516450360457-62c5678aefa8', 900),
imageAlt: 'Women in traditional attire performing garba dance',
},
{
slug: 'hsr-dandiya-social',
title: 'HSR Dandiya Social',
meta: 'Sat, 11 Oct · Dandiya',
venue: 'HSR Layout',
badge: 'Dandiya',
badgeVariant: 'accent',
imageUrl: img('photo-1533174072545-7a4b6ad7a6c3', 900),
imageAlt: 'Decorative lanterns and lights at an evening cultural event',
},
{
slug: 'bhajan-under-the-trees',
title: 'Bhajan Under the Trees',
meta: 'Thu, 16 Oct · Special',
venue: 'Indiranagar',
badge: 'Bhajan clubbing',
badgeVariant: 'default',
imageUrl: img('photo-1470229722913-7c0e2dbbafd3', 900),
imageAlt: 'Warm stage lighting at an intimate live music gathering',
},
]
export const categories = [
{
id: 'holi',
name: 'Holi',
tagline: 'Colour, dhol & daylight joy',
imageUrl: img('photo-1529156069898-49953e39b3ac', 800),
imageAlt: 'Friends celebrating Holi with gulal',
},
{
id: 'garba',
name: 'Garba',
tagline: 'Nine nights of circular devotion',
imageUrl: img('photo-1578662996442-48f60103fc96', 800),
imageAlt: 'Traditional garba dancers in coordinated movement',
},
{
id: 'dandiya',
name: 'Dandiya',
tagline: 'Sticks, swagger & social energy',
imageUrl: img('photo-1492684223066-81342ee5ff30', 800),
imageAlt: 'Evening festival lights over a dance floor',
},
{
id: 'special',
name: 'Cultural nights',
tagline: 'Bhajan, fusion & curated editions',
imageUrl: img('photo-1506157786151-b8491531f063', 800),
imageAlt: 'Audience silhouettes facing a warmly lit stage',
},
]
export const venues = [
{
name: 'Palace Grounds',
area: 'Vasanth Nagar',
note: 'Large-format Holi & open-air editions',
},
{
name: 'KTPO Whitefield',
area: 'Whitefield',
note: 'Convention-scale Garba & Dandiya',
},
{
name: 'HSR Layout',
area: 'South Bengaluru',
note: 'Neighbourhood socials & intimate nights',
},
{
name: 'Indiranagar & Koramangala',
area: 'Central',
note: 'Boutique bhajan and fusion experiences',
},
]
export const galleryImages = [
{
src: img('photo-1524492412937-b28074a5d7da', 1200),
alt: 'Holi colour cloud rising above celebrating crowd',
span: 'tall',
},
{
src: img('photo-1516450360457-62c5678aefa8', 1000),
alt: 'Garba dancers in traditional dress',
span: 'wide',
},
{
src: img('photo-1533174072545-7a4b6ad7a6c3', 1000),
alt: 'Festival lanterns at night',
span: 'square',
},
{
src: img('photo-1582510003544-4d00b7f74220', 1200),
alt: 'Open-air cultural festival at dusk',
span: 'wide',
},
{
src: img('photo-1506157786151-b8491531f063', 1000),
alt: 'Concert-style lights at a live performance',
span: 'tall',
},
{
src: img('photo-1470229722913-7c0e2dbbafd3', 1000),
alt: 'Warm ambient lighting over an audience',
span: 'square',
},
]
export const brandStory = {
eyebrow: 'Our story',
title: 'A editorial lens on Bengaluru’s festival calendar',
paragraphs: [
'Desi Clans began as a love letter to the city’s cultural weekends — the smell of gulal on Palace Grounds, the first beat of dhol at KTPO, the laughter spilling out of HSR parking lots after midnight Dandiya.',
'We work with organisers who care about craft: thoughtful lineups, safe crowds, honest timelines, and photography that feels like memory — not stock hype.',
],
pullQuote:
'Premium doesn’t mean exclusive for the sake of it. It means respect for the art, the audience, and the city that hosts us.',
}

View file

View file

@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react'
/**
* @template T
* @param {() => Promise<T>} queryFn
* @param {unknown[]} deps
*/
export function useAsyncQuery(queryFn, deps = []) {
const [status, setStatus] = useState('loading')
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const execute = useCallback(async () => {
setStatus('loading')
setError(null)
try {
const result = await queryFn()
setData(result)
setStatus('success')
return result
} catch (err) {
setError(err)
setStatus('error')
setData(null)
return null
}
}, deps)
useEffect(() => {
execute()
}, [execute])
return {
status,
data,
error,
retry: execute,
isLoading: status === 'loading',
isError: status === 'error',
isSuccess: status === 'success',
}
}

View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
const queries = {
xs: '(min-width: 390px)',
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
'2xl': '(min-width: 1440px)',
}
export function useBreakpoint() {
const [breakpoint, setBreakpoint] = useState('base')
useEffect(() => {
const mediaQueryLists = Object.entries(queries).map(([name, query]) => ({
name,
mql: window.matchMedia(query),
}))
const update = () => {
let current = 'base'
for (const { name, mql } of mediaQueryLists) {
if (mql.matches) {
current = name
}
}
setBreakpoint(current)
}
update()
for (const { mql } of mediaQueryLists) {
mql.addEventListener('change', update)
}
return () => {
for (const { mql } of mediaQueryLists) {
mql.removeEventListener('change', update)
}
}
}, [])
return breakpoint
}

View file

@ -0,0 +1,111 @@
import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { cancelOrder, createOrder, mockCompletePayment, verifyOrderPayment } from '../services/orders'
function loadRazorpayScript() {
return new Promise((resolve, reject) => {
if (window.Razorpay) {
resolve(window.Razorpay)
return
}
const script = document.createElement('script')
script.src = 'https://checkout.razorpay.com/v1/checkout.js'
script.async = true
script.onload = () => resolve(window.Razorpay)
script.onerror = () => reject(new Error('Could not load payment checkout'))
document.body.appendChild(script)
})
}
export function useCheckout({ eventId }) {
const { isAuthenticated } = useAuth()
const navigate = useNavigate()
const [status, setStatus] = useState('idle')
const [error, setError] = useState('')
const startCheckout = useCallback(
async (tier) => {
if (!isAuthenticated) {
navigate('/login', { state: { from: window.location.pathname } })
return
}
setError('')
setStatus('creating')
try {
const checkout = await createOrder({
eventId,
items: [{ ticketTypeId: tier.id, quantity: 1 }],
idempotencyKey: crypto.randomUUID(),
})
const payment = checkout.payment
if (payment.provider === 'mock') {
setStatus('paying')
await mockCompletePayment(checkout.id)
setStatus('success')
navigate('/account', { replace: true })
return
}
if (payment.provider === 'razorpay') {
setStatus('paying')
const Razorpay = await loadRazorpayScript()
await new Promise((resolve, reject) => {
const rzp = new Razorpay({
key: payment.key_id,
amount: payment.amount_paise,
currency: payment.currency,
name: 'Desi Clans',
description: 'Event tickets',
order_id: payment.provider_order_id,
handler: async (response) => {
try {
await verifyOrderPayment(checkout.id, {
provider_order_id: response.razorpay_order_id,
provider_payment_id: response.razorpay_payment_id,
signature: response.razorpay_signature,
})
resolve()
} catch (verifyError) {
reject(verifyError)
}
},
modal: {
ondismiss: async () => {
try {
await cancelOrder(checkout.id)
} catch {
// ignore cancel errors
}
reject(new Error('Payment cancelled'))
},
},
})
rzp.on('payment.failed', async () => {
try {
await cancelOrder(checkout.id)
} catch {
// ignore
}
reject(new Error('Payment failed'))
})
rzp.open()
})
setStatus('success')
navigate('/account', { replace: true })
return
}
throw new Error('Unsupported payment provider')
} catch (err) {
setStatus('error')
setError(err.message ?? 'Checkout failed')
}
},
[eventId, isAuthenticated, navigate],
)
return { startCheckout, status, error, isBusy: status === 'creating' || status === 'paying' }
}

View file

@ -0,0 +1,11 @@
import { useEffect } from 'react'
export function useDocumentTitle(title, suffix = 'Desi Clans') {
useEffect(() => {
const previous = document.title
document.title = title ? `${title} · ${suffix}` : suffix
return () => {
document.title = previous
}
}, [title, suffix])
}

View file

@ -0,0 +1,92 @@
import { useEffect, useMemo, useState } from 'react'
import {
buildGalleryImagesFromEvents,
fetchEventBySlug,
fetchEvents,
mapEventDetailView,
mapEventToCard,
mapFeaturedEvent,
pickFeaturedEvent,
} from '../services/events'
import { useAsyncQuery } from './useAsyncQuery'
export function useEventsList(options = {}) {
const { upcoming = true, status = 'published' } = options
const query = useAsyncQuery(
() => fetchEvents({ upcoming, status }),
[upcoming, status],
)
const events = query.data ?? []
const featuredSummary = useMemo(() => pickFeaturedEvent(events), [events])
const [featuredDetail, setFeaturedDetail] = useState(null)
useEffect(() => {
const slug = featuredSummary?.slug
if (!slug) {
setFeaturedDetail(null)
return undefined
}
let cancelled = false
const loadDetail = () => {
fetchEventBySlug(slug).then((detail) => {
if (!cancelled && detail) {
setFeaturedDetail(detail)
}
})
}
if (typeof requestIdleCallback === 'function') {
const id = requestIdleCallback(loadDetail, { timeout: 2500 })
return () => {
cancelled = true
cancelIdleCallback(id)
}
}
const timeoutId = window.setTimeout(loadDetail, 400)
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [featuredSummary?.slug])
const cards = useMemo(() => events.map(mapEventToCard), [events])
const featured = useMemo(() => {
const source = featuredDetail ?? featuredSummary
return mapFeaturedEvent(source)
}, [featuredDetail, featuredSummary])
const galleryImages = useMemo(() => buildGalleryImagesFromEvents(events), [events])
return {
...query,
events,
cards,
featured,
galleryImages,
isEmpty: query.isSuccess && events.length === 0,
}
}
export function useEventDetail(slug) {
const query = useAsyncQuery(
() => (slug ? fetchEventBySlug(slug) : Promise.resolve(null)),
[slug],
)
const event = useMemo(
() => (query.data ? mapEventDetailView(query.data) : null),
[query.data],
)
return {
...query,
event,
isNotFound: query.isSuccess && !query.data,
}
}

View file

@ -0,0 +1,19 @@
import { useMemo } from 'react'
import { fetchVenues, mapVenueToRow } from '../services/venues'
import { useAsyncQuery } from './useAsyncQuery'
export function useVenuesList() {
const query = useAsyncQuery(() => fetchVenues(), [])
const venues = useMemo(
() => (query.data ?? []).map(mapVenueToRow),
[query.data],
)
return {
...query,
venues,
isEmpty: query.isSuccess && venues.length === 0,
}
}

View file

@ -0,0 +1,21 @@
import { Outlet } from 'react-router-dom'
import { Navbar, SiteFooter, Button } from '../components'
export function AppLayout() {
return (
<div className="flex min-h-screen flex-col">
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-sm focus:bg-accent focus:px-3 focus:py-2 focus:text-white"
>
Skip to main content
</a>
<Navbar cta={<Button to="/system" size="sm" variant="secondary">System</Button>} />
<main id="main" className="flex-1">
<Outlet />
</main>
<SiteFooter />
</div>
)
}

View file

@ -0,0 +1,27 @@
import { Suspense } from 'react'
import { Outlet } from 'react-router-dom'
import { RouteFallback } from '../components/common/RouteFallback'
import { MarketingFooter } from '../components/home/MarketingFooter'
import { MarketingNavbar } from '../components/navigation/MarketingNavbar'
export function MarketingLayout() {
return (
<div className="flex min-h-screen flex-col overflow-x-hidden bg-surface">
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[60] focus:rounded-sm focus:bg-accent focus:px-3 focus:py-2 focus:text-white"
>
Skip to main content
</a>
<MarketingNavbar />
<main id="main" className="flex-1">
<Suspense fallback={<RouteFallback />}>
<Outlet />
</Suspense>
</main>
<MarketingFooter />
</div>
)
}

17
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { AuthProvider } from './context/AuthContext.jsx'
import './styles/index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)

View file

@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { AuthFormLayout } from '../components/auth/AuthFormLayout'
import { ApiErrorState } from '../components/common/RequestState'
import { Button } from '../components/ui/Button'
import { Spinner } from '../components/ui/Spinner'
import { Text } from '../components/typography/Text'
import { useAuth } from '../context/AuthContext'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import { fetchMyTickets } from '../services/tickets'
export function AccountPage() {
const { user, logout } = useAuth()
const [tickets, setTickets] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useDocumentTitle('Your account')
useEffect(() => {
fetchMyTickets()
.then(setTickets)
.catch(setError)
.finally(() => setLoading(false))
}, [])
return (
<AuthFormLayout title="Your account" description="Signed in to Desi Clans.">
<dl className="space-y-4">
<div>
<dt className="text-label uppercase text-ink-muted">Email</dt>
<dd className="mt-1 font-display text-xl text-ink">{user?.email}</dd>
</div>
{user?.full_name ? (
<div>
<dt className="text-label uppercase text-ink-muted">Name</dt>
<dd className="mt-1 text-body text-ink">{user.full_name}</dd>
</div>
) : null}
</dl>
<div className="mt-10 border-t border-border pt-8">
<h2 className="font-display text-xl text-ink">Your tickets</h2>
{loading ? (
<div className="mt-6">
<Spinner label="Loading tickets" />
</div>
) : null}
{error ? (
<ApiErrorState className="mt-6" title="Could not load tickets" error={error} onRetry={() => window.location.reload()} />
) : null}
{!loading && !error && tickets.length === 0 ? (
<Text tone="muted" className="mt-4">No tickets yet — grab a pass from an event page.</Text>
) : null}
{!loading && !error && tickets.length > 0 ? (
<ul className="mt-4 divide-y divide-border border-y border-border">
{tickets.map((ticket) => (
<li key={ticket.id} className="flex flex-wrap items-center justify-between gap-3 py-4">
<div>
<p className="font-display text-lg text-ink">{ticket.event_title ?? 'Event'}</p>
<Text tone="muted" size="sm">{ticket.ticket_type_name} · {ticket.code}</Text>
<Text tone="muted" size="sm" className="capitalize">{ticket.status}</Text>
</div>
<Link
to={`/tickets/${ticket.id}`}
className="text-label uppercase text-accent hover:text-ink"
>
View pass
</Link>
</li>
))}
</ul>
) : null}
</div>
<Button type="button" variant="secondary" className="mt-8" onClick={logout}>
Sign out
</Button>
</AuthFormLayout>
)
}

View file

@ -0,0 +1,193 @@
import { Calendar, Sparkles } from 'lucide-react'
import {
Badge,
Button,
Card,
EmptyState,
EventCard,
Eyebrow,
Heading,
Section,
SectionHeader,
SectionSplit,
SkeletonCard,
Spinner,
Text,
} from '../components'
import { useBreakpoint } from '../hooks/useBreakpoint'
const swatches = [
{ name: 'Ink', className: 'bg-ink' },
{ name: 'Surface', className: 'bg-surface border border-border' },
{ name: 'Accent', className: 'bg-accent' },
{ name: 'Gold', className: 'bg-gold' },
{ name: 'Festival', className: 'bg-festival' },
]
export function DesignSystemPage() {
const breakpoint = useBreakpoint()
return (
<>
<Section tone="ink" spacing="lg">
<div className="max-w-editorial">
<Eyebrow className="text-gold-muted">Desi Clans</Eyebrow>
<Heading level={1} className="mt-4 text-surface">
Design system
</Heading>
<Text tone="inverse" className="mt-6 max-w-prose text-surface/75">
Luxury editorial meets premium festival branding — cinematic spacing, terracotta accent,
sharp geometry. Not a SaaS dashboard.
</Text>
<p className="mt-8 text-caption uppercase text-surface/50">
Active breakpoint: <span className="text-surface">{breakpoint}</span>
</p>
</div>
</Section>
<Section>
<SectionHeader
eyebrow="Typography"
title="Display & body rhythm"
description="Fraunces for ceremony, DM Sans for clarity. Fluid display sizes via clamp."
/>
<div className="grid gap-10 md:grid-cols-2">
<div className="space-y-4">
<Heading level={1}>Holi at Palace Grounds</Heading>
<Heading level={2}>Garba under city lights</Heading>
<Heading level={3}>Dandiya — KTPO Whitefield</Heading>
</div>
<div className="space-y-4 border-l border-border pl-6">
<Text>Body text for narratives, venue copy, and trust-building detail.</Text>
<Text tone="muted" size="sm">
Meta lines — dates, neighborhoods, capacity notes.
</Text>
<Eyebrow>Bengaluru · Cultural calendar</Eyebrow>
</div>
</div>
</Section>
<Section tone="muted">
<SectionHeader eyebrow="Color" title="Token palette" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{swatches.map((swatch) => (
<div key={swatch.name}>
<div className={swatch.className + ' aspect-[4/3] rounded-sm'} />
<p className="mt-2 text-body-sm text-ink-muted">{swatch.name}</p>
</div>
))}
</div>
</Section>
<Section>
<SectionHeader eyebrow="Actions" title="Buttons & badges" />
<div className="flex flex-wrap gap-3">
<Button>Get tickets</Button>
<Button variant="secondary">View lineup</Button>
<Button variant="ghost">Join the list</Button>
<Button variant="dark">On dark surface</Button>
</div>
<div className="mt-8 flex flex-wrap gap-2">
<Badge>Holi</Badge>
<Badge variant="featured">Featured</Badge>
<Badge variant="festival">Garba</Badge>
<Badge variant="soldOut">Sold out</Badge>
<Badge variant="success">Checked in</Badge>
</div>
</Section>
<Section tone="elevated">
<SectionHeader eyebrow="Layout" title="Sections & cards" />
<SectionSplit
main={
<Card variant="editorial" padding="lg">
<Eyebrow>Editorial card</Eyebrow>
<Heading level={3} className="mt-3">
Left accent bar, minimal radius
</Heading>
<Text tone="muted" className="mt-4">
Use for pull quotes, founder notes, or festival essays — not generic feature grids.
</Text>
</Card>
}
aside={
<Card padding="lg">
<Text size="sm" tone="muted">Container</Text>
<Text className="mt-2">
Gutters scale from mobile to desktop. Sections use generous vertical rhythm.
</Text>
</Card>
}
/>
</Section>
<Section>
<SectionHeader
eyebrow="Events"
title="Card grid"
description="Responsive: 1 → 2 → 3 columns. Featured span on md+."
action={<Button variant="secondary" size="sm">All events</Button>}
/>
<div className="grid gap-10 md:grid-cols-2 xl:grid-cols-3">
<EventCard
featured
badge="Featured"
badgeVariant="featured"
title="Rang Barse — Palace Grounds"
meta="Sun, 9 Mar · Holi"
venue="Palace Grounds, Bengaluru"
imageUrl=""
/>
<EventCard
badge="Garba"
badgeVariant="festival"
title="Navratri Nights"
meta="Fri, 3 Oct"
venue="KTPO Whitefield"
imageUrl=""
/>
<EventCard
badge="Dandiya"
title="HSR Dandiya Social"
meta="Sat, 11 Oct"
venue="HSR Layout"
imageUrl=""
/>
</div>
</Section>
<Section tone="ink" spacing="md">
<SectionHeader
eyebrow="States"
title="Loading & empty"
description="Shimmer skeletons and quiet empty panels."
/>
<div className="grid gap-8 lg:grid-cols-2">
<div className="rounded-sm border border-white/10 bg-white/5 p-6">
<Spinner size="lg" className="text-surface" />
<SkeletonCard className="mt-8 [&_.ds-shimmer]:opacity-40" />
</div>
<EmptyState
icon={<Calendar className="h-8 w-8" />}
title="No editions this week"
description="When new Holi or Garba dates go live, they will appear here. Subscribe for first access."
actionLabel="Join the list"
actionVariant="inverse"
onAction={() => {}}
className="border-white/15 bg-white/5 text-surface [&_h3]:text-surface [&_p]:text-surface/70"
/>
</div>
</Section>
<Section spacing="sm">
<div className="flex items-center gap-2 text-ink-muted">
<Sparkles className="h-4 w-4 text-accent" aria-hidden />
<Text size="sm" tone="muted">
Import from <code className="text-ink">src/components</code> — build pages, not one-off styles.
</Text>
</div>
</Section>
</>
)
}

View file

@ -0,0 +1,138 @@
import { useRef } from 'react'
import { useParams } from 'react-router-dom'
import { useEventDetailAnimations } from '../animations/useEventDetailAnimations'
import {
EventDescription,
EventDetailGallery,
EventDetailHeader,
EventDetailHero,
EventFaq,
EventHighlights,
EventMobileTicketBar,
EventScheduleLocation,
EventTicketCta,
EventTicketTiers,
EventVenueInfo,
} from '../components/event'
import { ApiErrorState, EventDetailSkeleton } from '../components/common/RequestState'
import { Container } from '../components/ui/Container'
import { EmptyState } from '../components/ui/EmptyState'
import { useCheckout } from '../hooks/useCheckout'
import { useEventDetail } from '../hooks/useEvents'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
function getLowestAvailableInr(tiers) {
const available = tiers
.filter((tier) => tier.status === 'available' || tier.status === 'active')
.map((tier) => tier.priceInr)
if (!available.length) {
return undefined
}
return Math.min(...available)
}
export function EventDetailsPage() {
const heroImageRef = useRef(null)
const { slug } = useParams()
const { event, isLoading, isError, isNotFound, error, retry } = useEventDetail(slug)
useEventDetailAnimations({ heroImageRef, enabled: Boolean(event) })
const { startCheckout, error: checkoutError, isBusy: isCheckoutBusy } = useCheckout({
eventId: event?.id,
})
useDocumentTitle(event?.title ?? (isNotFound ? 'Edition not found' : 'Event'))
if (isLoading) {
return <EventDetailSkeleton />
}
if (isError) {
return (
<Container className="py-section">
<ApiErrorState title="Could not load this edition" error={error} onRetry={retry} />
</Container>
)
}
if (isNotFound || !event) {
return (
<Container className="py-section">
<EmptyState
title="Edition not found"
description="This event is not in our Bengaluru calendar yet — the slug may be outdated or incorrect."
actionLabel="Back to home"
actionTo="/"
/>
</Container>
)
}
const lowestPrice = getLowestAvailableInr(event.ticketTiers)
return (
<>
<EventDetailHero
coverImage={event.coverImage}
coverImageAlt={event.coverImageAlt}
badge={event.badge}
badgeVariant={event.badgeVariant}
title={event.title}
heroImageRef={heroImageRef}
/>
<div className="relative">
<div className="section-fade-top" aria-hidden />
<Container className="pb-24 lg:pb-16">
<div className="lg:grid lg:grid-cols-12 lg:gap-14 xl:gap-16">
<div className="lg:col-span-7 xl:col-span-8">
<div data-event-reveal>
<EventDetailHeader title={event.title} city={event.city} eventType={event.eventType} />
</div>
<div data-event-reveal>
<EventScheduleLocation
dateLabel={event.dateLabel}
timeLabel={event.timeLabel}
gatesLabel={event.gatesLabel}
venueName={event.venue.name}
venueArea={event.venue.area}
/>
</div>
<div data-event-reveal>
<EventDescription paragraphs={event.description} />
</div>
<div data-event-reveal>
<EventHighlights highlights={event.highlights} />
</div>
<div data-event-reveal>
<EventDetailGallery gallery={event.gallery} />
</div>
<div data-event-reveal>
<EventVenueInfo venue={event.venue} />
</div>
<div data-event-reveal>
<EventFaq faq={event.faq} />
</div>
</div>
<aside className="mt-10 lg:col-span-5 xl:col-span-4 lg:mt-0">
<div className="lg:sticky lg:top-28" data-event-tickets>
<EventTicketTiers
eventId={event.id}
ticketTiers={event.ticketTiers}
onSelectTier={startCheckout}
checkoutError={checkoutError}
isCheckoutBusy={isCheckoutBusy}
/>
</div>
</aside>
</div>
</Container>
</div>
<EventTicketCta title={event.title} />
<EventMobileTicketBar lowestAvailableInr={lowestPrice} />
</>
)
}

View file

@ -0,0 +1,54 @@
import { useRef } from 'react'
import { useHomeScrollAnimations } from '../animations/useHomeScrollAnimations'
import { BrandStorySection } from '../components/home/BrandStorySection'
import { CategoriesSection } from '../components/home/CategoriesSection'
import { FeaturedEventSection } from '../components/home/FeaturedEventSection'
import { FinalCtaSection } from '../components/home/FinalCtaSection'
import { GallerySection } from '../components/home/GallerySection'
import { HomeHero } from '../components/home/HomeHero'
import { UpcomingEventsSection } from '../components/home/UpcomingEventsSection'
import { VenuesSection } from '../components/home/VenuesSection'
import { useEventsList } from '../hooks/useEvents'
import { useVenuesList } from '../hooks/useVenues'
export function HomePage() {
const heroImageRef = useRef(null)
const eventsQuery = useEventsList({ upcoming: true })
const venuesQuery = useVenuesList()
useHomeScrollAnimations({ heroImageRef })
return (
<>
<HomeHero heroImageRef={heroImageRef} />
<UpcomingEventsSection
cards={eventsQuery.cards}
isLoading={eventsQuery.isLoading}
isError={eventsQuery.isError}
isEmpty={eventsQuery.isEmpty}
error={eventsQuery.error}
onRetry={eventsQuery.retry}
/>
<FeaturedEventSection
featured={eventsQuery.featured}
isLoading={eventsQuery.isLoading}
isError={eventsQuery.isError}
error={eventsQuery.error}
onRetry={eventsQuery.retry}
/>
<CategoriesSection />
<VenuesSection
venues={venuesQuery.venues}
isLoading={venuesQuery.isLoading}
isError={venuesQuery.isError}
isEmpty={venuesQuery.isEmpty}
error={venuesQuery.error}
onRetry={venuesQuery.retry}
/>
<GallerySection galleryImages={eventsQuery.galleryImages} />
<BrandStorySection />
<FinalCtaSection />
</>
)
}

View file

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { AuthFormLayout } from '../components/auth/AuthFormLayout'
import { Button } from '../components/ui/Button'
import { Text } from '../components/typography/Text'
import { useAuth } from '../context/AuthContext'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
export function LoginPage() {
const { login, isAuthenticated } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const redirectTo = location.state?.from ?? '/account'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
useDocumentTitle('Sign in')
useEffect(() => {
if (isAuthenticated) {
navigate(redirectTo, { replace: true })
}
}, [isAuthenticated, navigate, redirectTo])
async function handleSubmit(event) {
event.preventDefault()
setError('')
setSubmitting(true)
try {
await login({ email: email.trim(), password })
navigate(redirectTo, { replace: true })
} catch (err) {
setError(err.message ?? 'Could not sign in')
} finally {
setSubmitting(false)
}
}
return (
<AuthFormLayout
title="Sign in"
description="Access your Desi Clans account for tickets and saved editions."
>
<form className="space-y-5" onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="login-email" className="text-label uppercase text-ink-muted">Email</label>
<input
id="login-email"
type="email"
autoComplete="email"
required
className="mt-2 w-full min-h-11 border border-border bg-surface px-3 text-body focus:border-ink focus:outline-none"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="login-password" className="text-label uppercase text-ink-muted">Password</label>
<input
id="login-password"
type="password"
autoComplete="current-password"
required
minLength={8}
className="mt-2 w-full min-h-11 border border-border bg-surface px-3 text-body focus:border-ink focus:outline-none"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error ? (
<p className="text-body-sm text-accent" role="alert">{error}</p>
) : null}
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</Button>
</form>
<Text tone="muted" size="sm" className="mt-6">
New here?{' '}
<Link to="/register" className="text-ink underline-offset-4 hover:underline">
Create an account
</Link>
</Text>
</AuthFormLayout>
)
}

View file

@ -0,0 +1,15 @@
import { Button, Heading, Section, Text } from '../components'
export function NotFoundPage() {
return (
<Section spacing="lg" containerWidth="narrow" containerClassName="text-center">
<Heading level={1}>404</Heading>
<Text tone="muted" className="mt-4">
This page does not exist.
</Text>
<Button to="/" className="mt-10">
Back home
</Button>
</Section>
)
}

Some files were not shown because too many files have changed in this diff Show more