diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..52240b1e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist/ +.env +__pycache__/ +.venv/ +*.pyc diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..4a7ea3036 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..ed8831325 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Build (tsc type-check + vite build — the merge gate) + run: pnpm build + - name: Security audit + run: pnpm audit --audit-level=high + + api: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + POSTGRES_DB: venue404 + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U user -d venue404" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://user:pass@localhost:5432/venue404 + SUPABASE_URL: https://example.supabase.co + SUPABASE_JWT_SECRET: test-secret + SUPABASE_SERVICE_ROLE_KEY: test-service-role + STRIPE_SECRET_KEY: sk_test_dummy + STRIPE_WEBHOOK_SECRET: whsec_dummy + defaults: + run: + working-directory: apps/api + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "apps/api/pyproject.toml" + python-version: "3.12" + - name: Install dependencies + run: | + uv venv + echo "$PWD/.venv/bin" >> $GITHUB_PATH + uv pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Migrate + run: alembic upgrade head + - name: Tests + run: pytest -q + - name: Security audit + run: uvx pip-audit diff --git a/.github/workflows/jobs.yml b/.github/workflows/jobs.yml new file mode 100644 index 000000000..c9914ad78 --- /dev/null +++ b/.github/workflows/jobs.yml @@ -0,0 +1,48 @@ +name: Background Jobs + +# Triggers the API's background jobs at their original cadences by calling the +# token-guarded POST /api/internal/run-jobs endpoint. The in-process scheduler +# stays off (ENABLE_JOBS=false) so the free Render service can sleep. +# +# 15-min (*/15 * * * *) -> invoice_generator, payment_pending_expiry +# hourly (0 * * * *) -> hold_expiry, payment_reminders, search_indexer +# 6-hourly (0 */6 * * *) -> request_expiry, overdue_flag, overdue_autocancel +# daily (0 0 * * *) -> completion +on: + schedule: + - cron: "*/15 * * * *" + - cron: "0 * * * *" + - cron: "0 */6 * * *" + - cron: "0 0 * * *" + workflow_dispatch: + inputs: + jobs: + description: "Comma-separated job names to run" + required: true + default: "hold_expiry,payment_reminders,request_expiry,overdue_flag,overdue_autocancel,completion,search_indexer,invoice_generator,payment_pending_expiry" + +jobs: + run: + runs-on: ubuntu-latest + steps: + - name: Select job set + id: select + run: | + case "${{ github.event.schedule }}" in + "*/15 * * * *") JOBS="invoice_generator,payment_pending_expiry" ;; + "0 * * * *") JOBS="hold_expiry,payment_reminders,search_indexer" ;; + "0 */6 * * *") JOBS="request_expiry,overdue_flag,overdue_autocancel" ;; + "0 0 * * *") JOBS="completion" ;; + *) JOBS="${{ github.event.inputs.jobs }}" ;; + esac + echo "jobs=$JOBS" >> "$GITHUB_OUTPUT" + echo "Running jobs: $JOBS" + + - name: Warm up (ride out free-tier cold start) + run: curl -fsS --max-time 90 --retry 5 --retry-all-errors --retry-delay 12 "${{ secrets.API_BASE_URL }}/health" + + - name: Trigger jobs + run: | + curl -fsS --max-time 300 -X POST \ + -H "X-Job-Token: ${{ secrets.JOB_RUNNER_TOKEN }}" \ + "${{ secrets.API_BASE_URL }}/api/internal/run-jobs?jobs=${{ steps.select.outputs.jobs }}" diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml new file mode 100644 index 000000000..2ce49d0ba --- /dev/null +++ b/.github/workflows/keepalive.yml @@ -0,0 +1,19 @@ +name: Keepalive + +# Pings the API /health every 13 minutes so Render's free web service does not +# spin down (it sleeps after ~15 min idle). GitHub Actions cron is best-effort. +# +# NOTE: on a PRIVATE repo this consumes ~3,300 Actions minutes/month and will +# exceed the 2,000-min free budget — use a free external pinger (UptimeRobot / +# cron-job.org) instead. On a PUBLIC repo, Actions minutes are free. +on: + schedule: + - cron: "*/13 * * * *" + workflow_dispatch: + +jobs: + ping: + runs-on: ubuntu-latest + steps: + - name: Ping /health + run: curl -fsS --max-time 60 --retry 3 --retry-delay 10 "${{ secrets.API_BASE_URL }}/health" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..3bf5e0f4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# ---- Dependencies ---- +node_modules/ +.venv/ +*.egg-info/ + +# ---- Build output ---- +dist/ +build/ +*.tsbuildinfo + +# ---- Environment secrets ---- +.env +.env.local +.env.*.local + +# ---- Python runtime ---- +__pycache__/ +*.py[cod] +*.pyo + +# ---- Test & coverage ---- +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +coverage.xml + +# ---- Editor & OS ---- +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db + +# ---- Misc ---- +prd.txt +auth-prd.md +.claude/ +claude.md +/prds + +.akhil +self/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..238d4d936 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/apps/admin-panel/.env.example b/apps/admin-panel/.env.example new file mode 100644 index 000000000..f940594e8 --- /dev/null +++ b/apps/admin-panel/.env.example @@ -0,0 +1,7 @@ +# Supabase (browser client - anon key only) +VITE_SUPABASE_URL=https://your-project-ref.supabase.co +VITE_SUPABASE_ANON_KEY=your-supabase-anon-key + +# FastAPI backend +VITE_API_BASE_URL=http://localhost:8000 +VITE_STRIPE_PUBLISHABLE_KEY= \ No newline at end of file diff --git a/apps/admin-panel/index.html b/apps/admin-panel/index.html new file mode 100644 index 000000000..af41e4f9b --- /dev/null +++ b/apps/admin-panel/index.html @@ -0,0 +1,16 @@ + + + + + + + + Venue404 | Admin + + + +
+ + + + \ No newline at end of file diff --git a/apps/admin-panel/package.json b/apps/admin-panel/package.json new file mode 100644 index 000000000..efea36cc4 --- /dev/null +++ b/apps/admin-panel/package.json @@ -0,0 +1,33 @@ +{ + "name": "@venue404/admin-panel", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 5399", + "build": "tsc && vite build", + "lint": "eslint src", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-query": "^5.101.0", + "@venue404/api-client": "workspace:*", + "@venue404/ui": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.23.0", + "recharts": "^3.8.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "vite": "^8.1.3" + } +} \ No newline at end of file diff --git a/apps/admin-panel/src/App.tsx b/apps/admin-panel/src/App.tsx new file mode 100644 index 000000000..4e4b9ecb7 --- /dev/null +++ b/apps/admin-panel/src/App.tsx @@ -0,0 +1,6 @@ +import { RouterProvider } from 'react-router-dom' +import { router } from './routes' + +export default function App() { + return +} diff --git a/apps/admin-panel/src/components/AdminLayout.tsx b/apps/admin-panel/src/components/AdminLayout.tsx new file mode 100644 index 000000000..5aaec325a --- /dev/null +++ b/apps/admin-panel/src/components/AdminLayout.tsx @@ -0,0 +1,57 @@ +import { useNavigate, useLocation } from 'react-router-dom' +import { useAuth } from '../lib/AuthContext' +import { AppShell, Logo, type NavItemConfig } from '@venue404/ui' +import { + LayoutDashboard, Building2, Users, UserCheck, + CalendarDays, ClipboardList, Settings, Sparkles, LayoutGrid, Search, PhoneCall, +} from 'lucide-react' + +const NAV: NavItemConfig[] = [ + { label: 'Dashboard', href: '/dashboard', icon: }, + { label: 'Venue Approvals', href: '/venues/pending', icon: }, + { label: 'Users', href: '/users', icon: }, + { label: 'Venue Owners', href: '/owners', icon: }, + { label: 'Amenities', href: '/amenities', icon: }, + { label: 'Categories', href: '/categories', icon: }, + { label: 'Bookings', href: '/bookings', icon: }, + { label: 'External Reservations', href: '/external-reservations', icon: }, + { label: 'Deep Research', href: '/deep-research-insights', icon: }, + { label: 'Audit Log', href: '/audit-log', icon: }, + { label: 'Settings', href: '/settings', icon: }, +] + +type AdminLayoutProps = { + pageTitle: string + pageSubtitle?: string + children: React.ReactNode +} + +export function AdminLayout({ pageTitle, pageSubtitle, children }: AdminLayoutProps) { + const { user, signOut } = useAuth() + const navigate = useNavigate() + const location = useLocation() + + async function handleSignOut() { + await signOut() + navigate('/login', { replace: true }) + } + + return ( + } + user={user ? { + name: user.profile.full_name ?? user.email ?? 'Admin', + email: user.email ?? '', + role: 'Super Admin', + } : undefined} + onSignOut={handleSignOut} + > + {children} + + ) +} diff --git a/apps/admin-panel/src/components/GrowthChart.tsx b/apps/admin-panel/src/components/GrowthChart.tsx new file mode 100644 index 000000000..26841ae65 --- /dev/null +++ b/apps/admin-panel/src/components/GrowthChart.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts' +import { RefreshCw } from 'lucide-react' +import { createClient, adminGrowthEndpoints } from '@venue404/api-client' +import type { GrowthPeriod, GrowthStats } from '@venue404/api-client' +import { SectionHeader } from '@venue404/ui' + +const api = adminGrowthEndpoints(createClient()) + +const SERIES = [ + { key: 'users', label: 'Users', color: '#6366f1' }, + { key: 'owners', label: 'Owners', color: '#f59e0b' }, + { key: 'venues', label: 'Venues', color: '#10b981' }, + { key: 'bookings', label: 'Bookings', color: '#ef4444' }, +] as const + +const PERIODS: { label: string; value: GrowthPeriod }[] = [ + { label: '7D', value: '7d' }, + { label: '30D', value: '30d' }, + { label: '3M', value: '3m' }, + { label: '6M', value: '6m' }, + { label: '12M', value: '12m' }, +] + +function fmt(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + +function TotalPill({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+ + {label} + + {value.toLocaleString('en-IN')} + +
+ ) +} + +function ChartBody({ data }: { data: GrowthStats }) { + const chartData = data.labels.map((label, i) => ({ + label, + users: data.users[i], + owners: data.owners[i], + venues: data.venues[i], + bookings: data.bookings[i], + })) + + return ( +
+ {/* Totals strip */} +
+ {SERIES.map((s) => ( + + ))} +
+ + {/* Chart */} +
+ + + + + + [ + typeof value === 'number' ? value.toLocaleString('en-IN') : value, + typeof name === 'string' ? name.charAt(0).toUpperCase() + name.slice(1) : name, + ]} + /> + ( + + {value.charAt(0).toUpperCase() + value.slice(1)} + + )} + /> + {SERIES.map((s) => ( + + ))} + + +
+
+ ) +} + +export function GrowthChart() { + const [period, setPeriod] = useState('30d') + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'growth-stats', period], + queryFn: () => api.getGrowthStats(period), + }) + + const periodDesc = period.endsWith('d') + ? `Last ${period.slice(0, -1)} days — daily` + : `Last ${period.slice(0, -1)} months — monthly` + + return ( +
+ {/* Header */} +
+ + {PERIODS.map((p) => ( + + ))} +
+ } + /> +
+ + {/* Body */} +
+ {isLoading || !data ? ( +
+ +
+ ) : ( + + )} +
+ + ) +} diff --git a/apps/admin-panel/src/components/ProtectedRoute.tsx b/apps/admin-panel/src/components/ProtectedRoute.tsx new file mode 100644 index 000000000..5786e9787 --- /dev/null +++ b/apps/admin-panel/src/components/ProtectedRoute.tsx @@ -0,0 +1,17 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../lib/AuthContext' +import { LoadingScreen } from '@venue404/ui' + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth() + + if (loading) return + + if (!user) return + + if (!user.roles.includes('super_admin')) { + return + } + + return <>{children} +} diff --git a/apps/admin-panel/src/lib/AuthContext.tsx b/apps/admin-panel/src/lib/AuthContext.tsx new file mode 100644 index 000000000..dbdabf941 --- /dev/null +++ b/apps/admin-panel/src/lib/AuthContext.tsx @@ -0,0 +1,80 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { + createClient, + onAuthStateChange, + signInWithEmail, + signUpWithEmail, + signOut as supabaseSignOut, + type SignInInput, + type SignUpInput, +} from '@venue404/api-client' +import { authEndpoints, type AuthUser } from '@venue404/api-client' + +type AuthState = { + user: AuthUser | null + loading: boolean + signIn: (input: SignInInput) => Promise + signUp: (input: SignUpInput) => Promise + signOut: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + async function loadUser() { + try { + const client = createClient() + const authUser = await authEndpoints(client).me() + setUser(authUser) + } catch { + setUser(null) + } + } + + useEffect(() => { + const subscription = onAuthStateChange(async (event, session) => { + if (event === 'SIGNED_OUT') { + setUser(null) + setLoading(false) + return + } + + if ((event === 'INITIAL_SESSION' || event === 'SIGNED_IN') && session) { + await loadUser() + setLoading(false) + return + } + + setLoading(false) + }) + + return () => subscription.unsubscribe() + }, []) + + async function signIn(input: SignInInput) { + await signInWithEmail(input) + } + + async function signUp(input: SignUpInput) { + await signUpWithEmail(input) + } + + async function signOut() { + await supabaseSignOut() + } + + return ( + + {children} + + ) +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used inside AuthProvider') + return ctx +} diff --git a/apps/admin-panel/src/lib/queryClient.ts b/apps/admin-panel/src/lib/queryClient.ts new file mode 100644 index 000000000..76ba5efaf --- /dev/null +++ b/apps/admin-panel/src/lib/queryClient.ts @@ -0,0 +1,10 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + retry: 1, + }, + }, +}) diff --git a/apps/admin-panel/src/main.tsx b/apps/admin-panel/src/main.tsx new file mode 100644 index 000000000..19597d856 --- /dev/null +++ b/apps/admin-panel/src/main.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import { AuthProvider } from './lib/AuthContext' +import { queryClient } from './lib/queryClient' +import './styles/index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +) diff --git a/apps/admin-panel/src/pages/Amenities.tsx b/apps/admin-panel/src/pages/Amenities.tsx new file mode 100644 index 000000000..5409f0e1e --- /dev/null +++ b/apps/admin-panel/src/pages/Amenities.tsx @@ -0,0 +1,528 @@ +import { useState, useEffect, useRef } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Sparkles, Plus, Pencil, Trash2, Search, + AlertTriangle, CheckCircle2, +} from 'lucide-react' +import { createClient } from '@venue404/api-client' +import { adminAmenityEndpoints } from '@venue404/api-client' +import type { AdminAmenity } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, StatusBadge, SectionHeader, + EmptyState, LoadingScreen, ErrorState, Button, Modal, +} from '@venue404/ui' + +const api = adminAmenityEndpoints(createClient()) + +const DEBOUNCE_MS = 350 + +export default function Amenities() { + const qc = useQueryClient() + + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const [showArchived, setShowArchived] = useState(false) + + // Create modal + const [createOpen, setCreateOpen] = useState(false) + const [createName, setCreateName] = useState('') + const [createIcon, setCreateIcon] = useState('') + + // Edit modal + const [editTarget, setEditTarget] = useState(null) + const [editName, setEditName] = useState('') + const [editIcon, setEditIcon] = useState('') + + // Delete modal + const [deleteTarget, setDeleteTarget] = useState(null) + + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => setSearch(value), DEBOUNCE_MS) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + // ── Query ─────────────────────────────────────────────────────────────────── + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'amenities'], + queryFn: () => api.listAmenities({ include_deleted: true }), + }) + + const allAmenities = data?.items ?? [] + + // ── Mutations ─────────────────────────────────────────────────────────────── + + const createMutation = useMutation({ + mutationFn: (input: { name: string; icon: string | null }) => + api.createAmenity(input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'amenities'] }) + closeCreate() + }, + }) + + const editMutation = useMutation({ + mutationFn: ({ id, name, icon }: { id: string; name: string; icon: string | null }) => + api.updateAmenity(id, { name, icon }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'amenities'] }) + closeEdit() + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.deleteAmenity(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'amenities'] }) + closeDelete() + }, + }) + + // ── Client-side filtering ─────────────────────────────────────────────────── + + const filtered = allAmenities + .filter((a) => showArchived || !a.deleted_at) + .filter((a) => !search || a.name.toLowerCase().includes(search.toLowerCase())) + + const stats = { + total: allAmenities.filter((a) => !a.deleted_at).length, + inUse: allAmenities.filter((a) => !a.deleted_at && a.active_venue_count > 0).length, + archived: allAmenities.filter((a) => !!a.deleted_at).length, + } + + // ── Modal helpers ─────────────────────────────────────────────────────────── + + function openCreate() { + setCreateName('') + setCreateIcon('') + createMutation.reset() + setCreateOpen(true) + } + + function closeCreate() { + setCreateOpen(false) + createMutation.reset() + } + + function openEdit(a: AdminAmenity) { + setEditTarget(a) + setEditName(a.name) + setEditIcon(a.icon ?? '') + editMutation.reset() + } + + function closeEdit() { + setEditTarget(null) + editMutation.reset() + } + + function closeDelete() { + setDeleteTarget(null) + deleteMutation.reset() + } + + // ── Handlers ──────────────────────────────────────────────────────────────── + + function handleCreate() { + if (!createName.trim()) return + createMutation.mutate({ name: createName.trim(), icon: createIcon.trim() || null }) + } + + function handleEdit() { + if (!editTarget || !editName.trim()) return + editMutation.mutate({ id: editTarget.id, name: editName.trim(), icon: editIcon.trim() || null }) + } + + function handleDelete() { + if (!deleteTarget) return + deleteMutation.mutate(deleteTarget.id) + } + + const hasFilters = !!(search || showArchived) + + return ( + + + {/* Metric strip */} +
+
+ } + accent="brand" + /> +
+
+ } + accent="emerald" + /> +
+
+ } + accent="amber" + /> +
+
+ + {/* Table card */} +
+ + {/* Card header */} +
+ +
+ + {/* Content states */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ qc.invalidateQueries({ queryKey: ['admin', 'amenities'] })}> + Retry + + } + /> +
+ )} + + {!isLoading && !error && filtered.length === 0 && ( +
+ } + title="No amenities found" + description={ + search + ? 'Try adjusting the search term.' + : 'Add your first amenity to get started.' + } + /> +
+ )} + + {!isLoading && !error && filtered.length > 0 && ( +
+ + + + + + + + + + + + {filtered.map((amenity) => ( + + + + + + + + ))} + +
AmenityVenues using itStatusCreatedActions
+
+ {amenity.icon ? ( + + {amenity.icon} + + ) : ( + + + )} + {amenity.name} +
+
+ {amenity.active_venue_count > 0 ? ( + + {amenity.active_venue_count} {amenity.active_venue_count === 1 ? 'venue' : 'venues'} + + ) : ( + None + )} + + {amenity.deleted_at ? ( + + ) : ( + + )} + + {new Date(amenity.created_at).toLocaleDateString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + })} + + {amenity.deleted_at ? null : ( +
+ + +
+ )} +
+
+ )} +
+ + {/* Create Modal */} + +
+
+
+
+

New amenity

+

+ This amenity will immediately be available for venue owners to add to their listings. +

+ +
+
+ + { setCreateName(e.target.value); createMutation.reset() }} + autoFocus + /> +
+
+ + setCreateIcon(e.target.value)} + /> +
+
+ + {createMutation.error && ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create amenity'} +

+ )} + +
+ + +
+
+
+
+ + {/* Edit Modal */} + +
+
+
+
+

Edit amenity

+

+ Changes apply immediately across all venues using this amenity. +

+ +
+
+ + { setEditName(e.target.value); editMutation.reset() }} + autoFocus + /> +
+
+ + setEditIcon(e.target.value)} + /> +
+
+ + {editMutation.error && ( +

+ {editMutation.error instanceof Error ? editMutation.error.message : 'Failed to update amenity'} +

+ )} + +
+ + +
+
+
+
+ + {/* Delete Modal */} + +
+
+
+
+

Archive amenity

+

+ {deleteTarget?.name} will be hidden from venue owners and can no longer be assigned to new listings. +

+ + {/* Active venue warning */} + {deleteTarget && deleteTarget.active_venue_count > 0 && ( +
+
+ )} + + {deleteMutation.error && ( +
+ {deleteMutation.error instanceof Error ? deleteMutation.error.message : 'Failed to archive amenity'} +
+ )} + +
+ + +
+
+
+
+ +
+ ) +} diff --git a/apps/admin-panel/src/pages/AuditLog.tsx b/apps/admin-panel/src/pages/AuditLog.tsx new file mode 100644 index 000000000..d68fc440f --- /dev/null +++ b/apps/admin-panel/src/pages/AuditLog.tsx @@ -0,0 +1,300 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + CheckCircle2, XCircle, ShieldOff, + Sparkles, User, Building2, CalendarDays, ClipboardList, +} from 'lucide-react' +import { createClient, adminActionEndpoints } from '@venue404/api-client' +import type { AdminAction } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + SectionHeader, EmptyState, LoadingScreen, ErrorState, Button, +} from '@venue404/ui' + +const api = adminActionEndpoints(createClient()) + +type TargetTab = 'user' | 'venue' | 'amenity' | '' + +const TABS: { label: string; value: TargetTab; icon: React.ReactNode }[] = [ + { label: 'All', value: '', icon: }, + { label: 'Users', value: 'user', icon: }, + { label: 'Venues', value: 'venue', icon: }, + { label: 'Amenities', value: 'amenity', icon: }, +] + +// ── Action display metadata ─────────────────────────────────────────────────── + +type ActionMeta = { + label: string + icon: React.ReactNode + color: string + bg: string +} + +function getActionMeta(type: string): ActionMeta { + if (type.endsWith('approved') || type.endsWith('reactivated')) + return { label: fmtType(type), icon: , color: 'text-emerald-700', bg: 'bg-emerald-50' } + if (type.endsWith('rejected') || type.endsWith('deleted')) + return { label: fmtType(type), icon: , color: 'text-red-600', bg: 'bg-red-50' } + if (type.endsWith('suspended')) + return { label: fmtType(type), icon: , color: 'text-orange-600', bg: 'bg-orange-50' } + if (type.includes('created') || type.includes('updated')) + return { label: fmtType(type), icon: , color: 'text-brand', bg: 'bg-brand-light' } + return { label: fmtType(type), icon: , color: 'text-zinc-600', bg: 'bg-zinc-100' } +} + +function fmtType(type: string): string { + return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function fmtTargetType(t: string): string { + if (t === 'user') return 'User' + if (t === 'venue') return 'Venue' + if (t === 'amenity') return 'Amenity' + if (t === 'booking') return 'Booking' + return t.charAt(0).toUpperCase() + t.slice(1) +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + if (days < 7) return `${days}d ago` + return new Date(iso).toLocaleDateString('en-IN', { year: 'numeric', month: 'short', day: 'numeric' }) +} + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) +} + +// ── Component ───────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 25 + +export default function AuditLog() { + const qc = useQueryClient() + const [activeTab, setActiveTab] = useState('') + const [page, setPage] = useState(1) + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'audit-log', { page, target_type: activeTab }], + queryFn: () => api.listActions({ + page, + page_size: PAGE_SIZE, + target_type: activeTab || undefined, + }), + }) + + const total = data?.total ?? 0 + const totalPages = data?.total_pages ?? 1 + + return ( + +
+ + {/* Header + tabs */} +
+ + +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.value + return ( + + ) + })} +
+
+ + {/* States */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ qc.invalidateQueries({ queryKey: ['admin', 'audit-log'] })}> + Retry + + } + /> +
+ )} + + {!isLoading && !error && data?.items.length === 0 && ( +
+ } + title="No actions yet" + description={ + activeTab + ? `No admin actions recorded for ${fmtTargetType(activeTab).toLowerCase()}s.` + : 'Admin actions will appear here once recorded.' + } + /> +
+ )} + + {!isLoading && !error && data && data.items.length > 0 && ( + <> +
+ + + + + + + + + + + + {data.items.map((action) => ( + + ))} + +
ActionTargetAdminReasonTime
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)} of {total.toLocaleString()} + +
+ + Page {page} of {totalPages} + +
+
+ )} + + )} +
+
+ ) +} + +// ── Row component ───────────────────────────────────────────────────────────── + +function ActionRow({ action }: { action: AdminAction }) { + const meta = getActionMeta(action.action_type) + return ( + + + {/* Action */} + + + {meta.icon} + {meta.label} + + + + {/* Target */} + +
+ +
+
+ {action.target_type} + {action.target_name && ( + ({action.target_name}) + )} +
+ + {action.target_id} + +
+
+ + + {/* Admin */} + + + {action.admin_name ?? } + + + + {/* Reason */} + + {action.reason ? ( + + {action.reason} + + ) : ( + No reason provided + )} + + + {/* Time */} + + + {timeAgo(action.created_at)} + + + + ) +} + +function TargetIcon({ type }: { type: string }) { + const cls = 'h-3.5 w-3.5 text-zinc-400' + if (type === 'user') return + if (type === 'venue') return + if (type === 'amenity') return + if (type === 'booking') return + return +} diff --git a/apps/admin-panel/src/pages/Bookings.tsx b/apps/admin-panel/src/pages/Bookings.tsx new file mode 100644 index 000000000..05686eeaf --- /dev/null +++ b/apps/admin-panel/src/pages/Bookings.tsx @@ -0,0 +1,471 @@ +import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + CalendarDays, Building2, Search, + CheckCircle2, Clock, CalendarCheck, X, + Phone, Mail, CreditCard, +} from 'lucide-react' +import { createClient, adminBookingEndpoints } from '@venue404/api-client' +import type { AdminBookingSummary } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, SectionHeader, StatusBadge, EmptyState, + LoadingScreen, ErrorState, Button, +} from '@venue404/ui' + +const api = adminBookingEndpoints(createClient()) + +const PAGE_SIZE = 25 +const DEBOUNCE_MS = 350 + +type TabValue = '' | 'requested' | 'confirmed' | 'completed' | 'cancelled' + +const TABS: { label: string; value: TabValue; statsKey?: 'requested' | 'confirmed' | 'completed' | 'cancelled' }[] = [ + { label: 'All', value: '' }, + { label: 'Requested', value: 'requested', statsKey: 'requested' }, + { label: 'Confirmed', value: 'confirmed', statsKey: 'confirmed' }, + { label: 'Completed', value: 'completed', statsKey: 'completed' }, + { label: 'Cancelled', value: 'cancelled', statsKey: 'cancelled' }, +] + +// ── Status helpers ───────────────────────────────────────────────────────────── + +function statusVariant(status: string): 'success' | 'warning' | 'danger' | 'pending' | 'neutral' { + if (status === 'confirmed') return 'success' + if (status === 'completed') return 'neutral' + if (status === 'owner_accepted') return 'warning' + if (status === 'requested') return 'pending' + return 'danger' +} + +function statusLabel(status: string): string { + if (status === 'owner_accepted') return 'Accepted' + if (status === 'conflict_cancelled') return 'Conflict Cancelled' + if (status === 'user_cancelled') return 'User Cancelled' + if (status === 'admin_cancelled') return 'Admin Cancelled' + if (status === 'owner_rejected') return 'Rejected' + if (status === 'hold_expired') return 'Hold Expired' + if (status === 'request_expired') return 'Request Expired' + if (status === 'balance_overdue_cancelled') return 'Balance Overdue' + return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function paymentVariant(ps: string): 'success' | 'warning' | 'danger' | 'neutral' { + if (ps === 'fully_paid') return 'success' + if (ps === 'advance_paid') return 'warning' + if (ps === 'refunded' || ps === 'partially_refunded') return 'neutral' + return 'danger' +} + +function paymentLabel(ps: string): string { + if (ps === 'unpaid') return 'Unpaid' + if (ps === 'advance_paid') return 'Advance Paid' + if (ps === 'fully_paid') return 'Fully Paid' + if (ps === 'refunded') return 'Refunded' + if (ps === 'partially_refunded') return 'Part Refunded' + return ps.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +// ── Date helpers ─────────────────────────────────────────────────────────────── + +function fmtDate(iso: string): string { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('en-IN', { year: 'numeric', month: 'short', day: 'numeric' }) +} + +function fmtDateTime(iso: string): string { + return new Date(iso).toLocaleString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + if (days < 7) return `${days}d ago` + return fmtDate(iso) +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function Bookings() { + const qc = useQueryClient() + + const [activeTab, setActiveTab] = useState('') + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const [detailBooking, setDetailBooking] = useState(null) + + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { setSearch(value); setPage(1) }, DEBOUNCE_MS) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'bookings', { page, status: activeTab, search }], + queryFn: () => api.listBookings({ + page, + page_size: PAGE_SIZE, + status: activeTab || undefined, + search: search.trim() || undefined, + }), + }) + + const items = data?.items ?? [] + const total = data?.total ?? 0 + const totalPages = data?.total_pages ?? 1 + const stats = data?.stats ?? null + const hasFilters = !!(searchInput || activeTab) + + const invalidateBookings = () => qc.invalidateQueries({ queryKey: ['admin', 'bookings'] }) + + return ( + + + {/* Metric strip */} +
+ {[ + { label: 'Total Bookings', value: stats?.total, accent: 'brand' as const, icon: , description: 'All time' }, + { label: 'Confirmed', value: stats?.confirmed, accent: 'emerald' as const, icon: , description: 'Payment received' }, + { label: 'Requested', value: stats?.requested, accent: 'amber' as const, icon: , description: 'Awaiting owner action' }, + { label: 'Completed', value: stats?.completed, accent: 'violet' as const, icon: , description: 'Successfully held' }, + ].map((m, i) => ( +
+ +
+ ))} +
+ + {/* Main card */} +
+ + {/* Header + tabs */} +
+ + + {/* Tabs */} +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.value + const count = tab.statsKey ? stats?.[tab.statsKey] : undefined + return ( + + ) + })} +
+ + {/* Search */} +
+
+
+
+
+ + {/* Content states */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ Retry} + /> +
+ )} + + {!isLoading && !error && items.length === 0 && ( +
+ } + title="No bookings found" + description={ + hasFilters + ? 'Try adjusting the search or filters.' + : 'Bookings will appear here once customers start making requests.' + } + /> +
+ )} + + {!isLoading && !error && items.length > 0 && ( + <> +
+ + + + + + + + + + + + + + {items.map((b) => ( + setDetailBooking(b)} /> + ))} + +
VenueCustomerEvent dateBooking statusPaymentGuestsRequested +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)} of {total.toLocaleString()} + +
+ + Page {page} of {totalPages} + +
+
+ )} + + )} +
+ + {/* Detail modal */} + {detailBooking && ( + setDetailBooking(null)} /> + )} + +
+ ) +} + +// ── Row ─────────────────────────────────────────────────────────────────────── + +function BookingRow({ booking: b, onViewDetails }: { booking: AdminBookingSummary; onViewDetails: () => void }) { + return ( + + +
+ + {b.venue_name} +
+ + +
+
{b.customer_name ?? '—'}
+ {b.customer_email && ( +
{b.customer_email}
+ )} +
+ + {fmtDate(b.event_date)} + + + + + + + {b.guest_count} + + + {timeAgo(b.created_at)} + + + + + + + ) +} + +// ── Detail modal ────────────────────────────────────────────────────────────── + +function ContactRow({ icon, value }: { icon: React.ReactNode; value: string | null }) { + if (!value) return null + return ( +
+ {icon} + {value} +
+ ) +} + +function PersonCard({ + role, name, email, phone, +}: { + role: string + name: string | null + email: string | null + phone: string | null +}) { + return ( +
+

{role}

+
+
+ {name?.[0]?.toUpperCase() ?? '?'} +
+
+

{name ?? '—'}

+
+
+
+ } value={email} /> + } value={phone} /> +
+
+ ) +} + +function BookingDetailModal({ booking: b, onClose }: { booking: AdminBookingSummary; onClose: () => void }) { + return createPortal( +
+
+ +
+ + {/* Header */} +
+
+

Booking details

+

{b.id}

+
+ +
+ +
+ + {/* Venue */} +
+

Venue

+
+ + {b.venue_name} +
+
+ + {/* People */} +
+ + +
+ + {/* Booking info grid */} +
+
+

Event date

+

{fmtDate(b.event_date)}

+
+
+

Guests

+

{b.guest_count}

+
+
+

Booking status

+ +
+
+

Payment

+
+ + +
+
+
+ + {/* Footer */} +

+ Requested {fmtDateTime(b.created_at)} +

+ +
+
+
, + document.body, + ) +} diff --git a/apps/admin-panel/src/pages/Categories.tsx b/apps/admin-panel/src/pages/Categories.tsx new file mode 100644 index 000000000..6103846b4 --- /dev/null +++ b/apps/admin-panel/src/pages/Categories.tsx @@ -0,0 +1,430 @@ +import { useState, useRef } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + LayoutGrid, Plus, Pencil, Trash2, Image, X, + AlertTriangle, CheckCircle2, +} from 'lucide-react' +import { createClient } from '@venue404/api-client' +import { adminCategoryEndpoints } from '@venue404/api-client' +import type { AdminCategory } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, StatusBadge, SectionHeader, + EmptyState, LoadingScreen, ErrorState, Button, Modal, +} from '@venue404/ui' + +const api = adminCategoryEndpoints(createClient()) + +export default function Categories() { + const qc = useQueryClient() + + const [showArchived, setShowArchived] = useState(false) + + // Create modal + const [createOpen, setCreateOpen] = useState(false) + const [createSlug, setCreateSlug] = useState('') + const [createLabel, setCreateLabel] = useState('') + const [createIcon, setCreateIcon] = useState('') + const [createSortOrder, setCreateSortOrder] = useState('0') + + // Edit modal + const [editTarget, setEditTarget] = useState(null) + const [editLabel, setEditLabel] = useState('') + const [editIcon, setEditIcon] = useState('') + const [editSortOrder, setEditSortOrder] = useState('0') + const [editIsActive, setEditIsActive] = useState(true) + + // Delete modal + const [deleteTarget, setDeleteTarget] = useState(null) + + // Banner upload + const [bannerTarget, setBannerTarget] = useState(null) + const bannerInputRef = useRef(null) + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'categories'], + queryFn: () => api.listCategories({ include_deleted: true }), + }) + + const allCategories = data?.items ?? [] + + const filtered = allCategories.filter((c) => showArchived || !c.deleted_at) + + const stats = { + total: allCategories.filter((c) => !c.deleted_at).length, + active: allCategories.filter((c) => !c.deleted_at && c.is_active).length, + archived: allCategories.filter((c) => !!c.deleted_at).length, + } + + // ── Mutations ─────────────────────────────────────────────────────────────── + + const createMutation = useMutation({ + mutationFn: (input: { slug: string; label: string; icon: string | null; sort_order: number }) => + api.createCategory(input), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'categories'] }); closeCreate() }, + }) + + const editMutation = useMutation({ + mutationFn: ({ id, ...body }: { id: string; label: string; icon: string | null; sort_order: number; is_active: boolean }) => + api.updateCategory(id, body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'categories'] }); closeEdit() }, + }) + + const bannerMutation = useMutation({ + mutationFn: ({ id, file }: { id: string; file: File }) => { + const fd = new FormData() + fd.append('file', file) + return api.uploadCategoryBanner(id, fd) + }, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'categories'] }); setBannerTarget(null) }, + }) + + const deleteBannerMutation = useMutation({ + mutationFn: (id: string) => api.deleteCategoryBanner(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'categories'] }), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.deleteCategory(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'categories'] }); closeDelete() }, + }) + + // ── Modal helpers ─────────────────────────────────────────────────────────── + + function openCreate() { + setCreateSlug(''); setCreateLabel(''); setCreateIcon(''); setCreateSortOrder('0') + createMutation.reset(); setCreateOpen(true) + } + function closeCreate() { setCreateOpen(false); createMutation.reset() } + + function openEdit(c: AdminCategory) { + setEditTarget(c); setEditLabel(c.label); setEditIcon(c.icon ?? '') + setEditSortOrder(String(c.sort_order)); setEditIsActive(c.is_active) + editMutation.reset() + } + function closeEdit() { setEditTarget(null); editMutation.reset() } + + function closeDelete() { setDeleteTarget(null); deleteMutation.reset() } + + // ── Handlers ──────────────────────────────────────────────────────────────── + + function handleCreate() { + if (!createSlug.trim() || !createLabel.trim()) return + createMutation.mutate({ + slug: createSlug.trim(), + label: createLabel.trim(), + icon: createIcon.trim() || null, + sort_order: parseInt(createSortOrder, 10) || 0, + }) + } + + function handleEdit() { + if (!editTarget || !editLabel.trim()) return + editMutation.mutate({ + id: editTarget.id, + label: editLabel.trim(), + icon: editIcon.trim() || null, + sort_order: parseInt(editSortOrder, 10) || 0, + is_active: editIsActive, + }) + } + + function handleBannerFile(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file || !bannerTarget) return + bannerMutation.mutate({ id: bannerTarget.id, file }) + } + + return ( + + + {/* Metric strip */} +
+
+ } accent="brand" /> +
+
+ } accent="emerald" /> +
+
+ } accent="amber" /> +
+
+ + {/* Table card */} +
+
+ + New category + + } + /> +
+ +
+
+ + {isLoading &&
} + + {!isLoading && error && ( +
+ qc.invalidateQueries({ queryKey: ['admin', 'categories'] })}>Retry} /> +
+ )} + + {!isLoading && !error && filtered.length === 0 && ( +
+ } title="No categories found" + description="Add your first category to get started." /> +
+ )} + + {!isLoading && !error && filtered.length > 0 && ( +
+ + + + + + + + + + + + + + {filtered.map((cat) => ( + + + + + + + + + + ))} + +
CategorySlugBannerVenuesStatusOrderActions
+
+ {cat.icon && ( + {cat.icon} + )} + {cat.label} +
+
{cat.slug} + {cat.banner_image ? ( +
+ {cat.label} + {!cat.deleted_at && ( + + )} +
+ ) : ( + !cat.deleted_at ? ( + + ) : ( + None + ) + )} +
+ {cat.venue_count > 0 ? ( + + {cat.venue_count} {cat.venue_count === 1 ? 'venue' : 'venues'} + + ) : ( + None + )} + + {cat.deleted_at ? ( + + ) : cat.is_active ? ( + + ) : ( + + )} + {cat.sort_order} + {!cat.deleted_at && ( +
+ + +
+ )} +
+
+ )} +
+ + {/* Hidden banner file input */} + + + {/* Create Modal */} + +
+
+
+ +
+

New category

+

Slug is permanent and used in URLs (lowercase, underscores only).

+
+
+ + { setCreateSlug(e.target.value); createMutation.reset() }} autoFocus /> +
+
+ + { setCreateLabel(e.target.value); createMutation.reset() }} /> +
+
+ + setCreateIcon(e.target.value)} /> +
+
+ + setCreateSortOrder(e.target.value)} /> +
+
+ {createMutation.error && ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create category'} +

+ )} +
+ + +
+
+
+
+ + {/* Edit Modal */} + +
+
+
+ +
+

Edit category

+

Slug cannot be changed after creation.

+
+
+ + +
+
+ + { setEditLabel(e.target.value); editMutation.reset() }} autoFocus /> +
+
+ + setEditIcon(e.target.value)} /> +
+
+ + setEditSortOrder(e.target.value)} /> +
+ +
+ {editMutation.error && ( +

+ {editMutation.error instanceof Error ? editMutation.error.message : 'Failed to update category'} +

+ )} +
+ + +
+
+
+
+ + {/* Archive Modal */} + +
+
+
+ +
+

Archive category

+

+ {deleteTarget?.label} will be hidden from venue owners. +

+ {deleteTarget && deleteTarget.venue_count > 0 && ( +
+ +

+ {deleteTarget.venue_count} {deleteTarget.venue_count === 1 ? 'venue uses' : 'venues use'} this category.{' '} + Existing venues are unaffected but this category won't appear for new venues. +

+
+ )} + {deleteMutation.error && ( +
+ {deleteMutation.error instanceof Error ? deleteMutation.error.message : 'Failed to archive category'} +
+ )} +
+ + +
+
+
+
+ +
+ ) +} diff --git a/apps/admin-panel/src/pages/ComingSoon.tsx b/apps/admin-panel/src/pages/ComingSoon.tsx new file mode 100644 index 000000000..bf0047adb --- /dev/null +++ b/apps/admin-panel/src/pages/ComingSoon.tsx @@ -0,0 +1,22 @@ +import { EmptyState } from '@venue404/ui' +import { Construction } from 'lucide-react' +import { AdminLayout } from '../components/AdminLayout' + +type ComingSoonProps = { + title: string + description: string +} + +export default function ComingSoon({ title, description }: ComingSoonProps) { + return ( + +
+ } + title={title} + description={description} + /> +
+
+ ) +} diff --git a/apps/admin-panel/src/pages/Dashboard.tsx b/apps/admin-panel/src/pages/Dashboard.tsx new file mode 100644 index 000000000..868de58b9 --- /dev/null +++ b/apps/admin-panel/src/pages/Dashboard.tsx @@ -0,0 +1,237 @@ +import { useQuery } from '@tanstack/react-query' +import { useAuth } from '../lib/AuthContext' +import { + MetricCard, ActivityItem, + SectionHeader, StatusBadge, EmptyState, + type DashboardMetric, +} from '@venue404/ui' +import { + Building2, UserCheck, + CalendarDays, ClipboardList, + CheckCircle2, XCircle, Clock, + RefreshCw, +} from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { AdminLayout } from '../components/AdminLayout' +import { createClient, ApiError } from '@venue404/api-client' +import { adminActionEndpoints, adminUserEndpoints, adminBookingEndpoints, adminVenueEndpoints } from '@venue404/api-client' +import { GrowthChart } from '../components/GrowthChart' + +const actionsApi = adminActionEndpoints(createClient()) +const usersApi = adminUserEndpoints(createClient()) +const bookingsApi = adminBookingEndpoints(createClient()) +const venuesApi = adminVenueEndpoints(createClient()) + +const METRIC_TEMPLATES: DashboardMetric[] = [ + { + label: 'Pending Approvals', + value: '—', + description: 'Venues awaiting approval', + icon: , + accent: 'amber', + }, + { + label: 'Active Bookings', + value: '—', + description: 'Confirmed this month', + icon: , + accent: 'brand', + }, + { + label: 'Venue Owners', + value: '—', + description: 'Registered on platform', + icon: , + accent: 'emerald', + }, + { + label: 'Open Actions', + value: '—', + description: 'Total admin actions logged', + icon: , + accent: 'violet', + }, +] + +const today = new Date().toLocaleDateString('en-IN', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', +}) + +function actionLabel(type: string): string { + return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function actionIcon(type: string) { + if (type.endsWith('approved') || type.endsWith('reactivated') || type.endsWith('completed')) + return + if (type.endsWith('rejected') || type.endsWith('suspended') || type.endsWith('deleted')) + return + return +} + +function actionBadge(type: string) { + if (type.includes('suspend')) return + if (type.includes('reactivat')) return + if (type.includes('approved')) return + if (type.includes('rejected')) return + return null +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + return `${Math.floor(hrs / 24)}d ago` +} + +function suppressAuthErrors(e: unknown) { + if (e instanceof ApiError && (e.status === 401 || e.status === 403)) throw e + return null +} + +export default function Dashboard() { + const { user } = useAuth() + const navigate = useNavigate() + + const { data: actionsData, isLoading: actionsLoading } = useQuery({ + queryKey: ['admin', 'dashboard', 'actions'], + queryFn: () => actionsApi.listActions({ limit: 4 }).catch(suppressAuthErrors), + }) + + const { data: ownerStats } = useQuery({ + queryKey: ['admin', 'dashboard', 'owner-stats'], + queryFn: () => usersApi.getOwnerStats().catch(suppressAuthErrors), + }) + + const { data: bookingStats } = useQuery({ + queryKey: ['admin', 'dashboard', 'booking-stats'], + queryFn: () => bookingsApi.getStats().catch(suppressAuthErrors), + }) + + const { data: venueStats } = useQuery({ + queryKey: ['admin', 'dashboard', 'venue-stats'], + queryFn: () => venuesApi.getVenueStats().catch(suppressAuthErrors), + }) + + + const recentActions = actionsData?.items ?? [] + const actionsTotal = actionsData?.total ?? null + + const metrics = METRIC_TEMPLATES.map((m) => { + if (m.label === 'Pending Approvals') return { ...m, value: venueStats ? String(venueStats.pending_approval) : '—' } + if (m.label === 'Active Bookings') return { ...m, value: bookingStats ? String(bookingStats.confirmed) : '—' } + if (m.label === 'Venue Owners') return { ...m, value: ownerStats ? String(ownerStats.total) : '—' } + if (m.label === 'Open Actions') return { ...m, value: actionsTotal !== null ? String(actionsTotal) : '—' } + return m + }) + + const firstName = user?.profile.full_name?.split(' ')[0] ?? null + + return ( + + {/* Welcome */} +
+

+ {firstName ? `Good to see you, ${firstName}` : 'Welcome back'} +

+

+ Here is what needs your attention today. +

+
+ + {/* Metrics */} +
+ {metrics.map((m, i) => ( +
+ +
+ ))} +
+ + {/* Content grid */} +
+ + {/* Platform growth chart */} +
+ +
+ + {/* Recent audit actions */} +
+
+ navigate('/audit-log')} + className="press text-xs font-medium text-brand transition-colors hover:text-brand" + > + Full log + + } + /> +
+ + {actionsLoading && ( +
+ +
+ )} + + {!actionsLoading && recentActions.length === 0 && ( +
+ } + title="No actions yet" + description="Admin actions will appear here." + /> +
+ )} + + {!actionsLoading && recentActions.length > 0 && ( +
    + {recentActions.map((a) => ( +
  • + +
  • + ))} +
+ )} +
+
+ + {/* Quick actions */} +
+ +
+ {[ + { label: 'Review pending venues', href: '/venues/pending', icon: }, + { label: 'Manage users', href: '/users', icon: }, + { label: 'Open audit log', href: '/audit-log', icon: }, + { label: 'Active bookings', href: '/bookings', icon: }, + ].map((a) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/admin-panel/src/pages/DeepResearchInsights.tsx b/apps/admin-panel/src/pages/DeepResearchInsights.tsx new file mode 100644 index 000000000..1b092325b --- /dev/null +++ b/apps/admin-panel/src/pages/DeepResearchInsights.tsx @@ -0,0 +1,396 @@ +import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useQuery } from '@tanstack/react-query' +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, +} from 'recharts' +import { + Sparkles, Search, TrendingUp, Target, X, MapPin, Users, Wallet, CalendarDays, Tag, +} from 'lucide-react' +import { createClient, adminDeepResearchEndpoints } from '@venue404/api-client' +import type { DeepResearchQuerySummary, DeepResearchQueryDetail } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, SectionHeader, EmptyState, LoadingScreen, ErrorState, Button, +} from '@venue404/ui' + +const api = adminDeepResearchEndpoints(createClient()) +const PAGE_SIZE = 20 +const DEBOUNCE_MS = 350 + +type QueryBreakdown = { + intent?: string + city?: string | null + venue_type?: string | null + capacity?: number | null + budget_hint?: string | null + date_hint?: string | null + required_amenities?: string[] + special_requirements?: string[] +} + +function pct(value: number | null | undefined) { + if (value == null) return '—' + return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%` +} + +function fmtDateTime(iso: string): string { + return new Date(iso).toLocaleString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + return `${days}d ago` +} + +export default function DeepResearchInsights() { + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const [detailId, setDetailId] = useState(null) + + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { setSearch(value); setPage(1) }, DEBOUNCE_MS) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + const statsQuery = useQuery({ + queryKey: ['admin', 'deep-research', 'stats'], + queryFn: () => api.getStats(30), + }) + + const listQuery = useQuery({ + queryKey: ['admin', 'deep-research', 'queries', { page, search }], + queryFn: () => api.listQueries({ page, page_size: PAGE_SIZE, search: search.trim() || undefined }), + }) + + const stats = statsQuery.data + const chartData = stats + ? stats.labels.map((label, i) => ({ label, count: stats.query_counts[i] })) + : [] + + const items = listQuery.data?.items ?? [] + const total = listQuery.data?.total ?? 0 + const totalPages = Math.ceil(total / PAGE_SIZE) || 1 + + return ( + + {/* Metric strip */} +
+ {[ + { label: 'Total queries', value: stats?.total_queries, icon: , accent: 'brand' as const, description: 'All time' }, + { label: 'Avg results / query', value: stats ? stats.avg_result_count.toFixed(1) : undefined, icon: , accent: 'emerald' as const, description: 'Internal catalog matches' }, + { label: 'Avg match score', value: stats ? pct(stats.avg_match_score_overall) : undefined, icon: , accent: 'violet' as const, description: 'Blended relevance, all-time' }, + { label: 'Last 30 days', value: stats?.query_counts.reduce((a, b) => a + b, 0), icon: , accent: 'amber' as const, description: 'Queries this period' }, + ].map((m) => ( + + ))} +
+ + {/* Chart */} +
+
+ +
+
+ {statsQuery.isLoading || !stats ? ( +
Loading…
+ ) : ( + + + + + + + + + + )} +
+
+ + {/* Recent queries table */} +
+
+ +
+
+
+ + {listQuery.isLoading && ( +
+ )} + + {!listQuery.isLoading && listQuery.error && ( +
+ listQuery.refetch()}>Retry} + /> +
+ )} + + {!listQuery.isLoading && !listQuery.error && items.length === 0 && ( +
+ } + title="No queries found" + description={search ? 'Try a different search term.' : 'Deep Research queries will appear here once users start searching.'} + /> +
+ )} + + {!listQuery.isLoading && !listQuery.error && items.length > 0 && ( + <> +
+ + + + + + + + + + + + {items.map((q) => ( + setDetailId(q.id)} /> + ))} + +
QueryCityResultsMatch scoreWhen +
+
+ + {totalPages > 1 && ( +
+ {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)} of {total.toLocaleString()} +
+ + Page {page} of {totalPages} + +
+
+ )} + + )} +
+ + {detailId && setDetailId(null)} />} +
+ ) +} + +function QueryRow({ query: q, onViewDetails }: { query: DeepResearchQuerySummary; onViewDetails: () => void }) { + return ( + + + {q.query_text} + + {q.city_filter ?? '—'} + {q.result_count} + {pct(q.avg_match_score)} + + {timeAgo(q.created_at)} + + + + + + ) +} + +function DetailField({ icon: Icon, label, value }: { icon: typeof Sparkles; label: string; value: string | null | undefined }) { + return ( +
+ + + +
+

{label}

+

{value ?? '—'}

+
+
+ ) +} + +function QueryDetailModal({ queryId, onClose }: { queryId: string; onClose: () => void }) { + const detailQuery = useQuery({ + queryKey: ['admin', 'deep-research', 'query', queryId], + queryFn: () => api.getQuery(queryId), + }) + + const detail: DeepResearchQueryDetail | undefined = detailQuery.data + const breakdown = (detail?.understanding_json ?? null) as QueryBreakdown | null + + return createPortal( +
+
+ +
+
+
+

Query details

+ {detail &&

{fmtDateTime(detail.created_at)}

} +
+ +
+ +
+ {detailQuery.isLoading &&

Loading…

} + + {detail && ( + <> +
+

Raw query

+

{detail.query_text}

+
+ + {breakdown && ( +
+

Model breakdown

+
+ + + + + + +
+ +
+

Required amenities

+ {(breakdown.required_amenities?.length ?? 0) > 0 ? ( +
+ {breakdown.required_amenities!.map((tag) => ( + + {tag.replace(/_/g, ' ')} + + ))} +
+ ) : ( +

None detected

+ )} +
+ +
+

Special requirements

+ {(breakdown.special_requirements?.length ?? 0) > 0 ? ( +
    + {breakdown.special_requirements!.map((req) => ( +
  • + + {req} +
  • + ))} +
+ ) : ( +

None detected

+ )} +
+
+ )} + +
+
+

Results found

+

{detail.result_count}

+
+
+

Avg match score

+

{pct(detail.avg_match_score)}

+
+
+ + {detail.top_results_json && detail.top_results_json.length > 0 && ( +
+

Top results

+
+ {detail.top_results_json.map((r) => ( +
+ {r.name} +
+ {r.match_source && ( + + {r.match_source} + + )} + {pct(r.match_score)} +
+
+ ))} +
+
+ )} + + )} +
+
+
, + document.body, + ) +} diff --git a/apps/admin-panel/src/pages/ExternalReservations.tsx b/apps/admin-panel/src/pages/ExternalReservations.tsx new file mode 100644 index 000000000..f1c98e19a --- /dev/null +++ b/apps/admin-panel/src/pages/ExternalReservations.tsx @@ -0,0 +1,563 @@ +import { useState } from 'react' +import { createPortal } from 'react-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + PhoneCall, UserCheck, Send, CalendarPlus, MapPin, Users as UsersIcon, Tag, + X, Mail, Phone, Globe, Star, ExternalLink, StickyNote, Calendar, +} from 'lucide-react' +import { createClient, adminExternalReservationEndpoints } from '@venue404/api-client' +import type { + ExternalReservationSummary, ExternalReservationStatus, +} from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + SectionHeader, StatusBadge, EmptyState, LoadingScreen, ErrorState, Button, Modal, +} from '@venue404/ui' + +const api = adminExternalReservationEndpoints(createClient()) + +const TABS: { label: string; value: ExternalReservationStatus | '' }[] = [ + { label: 'All', value: '' }, + { label: 'New', value: 'new' }, + { label: 'Contacted', value: 'contacted' }, + { label: 'Owner Interested', value: 'owner_interested' }, + { label: 'Owner Invited', value: 'owner_invited' }, + { label: 'Venue Pending', value: 'venue_pending_approval' }, + { label: 'Venue Approved', value: 'venue_approved' }, + { label: 'Booking Created', value: 'booking_created' }, +] + +function statusVariant(s: ExternalReservationStatus): 'success' | 'danger' | 'pending' | 'warning' | 'neutral' { + if (s === 'booking_created' || s === 'closed' || s === 'venue_approved') return 'success' + if (s === 'cancelled' || s === 'rejected') return 'danger' + if (s === 'new') return 'neutral' + return 'pending' +} + +function statusLabel(s: ExternalReservationStatus): string { + return s.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') +} + +const PAGE_SIZE = 12 + +export default function ExternalReservations() { + const qc = useQueryClient() + const [activeTab, setActiveTab] = useState('') + const [page, setPage] = useState(1) + + const [contactTarget, setContactTarget] = useState(null) + const [contactMethod, setContactMethod] = useState('') + const [contactNotes, setContactNotes] = useState('') + const [followUpDate, setFollowUpDate] = useState('') + + const [detailTarget, setDetailTarget] = useState(null) + + const [inviteTarget, setInviteTarget] = useState(null) + const [venueName, setVenueName] = useState('') + const [ownerName, setOwnerName] = useState('') + const [ownerEmail, setOwnerEmail] = useState('') + const [ownerPhone, setOwnerPhone] = useState('') + const [inviteLink, setInviteLink] = useState(null) + const [linkCopied, setLinkCopied] = useState(false) + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'external-reservations', { page, status: activeTab }], + queryFn: () => api.listReservations({ page, page_size: PAGE_SIZE, status: activeTab || undefined }), + }) + + const items = data?.items ?? [] + const total = data?.total ?? 0 + const totalPages = data?.total_pages ?? 1 + + const invalidate = () => qc.invalidateQueries({ queryKey: ['admin', 'external-reservations'] }) + + const contactMutation = useMutation({ + mutationFn: ({ id }: { id: string }) => + api.contactOwner(id, { + contact_method: contactMethod, + notes: contactNotes || undefined, + follow_up_date: followUpDate || undefined, + }), + onSuccess: () => { invalidate(); closeContact() }, + }) + + const markInterestedMutation = useMutation({ + mutationFn: (id: string) => api.markInterested(id), + onSuccess: invalidate, + }) + + const inviteMutation = useMutation({ + mutationFn: ({ id }: { id: string }) => + api.inviteOwner(id, { + venue_name: venueName, + owner_name: ownerName || undefined, + email: ownerEmail, + phone: ownerPhone || undefined, + }), + onSuccess: (res) => { invalidate(); setInviteLink(res.action_link) }, + }) + + const createBookingMutation = useMutation({ + mutationFn: (id: string) => api.createBooking(id), + onSuccess: invalidate, + }) + + function closeContact() { + setContactTarget(null); setContactMethod(''); setContactNotes(''); setFollowUpDate('') + contactMutation.reset() + } + function closeInvite() { + setInviteTarget(null); setVenueName(''); setOwnerName(''); setOwnerEmail(''); setOwnerPhone('') + setInviteLink(null); setLinkCopied(false) + inviteMutation.reset() + } + + return ( + +
+
+ +
+ {TABS.map((tab) => ( + + ))} +
+
+ + {isLoading &&
} + + {!isLoading && error && ( +
+ Retry} + /> +
+ )} + + {!isLoading && !error && items.length === 0 && ( +
+ } + title="No reservations found" + description="No external reservations match this filter." + /> +
+ )} + + {!isLoading && !error && items.length > 0 && ( +
+ {items.map((r) => ( + setContactTarget(r)} + onMarkInterested={() => markInterestedMutation.mutate(r.id)} + onInvite={() => { setInviteTarget(r); setVenueName(r.lead_name) }} + onCreateBooking={() => createBookingMutation.mutate(r.id)} + onViewDetails={() => setDetailTarget(r)} + markInterestedPending={markInterestedMutation.isPending} + createBookingPending={createBookingMutation.isPending} + /> + ))} +
+ )} + + {!isLoading && totalPages > 1 && ( +
+ {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)} of {total} +
+ + Page {page} of {totalPages} + +
+
+ )} +
+ + {/* Contact modal */} + +
+
+

Log contact

+

+ Record how you reached out to {contactTarget?.lead_name}. +

+
+
+ + setContactMethod(e.target.value)} autoFocus /> +
+
+ +