first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
6
frontend/.env.example
Normal file
6
frontend/.env.example
Normal 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
24
frontend/.gitignore
vendored
Normal 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
8
frontend/.oxlintrc.json
Normal 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
40
frontend/README.md
Normal 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
31
frontend/index.html
Normal 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&fit=crop&w=1600&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
4231
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
47
frontend/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
frontend/src/admin/AdminAuthContext.jsx
Normal file
71
frontend/src/admin/AdminAuthContext.jsx
Normal 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
|
||||
}
|
||||
19
frontend/src/admin/AdminProtectedRoute.jsx
Normal file
19
frontend/src/admin/AdminProtectedRoute.jsx
Normal 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 />
|
||||
}
|
||||
36
frontend/src/admin/AdminRoutes.jsx
Normal file
36
frontend/src/admin/AdminRoutes.jsx
Normal 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
28
frontend/src/admin/api.js
Normal 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')
|
||||
}
|
||||
12
frontend/src/admin/components/AdminField.jsx
Normal file
12
frontend/src/admin/components/AdminField.jsx
Normal 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}`
|
||||
}
|
||||
54
frontend/src/admin/components/AdminLayout.jsx
Normal file
54
frontend/src/admin/components/AdminLayout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
frontend/src/admin/components/AdminPageHeader.jsx
Normal file
11
frontend/src/admin/components/AdminPageHeader.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/admin/components/AdminTable.jsx
Normal file
29
frontend/src/admin/components/AdminTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
frontend/src/admin/pages/AdminDashboardPage.jsx
Normal file
44
frontend/src/admin/pages/AdminDashboardPage.jsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
94
frontend/src/admin/pages/AdminEventsPage.jsx
Normal file
94
frontend/src/admin/pages/AdminEventsPage.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
48
frontend/src/admin/pages/AdminLoginPage.jsx
Normal file
48
frontend/src/admin/pages/AdminLoginPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
frontend/src/admin/pages/AdminOrdersPage.jsx
Normal file
47
frontend/src/admin/pages/AdminOrdersPage.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
32
frontend/src/admin/pages/AdminPaymentsPage.jsx
Normal file
32
frontend/src/admin/pages/AdminPaymentsPage.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
frontend/src/admin/pages/AdminStatsPage.jsx
Normal file
33
frontend/src/admin/pages/AdminStatsPage.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
67
frontend/src/admin/pages/AdminTicketTypesPage.jsx
Normal file
67
frontend/src/admin/pages/AdminTicketTypesPage.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
45
frontend/src/admin/pages/AdminVenuesPage.jsx
Normal file
45
frontend/src/admin/pages/AdminVenuesPage.jsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
48
frontend/src/admin/pages/AdminVerifyPage.jsx
Normal file
48
frontend/src/admin/pages/AdminVerifyPage.jsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
26
frontend/src/admin/token.js
Normal file
26
frontend/src/admin/token.js
Normal 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)
|
||||
}
|
||||
3
frontend/src/animations/gsap.js
Normal file
3
frontend/src/animations/gsap.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/** @deprecated Import `loadGsap` or `prefersReducedMotion` from dedicated modules. */
|
||||
export { loadGsap } from './loadGsap'
|
||||
export { prefersReducedMotion } from '../utils/motion'
|
||||
22
frontend/src/animations/loadGsap.js
Normal file
22
frontend/src/animations/loadGsap.js
Normal 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
|
||||
}
|
||||
96
frontend/src/animations/useEventDetailAnimations.js
Normal file
96
frontend/src/animations/useEventDetailAnimations.js
Normal 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])
|
||||
}
|
||||
95
frontend/src/animations/useHomeScrollAnimations.js
Normal file
95
frontend/src/animations/useHomeScrollAnimations.js
Normal 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])
|
||||
}
|
||||
1
frontend/src/api/client.js
Normal file
1
frontend/src/api/client.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { apiClient, fetchHealth, ApiError, normalizeApiError } from '../services/api'
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
19
frontend/src/components/auth/AuthFormLayout.jsx
Normal file
19
frontend/src/components/auth/AuthFormLayout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/auth/ProtectedRoute.jsx
Normal file
24
frontend/src/components/auth/ProtectedRoute.jsx
Normal 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 />
|
||||
}
|
||||
100
frontend/src/components/cards/EventCard.jsx
Normal file
100
frontend/src/components/cards/EventCard.jsx
Normal 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)
|
||||
55
frontend/src/components/common/RequestState.jsx
Normal file
55
frontend/src/components/common/RequestState.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
frontend/src/components/common/RouteFallback.jsx
Normal file
6
frontend/src/components/common/RouteFallback.jsx
Normal 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" />
|
||||
)
|
||||
}
|
||||
22
frontend/src/components/event/EventDescription.jsx
Normal file
22
frontend/src/components/event/EventDescription.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
frontend/src/components/event/EventDetailGallery.jsx
Normal file
43
frontend/src/components/event/EventDetailGallery.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
frontend/src/components/event/EventDetailHeader.jsx
Normal file
18
frontend/src/components/event/EventDetailHeader.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
frontend/src/components/event/EventDetailHero.jsx
Normal file
50
frontend/src/components/event/EventDetailHero.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
32
frontend/src/components/event/EventFaq.jsx
Normal file
32
frontend/src/components/event/EventFaq.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/event/EventHighlights.jsx
Normal file
24
frontend/src/components/event/EventHighlights.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/event/EventMobileTicketBar.jsx
Normal file
24
frontend/src/components/event/EventMobileTicketBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/event/EventScheduleLocation.jsx
Normal file
35
frontend/src/components/event/EventScheduleLocation.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/event/EventTicketCta.jsx
Normal file
35
frontend/src/components/event/EventTicketCta.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/event/EventTicketTiers.jsx
Normal file
66
frontend/src/components/event/EventTicketTiers.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
frontend/src/components/event/EventVenueInfo.jsx
Normal file
43
frontend/src/components/event/EventVenueInfo.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
frontend/src/components/event/index.js
Normal file
11
frontend/src/components/event/index.js
Normal 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'
|
||||
38
frontend/src/components/home/BrandStorySection.jsx
Normal file
38
frontend/src/components/home/BrandStorySection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/home/CategoriesSection.jsx
Normal file
48
frontend/src/components/home/CategoriesSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
frontend/src/components/home/FeaturedEventSection.jsx
Normal file
81
frontend/src/components/home/FeaturedEventSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/home/FinalCtaSection.jsx
Normal file
62
frontend/src/components/home/FinalCtaSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
frontend/src/components/home/GallerySection.jsx
Normal file
90
frontend/src/components/home/GallerySection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
frontend/src/components/home/HomeHero.jsx
Normal file
102
frontend/src/components/home/HomeHero.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/home/MarketingFooter.jsx
Normal file
80
frontend/src/components/home/MarketingFooter.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
frontend/src/components/home/UpcomingEventsSection.jsx
Normal file
43
frontend/src/components/home/UpcomingEventsSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/home/VenuesSection.jsx
Normal file
56
frontend/src/components/home/VenuesSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/index.js
Normal file
16
frontend/src/components/index.js
Normal 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'
|
||||
19
frontend/src/components/layout/SectionSplit.jsx
Normal file
19
frontend/src/components/layout/SectionSplit.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
frontend/src/components/navigation/MarketingNavbar.jsx
Normal file
195
frontend/src/components/navigation/MarketingNavbar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/navigation/NavLink.jsx
Normal file
20
frontend/src/components/navigation/NavLink.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/navigation/Navbar.jsx
Normal file
76
frontend/src/components/navigation/Navbar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/navigation/SiteFooter.jsx
Normal file
20
frontend/src/components/navigation/SiteFooter.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/tickets/DigitalTicket.jsx
Normal file
78
frontend/src/components/tickets/DigitalTicket.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
frontend/src/components/typography/Eyebrow.jsx
Normal file
15
frontend/src/components/typography/Eyebrow.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/typography/Heading.jsx
Normal file
16
frontend/src/components/typography/Heading.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/typography/Text.jsx
Normal file
24
frontend/src/components/typography/Text.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/components/ui/Badge.jsx
Normal file
25
frontend/src/components/ui/Badge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/ui/Button.jsx
Normal file
67
frontend/src/components/ui/Button.jsx
Normal 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>
|
||||
)
|
||||
})
|
||||
39
frontend/src/components/ui/Card.jsx
Normal file
39
frontend/src/components/ui/Card.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/ui/Container.jsx
Normal file
24
frontend/src/components/ui/Container.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
frontend/src/components/ui/EmptyState.jsx
Normal file
45
frontend/src/components/ui/EmptyState.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/ui/Section.jsx
Normal file
63
frontend/src/components/ui/Section.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/components/ui/Skeleton.jsx
Normal file
29
frontend/src/components/ui/Skeleton.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
frontend/src/components/ui/Spinner.jsx
Normal file
22
frontend/src/components/ui/Spinner.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
frontend/src/context/AuthContext.jsx
Normal file
88
frontend/src/context/AuthContext.jsx
Normal 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
|
||||
}
|
||||
251
frontend/src/data/eventsMock.js
Normal file
251
frontend/src/data/eventsMock.js
Normal 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)
|
||||
}
|
||||
162
frontend/src/data/homeMock.js
Normal file
162
frontend/src/data/homeMock.js
Normal 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.',
|
||||
}
|
||||
0
frontend/src/hooks/.gitkeep
Normal file
0
frontend/src/hooks/.gitkeep
Normal file
42
frontend/src/hooks/useAsyncQuery.js
Normal file
42
frontend/src/hooks/useAsyncQuery.js
Normal 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',
|
||||
}
|
||||
}
|
||||
43
frontend/src/hooks/useBreakpoint.js
Normal file
43
frontend/src/hooks/useBreakpoint.js
Normal 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
|
||||
}
|
||||
111
frontend/src/hooks/useCheckout.js
Normal file
111
frontend/src/hooks/useCheckout.js
Normal 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' }
|
||||
}
|
||||
11
frontend/src/hooks/useDocumentTitle.js
Normal file
11
frontend/src/hooks/useDocumentTitle.js
Normal 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])
|
||||
}
|
||||
92
frontend/src/hooks/useEvents.js
Normal file
92
frontend/src/hooks/useEvents.js
Normal 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,
|
||||
}
|
||||
}
|
||||
19
frontend/src/hooks/useVenues.js
Normal file
19
frontend/src/hooks/useVenues.js
Normal 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,
|
||||
}
|
||||
}
|
||||
21
frontend/src/layouts/AppLayout.jsx
Normal file
21
frontend/src/layouts/AppLayout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
frontend/src/layouts/MarketingLayout.jsx
Normal file
27
frontend/src/layouts/MarketingLayout.jsx
Normal 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
17
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
82
frontend/src/pages/AccountPage.jsx
Normal file
82
frontend/src/pages/AccountPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
frontend/src/pages/DesignSystemPage.jsx
Normal file
193
frontend/src/pages/DesignSystemPage.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
138
frontend/src/pages/EventDetailsPage.jsx
Normal file
138
frontend/src/pages/EventDetailsPage.jsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
54
frontend/src/pages/HomePage.jsx
Normal file
54
frontend/src/pages/HomePage.jsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
89
frontend/src/pages/LoginPage.jsx
Normal file
89
frontend/src/pages/LoginPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
frontend/src/pages/NotFoundPage.jsx
Normal file
15
frontend/src/pages/NotFoundPage.jsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue