# Vihaya Events — booking storefront starter (Next.js App Router) Write each file below at the given path. Then: npm install next react react-dom # put your key in .env.local npm run dev The API key is read only in server code. The browser talks to /api/checkout/* on your own origin and never sees it. ======================================================================== FILE: .env.local WHY: Server-only. No NEXT_PUBLIC_ prefix, so Next will not inline it into the client bundle. ======================================================================== # Create a key at https://events.vihaya.app/profile/developer/keys # A "Checkout only" key is enough for a storefront. VIHAYA_API_KEY=vh_live_xxxxxxxxxxxxxxxxxxxx ======================================================================== FILE: lib/vihaya.ts WHY: The only file that knows the API key. Import it from server code exclusively. ======================================================================== import 'server-only'; const BASE = 'https://events.vihaya.app'; function key(): string { const k = process.env.VIHAYA_API_KEY; // Fail loudly at the first call rather than sending an unauthenticated // request and reading a confusing 401 back. if (!k) throw new Error('VIHAYA_API_KEY is not set'); return k; } export type VihayaError = { error: string; status: number }; async function call(path: string, init?: RequestInit): Promise { const res = await fetch(BASE + path, { ...init, headers: { 'x-api-key': key(), 'content-type': 'application/json', ...init?.headers, }, // Listings can be cached briefly; anything that books must not be. cache: init?.method && init.method !== 'GET' ? 'no-store' : 'no-store', }); const body = await res.json().catch(() => ({})); if (!res.ok) { const err: VihayaError = { error: body?.error ?? res.statusText, status: res.status }; throw Object.assign(new Error(err.error), err); } return body as T; } export type Track = { title: string; price?: number; capacity?: number; registrationCount?: number; status?: string; date?: string; }; export type VihayaEvent = { id: string; title: string; slug?: string; description?: string; imageUrl?: string; date?: string; endDate?: string; venueName?: string; city?: string; isFree?: boolean; price?: number; capacity?: number; registrationCount?: number; status?: 'draft' | 'published' | 'archived'; subEvents?: Track[]; }; /** Responses are shaped { success, data }. */ export async function listEvents(): Promise { const r = await call<{ data: VihayaEvent[] }>('/api/v1/events'); return r.data ?? []; } export async function getEvent(id: string): Promise { const r = await call<{ data: VihayaEvent }>(`/api/v1/events/${id}`); return r.data; } export type Attendee = { name: string; email: string; phone?: string; customFields?: Record; promoCode?: string; }; /** Free event → { success, registrationId }. Paid event → an order to pay. */ export type StartResult = | { kind: 'confirmed'; registrationId: string } | { kind: 'payment'; registrationId: string; orderId: string; amount: number; currency: string; key: string }; export async function startRegistration(eventId: string, attendee: Attendee): Promise { const r = await call>(`/api/v1/events/${eventId}/register`, { method: 'POST', body: JSON.stringify(attendee), }); // Branch on the shape that came back rather than on whether YOU think the // event is free — an event can change price between page load and checkout. if (r.orderId) { return { kind: 'payment', registrationId: r.registrationId, orderId: r.orderId, amount: r.amount, currency: r.currency ?? 'INR', key: r.key, }; } return { kind: 'confirmed', registrationId: r.registrationId }; } /** * Step 2. Sends exactly the three ids the endpoint accepts. * The server verifies paymentId against Razorpay before confirming, so this * returning OK is the only thing that means "paid". */ export async function confirmRegistration( eventId: string, ids: { registrationId: string; orderId: string; paymentId: string }, ): Promise<{ success: boolean }> { return call(`/api/v1/events/${eventId}/register`, { method: 'POST', body: JSON.stringify(ids), }); } ======================================================================== FILE: app/api/checkout/start/route.ts WHY: Step 1, behind your own origin. The browser posts here; only this handler holds the key. ======================================================================== import { NextResponse } from 'next/server'; import { startRegistration } from '@/lib/vihaya'; export async function POST(req: Request) { const { eventId, name, email, phone, customFields, promoCode } = await req.json(); if (!eventId || !name || !email) { return NextResponse.json({ error: 'eventId, name and email are required' }, { status: 400 }); } try { const result = await startRegistration(eventId, { name, email, phone, customFields, promoCode }); // Everything here is safe to send to the browser: the Razorpay `key` is a // publishable key, not your Vihaya key. return NextResponse.json(result); } catch (e: any) { // Pass the upstream status through so the UI can tell "sold out" from "bad request". return NextResponse.json({ error: e.error ?? 'Registration failed' }, { status: e.status ?? 502 }); } } ======================================================================== FILE: app/api/checkout/confirm/route.ts WHY: Step 2. Called after Razorpay hands the browser a payment id. ======================================================================== import { NextResponse } from 'next/server'; import { confirmRegistration } from '@/lib/vihaya'; export async function POST(req: Request) { const { eventId, registrationId, orderId, paymentId } = await req.json(); if (!eventId || !registrationId || !orderId || !paymentId) { return NextResponse.json({ error: 'Missing confirmation ids' }, { status: 400 }); } try { const result = await confirmRegistration(eventId, { registrationId, orderId, paymentId }); return NextResponse.json(result); } catch (e: any) { // A failure here means the registration stays pending — say so, and do not // show the user a ticket. return NextResponse.json({ error: e.error ?? 'Confirmation failed' }, { status: e.status ?? 502 }); } } ======================================================================== FILE: app/page.tsx WHY: The listing. A server component, so the fetch (and the key) stay on the server. ======================================================================== import Link from 'next/link'; import { listEvents } from '@/lib/vihaya'; export const revalidate = 60; export default async function EventsPage() { const events = (await listEvents()).filter(e => e.status === 'published'); return (

Upcoming events

{events.length === 0 && (

Nothing on sale right now.

)}
{events.map(e => { const soldOut = !!e.capacity && (e.registrationCount ?? 0) >= e.capacity; return ( {e.imageUrl && ( /* eslint-disable-next-line @next/next/no-img-element */ )}

{e.title}

{e.date ? new Date(e.date).toLocaleDateString() : 'Date TBC'} {e.city ? ` · ${e.city}` : ''}

{soldOut ? 'Sold out' : e.isFree ? 'Free' : `₹${e.price}`}

); })}
); } ======================================================================== FILE: app/events/[id]/page.tsx WHY: One event, with the booking form mounted as a client island. ======================================================================== import { getEvent } from '@/lib/vihaya'; import { BookingForm } from './BookingForm'; export default async function EventPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const event = await getEvent(id); const seatsLeft = event.capacity ? Math.max(0, event.capacity - (event.registrationCount ?? 0)) : null; return (
{event.imageUrl && ( /* eslint-disable-next-line @next/next/no-img-element */ )}

{event.title}

{event.date ? new Date(event.date).toLocaleString() : 'Date TBC'} {event.venueName ? ` · ${event.venueName}` : ''}

{event.description && (

{event.description}

)} {seatsLeft !== null && seatsLeft <= 10 && seatsLeft > 0 && (

Only {seatsLeft} seats left

)}
{seatsLeft === 0 ? (

This event is sold out.

) : ( )}
); } ======================================================================== FILE: app/events/[id]/BookingForm.tsx WHY: The whole checkout, both steps, including the Razorpay handoff in the middle. ======================================================================== 'use client'; import { useState } from 'react'; declare global { interface Window { Razorpay?: any } } /** Razorpay's script is loaded on demand so it costs nothing on a free event. */ function loadRazorpay(): Promise { return new Promise((resolve, reject) => { if (window.Razorpay) return resolve(); const s = document.createElement('script'); s.src = 'https://checkout.razorpay.com/v1/checkout.js'; s.onload = () => resolve(); s.onerror = () => reject(new Error('Could not load Razorpay')); document.body.appendChild(s); }); } export function BookingForm({ eventId, isFree, price, title, }: { eventId: string; isFree: boolean; price: number; title: string }) { const [form, setForm] = useState({ name: '', email: '', phone: '' }); const [state, setState] = useState<'idle' | 'working' | 'done'>('idle'); const [error, setError] = useState(null); const set = (k: keyof typeof form) => (e: React.ChangeEvent) => setForm(f => ({ ...f, [k]: e.target.value })); async function submit(e: React.FormEvent) { e.preventDefault(); setError(null); setState('working'); try { // ---- Step 1 ------------------------------------------------------- const startRes = await fetch('/api/checkout/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ eventId, ...form }), }); const start = await startRes.json(); if (!startRes.ok) throw new Error(start.error); // A free event is already confirmed. There is no step 2. if (start.kind === 'confirmed') { setState('done'); return; } // ---- Razorpay ------------------------------------------------------ await loadRazorpay(); await new Promise((resolve, reject) => { const rz = new window.Razorpay({ key: start.key, order_id: start.orderId, amount: start.amount, // already in paise — do not convert currency: start.currency, name: title, prefill: { name: form.name, email: form.email, contact: form.phone }, // The user closing the sheet is not an error, but it is not a sale // either — the registration simply stays pending. modal: { ondismiss: () => reject(new Error('Payment cancelled')) }, handler: async (r: any) => { try { // ---- Step 2 ----------------------------------------------- const confirmRes = await fetch('/api/checkout/confirm', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ eventId, registrationId: start.registrationId, orderId: start.orderId, paymentId: r.razorpay_payment_id, }), }); const confirm = await confirmRes.json(); if (!confirmRes.ok) throw new Error(confirm.error); resolve(); } catch (err) { reject(err); } }, }); rz.open(); }); setState('done'); } catch (err: any) { setError(err.message ?? 'Something went wrong'); setState('idle'); } } if (state === 'done') { return (

You're in.

Your ticket and QR code are on their way by email.

); } return (

Your ticket is sent here.

{error &&

{error}

}
); }