Port Archive microsite UI and wire defaultsite API.

Replace section renderer with named section components, add theme patterns/bootstrap deps, and format countdown in IST.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-08-06 13:04:58 +05:30
parent d9843a5fb1
commit c9519ac24c
42 changed files with 5283 additions and 1893 deletions

View File

@ -6,3 +6,7 @@ VITE_PUBLIC_WEB_BASE=http://localhost:5175
# Production (see .env.production — used automatically by `npm run build`)
# VITE_API_BASE_URL=https://api.eventbuzz.live/api
# VITE_PUBLIC_WEB_BASE=https://web.eventbuzz.live
# Landing page demo checkout (public ticket catalog)
VITE_DEMO_EVENT_ID=6a620657ef2449311c51fd6b
VITE_DEMO_TICKET_ID=6a6b37ba9ce2326fe3e591f6

4
.env.production Normal file
View File

@ -0,0 +1,4 @@
VITE_API_BASE_URL=https://api.eventbuzz.live/api
VITE_PUBLIC_WEB_BASE=https://web.eventbuzz.live
VITE_DEMO_EVENT_ID=6a620657ef2449311c51fd6b
VITE_DEMO_TICKET_ID=6a6b37ba9ce2326fe3e591f6

182
deploy.sh
View File

@ -1,181 +1,3 @@
#!/usr/bin/env bash
#
# EventBuzz production deploy
#
# Usage:
# ./deploy.sh # backend + dashboard + visitor website + public web
# ./deploy.sh backend
# ./deploy.sh frontend
# ./deploy.sh website
# ./deploy.sh web
# ./deploy.sh all
#
# Requirements: ssh, rsync, npm, aws CLI (profile stratawiztech)
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SSH_KEY="${SSH_KEY:-$HOME/Documents/Keys/ravimac}"
SSH_HOST="${SSH_HOST:-root@168.144.70.247}"
BACKEND_REMOTE="${BACKEND_REMOTE:-/var/www/backend}"
AWS_PROFILE="${AWS_PROFILE:-stratawiztech}"
FRONTEND_DIR="$ROOT/frontend"
EVENTBUZZ_DIR="$ROOT/eventbuzz"
WEB_DIR="$ROOT/web"
BACKEND_DIR="$ROOT/backend"
S3_CONSOLE="s3://eventbuzzlive/console/"
S3_WEBSITE="s3://eventbuzzlive/website/"
S3_WEB="s3://eventbuzzlive/web/"
# CloudFront distributions (console dashboard + visitor website + public web)
CF_CONSOLE="${CF_CONSOLE:-E1QQM75X0JF0IU}"
CF_WEBSITE="${CF_WEBSITE:-E3ORUQT7269RIM}"
CF_WEB="${CF_WEB:-ETZS6Z42DG7I6}"
log() { printf '\n\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
ssh_cmd() {
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=accept-new "$SSH_HOST" "$@"
}
rsync_ssh() {
rsync -avz -e "ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new" "$@"
}
invalidate_cloudfront() {
local distribution_id="$1"
local label="$2"
log "CloudFront invalidation ($label) → $distribution_id /*"
aws cloudfront create-invalidation \
--distribution-id "$distribution_id" \
--paths "/*" \
--profile "$AWS_PROFILE" \
--output text \
--query 'Invalidation.Id'
}
# Ensure dashboard / visitor builds use production API URLs (not localhost).
apply_production_env() {
log "Applying production env for dashboard (frontend/.env.production)"
if [[ ! -f "$FRONTEND_DIR/.env.production" ]]; then
die "Missing $FRONTEND_DIR/.env.production — create it with production VITE_* URLs."
fi
# If a local .env exists, show what we're overriding for clarity
if [[ -f "$FRONTEND_DIR/.env" ]]; then
grep -E '^VITE_API_URL=' "$FRONTEND_DIR/.env.production" || true
fi
log "Applying production env for visitor site (eventbuzz/.env.production)"
if [[ ! -f "$EVENTBUZZ_DIR/.env.production" ]]; then
die "Missing $EVENTBUZZ_DIR/.env.production — create it with production VITE_* URLs."
fi
grep -E '^VITE_API_BASE_URL=' "$EVENTBUZZ_DIR/.env.production" || true
log "Applying production env for public web (web/.env.production)"
if [[ ! -f "$WEB_DIR/.env.production" ]]; then
die "Missing $WEB_DIR/.env.production — create it with production VITE_* URLs."
fi
grep -E '^VITE_(API_BASE_URL|PUBLIC_WEB_BASE)=' "$WEB_DIR/.env.production" || true
}
deploy_backend() {
log "Deploying backend → $SSH_HOST:$BACKEND_REMOTE/"
[[ -d "$BACKEND_DIR/src" ]] || die "Backend src folder not found at $BACKEND_DIR/src"
[[ -f "$BACKEND_DIR/package.json" ]] || die "Backend package.json not found"
[[ -f "$SSH_KEY" ]] || die "SSH key not found: $SSH_KEY"
rsync_ssh --delete \
"$BACKEND_DIR/src/" \
"$SSH_HOST:$BACKEND_REMOTE/src/"
log "Syncing package.json + package-lock.json"
rsync_ssh \
"$BACKEND_DIR/package.json" \
"$BACKEND_DIR/package-lock.json" \
"$SSH_HOST:$BACKEND_REMOTE/"
log "Installing dependencies + reloading API on server (pm2)"
ssh_cmd "cd '$BACKEND_REMOTE' && npm ci --omit=dev && (pm2 reload all || pm2 restart all || echo 'pm2 reload skipped — run manually on server')"
}
deploy_frontend() {
log "Building dashboard (console) for production"
apply_production_env
cd "$FRONTEND_DIR"
npm run build
log "Uploading dashboard dist → $S3_CONSOLE"
aws s3 rm "$S3_CONSOLE" --recursive --profile "$AWS_PROFILE"
aws s3 sync "$FRONTEND_DIR/dist" "$S3_CONSOLE" --profile "$AWS_PROFILE"
invalidate_cloudfront "$CF_CONSOLE" "console"
log "Dashboard deploy complete"
}
deploy_website() {
log "Building visitor website for production"
apply_production_env
cd "$EVENTBUZZ_DIR"
npm run build
log "Uploading visitor site dist → $S3_WEBSITE"
aws s3 rm "$S3_WEBSITE" --recursive --profile "$AWS_PROFILE"
aws s3 sync "$EVENTBUZZ_DIR/dist" "$S3_WEBSITE" --profile "$AWS_PROFILE"
invalidate_cloudfront "$CF_WEBSITE" "website"
log "Visitor website deploy complete"
}
deploy_web() {
log "Building public web app for production"
apply_production_env
cd "$WEB_DIR"
npm run build
log "Uploading public web dist → $S3_WEB"
aws s3 rm "$S3_WEB" --recursive --profile "$AWS_PROFILE"
aws s3 sync "$WEB_DIR/dist" "$S3_WEB" --profile "$AWS_PROFILE"
invalidate_cloudfront "$CF_WEB" "web"
log "Public web deploy complete"
}
usage() {
cat <<EOF
Usage: $(basename "$0") [backend|frontend|website|web|all]
backend rsync backend/src + package files, npm ci on server, pm2 reload
frontend production build + S3 sync to $S3_CONSOLE + CF invalidation
website production build + S3 sync to $S3_WEBSITE + CF invalidation
web production build + S3 sync to $S3_WEB + CF invalidation
all run all four (default)
Environment overrides:
SSH_KEY, SSH_HOST, BACKEND_REMOTE, AWS_PROFILE, CF_CONSOLE, CF_WEBSITE, CF_WEB
EOF
}
TARGET="${1:-all}"
case "$TARGET" in
backend) deploy_backend ;;
frontend) deploy_frontend ;;
website) deploy_website ;;
web) deploy_web ;;
all)
deploy_backend
deploy_frontend
deploy_website
deploy_web
;;
-h|--help|help) usage ;;
*) die "Unknown target: $TARGET (use backend|frontend|website|web|all)" ;;
esac
log "Done."
# Deprecated wrapper — use the repo-root deploy script instead.
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/deploy.sh" "$@"

View File

@ -3,7 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EventBuzz</title>
<title>EventBuzz — Live events, ticketing & engagement</title>
<meta
name="description"
content="EventBuzz helps you run registration, ticketing, agendas, and live audience engagement in one place."
/>
</head>
<body>
<div id="root"></div>

1250
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,13 +9,19 @@
"preview": "vite preview --port 5175"
},
"dependencies": {
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"flatpickr": "^4.6.13",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^18.3.1",
"react-icons": "^5.7.0",
"react-player": "^3.4.0",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",

View File

@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { LandingPage } from './pages/LandingPage';
import { EventMicrositePage } from './pages/EventMicrositePage';
import { RegisterPage } from './pages/RegisterPage';
import { FormPage } from './pages/FormPage';
@ -8,6 +9,7 @@ export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/event/:eventId" element={<EventMicrositePage />} />
<Route path="/event/:eventId/register" element={<RegisterPage />} />
<Route path="/event/:eventId/form/:formId" element={<FormPage />} />

View File

@ -5,7 +5,7 @@ import type { PublicMicrositePayload, RegistrationInfo } from '../types';
export const micrositeApi = {
get(eventId: string): Promise<PublicMicrositePayload> {
//Real API call
return apiGet<PublicMicrositePayload>(`/public/events/${eventId}/microsite`);
return apiGet<PublicMicrositePayload>(`/public/events/${eventId}/defaultsite`);
//Mock: Testing data
// return Promise.resolve({

View File

@ -0,0 +1,163 @@
import { apiGet, apiPost } from './client';
export type PublicTicketCatalogItem = {
_id: string;
name: string;
description?: string;
keyPoints?: string[];
basePriceMinor: number;
currency: string;
maxPerOrder?: number | null;
requiresBillingState?: boolean;
};
export type TicketQuote = {
ticketId: string;
quantity: number;
billingStateCode: string | null;
baseMinor: number;
taxMinor: number;
totalMinor: number;
platformFeeMinor?: number;
ticketName?: string;
eventName?: string;
taxLines: { label: string; rateBps: number; amountMinor: number }[];
currency: string;
};
export type PublicCheckoutOptions = {
onlineAvailable: boolean;
onlineGateways?: Array<{
id: string;
provider: string;
label: string;
isActive: boolean;
isPrimary: boolean;
testMode: boolean;
ticketCheckoutSupported: boolean;
}>;
manualUpi: {
manualQrMode: 'upload' | 'dynamic';
eventName: string;
upiQrImageUrl: string;
upiId: string;
payeeName: string;
instructions: string;
platformFeeInr: number;
} | null;
};
export type CheckoutOrderResponse = {
keyId: string;
orderId: string;
amount: number;
currency: string;
transactionId: string;
checkoutSource: string;
};
export type TicketAttendeePayload = {
firstName: string;
lastName: string;
email: string;
phone?: string;
organization?: string;
designation?: string;
displayName?: string;
formData?: Record<string, unknown>;
};
export type PublicEventSummary = {
name: string;
subtitle?: string;
startDate?: string;
endDate?: string;
location?: string;
locationType?: string;
image?: string;
};
export function loadRazorpayScript(): Promise<void> {
if (typeof window === 'undefined') return Promise.reject(new Error('No window'));
const w = window as unknown as { Razorpay?: unknown };
if (w.Razorpay) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://checkout.razorpay.com/v1/checkout.js';
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error('Failed to load Razorpay'));
document.body.appendChild(s);
});
}
export const publicEventTicketsApi = {
getEventSummary(eventId: string): Promise<PublicEventSummary> {
return apiGet<PublicEventSummary>(`/public/events/${eventId}/summary`);
},
listTickets(eventId: string): Promise<PublicTicketCatalogItem[]> {
return apiGet<{ tickets: PublicTicketCatalogItem[] }>(`/public/events/${eventId}/tickets`).then(
(data) => data.tickets ?? [],
);
},
checkoutOptions(eventId: string): Promise<PublicCheckoutOptions> {
return apiGet<PublicCheckoutOptions>(`/public/events/${eventId}/tickets/checkout-options`);
},
quote(eventId: string, payload: { ticketId: string; quantity: number; billingStateCode?: string }): Promise<TicketQuote> {
return apiPost<TicketQuote>(`/public/events/${eventId}/tickets/quote`, payload);
},
manualQuote(
eventId: string,
payload: { ticketId: string; quantity: number; billingStateCode?: string },
): Promise<TicketQuote> {
return apiPost<TicketQuote>(`/public/events/${eventId}/tickets/manual-quote`, payload);
},
checkout(
eventId: string,
payload: {
ticketId: string;
quantity: number;
billingStateCode?: string;
purchaserEmail: string;
sourceFormId?: string;
attendees: TicketAttendeePayload[];
expectedTotalMinor: number;
idempotencyKey?: string;
orgGatewayId?: string;
},
): Promise<CheckoutOrderResponse> {
return apiPost<CheckoutOrderResponse>(`/public/events/${eventId}/tickets/checkout`, payload);
},
verifyPayment(
eventId: string,
payload: { orderId: string; paymentId: string; signature: string },
): Promise<{ verified: boolean }> {
return apiPost<{ verified: boolean }>(`/public/events/${eventId}/payment/verify`, payload);
},
manualSubmit(
eventId: string,
payload: {
ticketId: string;
quantity: number;
billingStateCode?: string;
purchaserEmail: string;
sourceFormId?: string;
attendees: TicketAttendeePayload[];
expectedTotalMinor: number;
screenshotDataUrl: string;
buyerNote?: string;
},
): Promise<{ orderId: string; message: string }> {
return apiPost<{ orderId: string; message: string }>(
`/public/events/${eventId}/tickets/manual-submit`,
payload,
);
},
};

BIN
src/assets/logo-white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
src/assets/main_banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -6,6 +6,8 @@ import { DynamicFormFields, buildSubmitData, validateFormFields } from './Dynami
import { FormCaptchaField, validateCaptchaAnswer } from './FormCaptchaField';
import { assignUniqueFieldNames } from '../../utils/formFieldNames';
import { ensureRegistrationCoreFields } from '../../utils/registrationFormFields';
import { RegistrationTicketStep } from './RegistrationTicketStep';
import { buildTicketAttendeeFromForm } from '../../utils/registrationTicketAttendee';
type Props = {
eventId: string;
@ -25,11 +27,15 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [doneMessage, setDoneMessage] = useState<string | null>(null);
const [step, setStep] = useState<1 | 2>(1);
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [captchaToken, setCaptchaToken] = useState('');
const [captchaMode, setCaptchaMode] = useState<'server' | 'local'>('server');
const [localCaptchaAnswer, setLocalCaptchaAnswer] = useState<string | null>(null);
const ticketsEnabled = !!(form?.settings?.paymentEnabled && (form.tickets?.length || 0) > 0);
const handleCaptchaModeChange = useCallback((mode: 'server' | 'local', localAnswer: string | null) => {
setCaptchaMode(mode);
setLocalCaptchaAnswer(localAnswer);
@ -69,6 +75,12 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
setSubmitError(captchaErr);
return;
}
if (ticketsEnabled) {
setStep(2);
return;
}
setSubmitting(true);
try {
await formsApi.submit(formId, {
@ -103,7 +115,10 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
}
if (done) {
const msg = form.settings?.thankYouMessage?.trim() || 'Thank you for your response.';
const msg =
doneMessage
|| form.settings?.thankYouMessage?.trim()
|| 'Thank you for your response.';
const ev = form.event;
return (
<div className={embed ? 'form-page-center form-page-center--embed' : 'form-page-center'}>
@ -126,6 +141,11 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
const ev = form.event;
const pageTitle = titleOverride || form.name;
const attendee = buildTicketAttendeeFromForm(
form.fields || [],
values,
buildSubmitData(form.fields || [], values),
);
return (
<div className={embed ? 'form-page form-page--embed' : 'form-page'}>
@ -136,7 +156,9 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
</h1>
{form.description ? <p className="form-desc">{form.description}</p> : <div style={{ marginBottom: 24 }} />}
{step === 1 ? (
<form className="form-panel" onSubmit={(e) => void onSubmit(e)}>
{ticketsEnabled ? <p className="form-ticket-step-label">Step 1 of 2 — Your details</p> : null}
<DynamicFormFields
fields={form.fields || []}
values={values}
@ -153,9 +175,27 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
/>
{submitError ? <p className="form-error">{submitError}</p> : null}
<button type="submit" className="form-submit" disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit'}
{submitting ? 'Submitting…' : ticketsEnabled ? 'Continue' : 'Submit'}
</button>
</form>
) : (
<RegistrationTicketStep
eventId={eventId}
formId={formId}
eventName={ev?.name}
tickets={form.tickets || []}
attendee={attendee}
onBack={() => setStep(1)}
onPaid={() => {
setDoneMessage(null);
setDone(true);
}}
onPendingReview={(message) => {
setDoneMessage(message);
setDone(true);
}}
/>
)}
</div>
</div>
);

View File

@ -64,7 +64,7 @@ export function PublicFormEventHeader({
const showLoc = d.showVenue && shouldShowLocation(event);
const embedded = variant === 'embedded';
const showBanner = d.showLogo && !!event.imageUrl && !event.logoUrl;
const showBanner = d.showBanner && !!event.imageUrl;
const showLogo = d.showLogo && !!event.logoUrl;
const showTitle = d.showTitle && !!event.name;
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
@ -91,14 +91,14 @@ export function PublicFormEventHeader({
return (
<div className={`form-event-header${embedded ? ' form-event-header--embedded' : ''}`}>
{showLogo ? (
<img src={event.logoUrl!} alt={event.name} className="form-event-logo" />
) : null}
{showBanner ? (
<div className="form-event-banner">
<img src={event.imageUrl!} alt="" />
</div>
) : null}
{showLogo ? (
<img src={event.logoUrl!} alt={event.name} className="form-event-logo" />
) : null}
{showTitle || showSubtitle || metaItems.length > 0 ? (
<div>
{showTitle ? <h1 className="form-event-name">{event.name}</h1> : null}

View File

@ -0,0 +1,406 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import QRCode from 'qrcode';
import {
loadRazorpayScript,
publicEventTicketsApi,
type PublicTicketCatalogItem,
type TicketAttendeePayload,
type TicketQuote,
} from '../../api/publicEventTickets';
import { formatTicketPrice, INDIAN_STATES } from '../../utils/registrationTicketAttendee';
import { buildUpiPayUri } from '../../utils/upiPayUri';
const PAYMENT_CANCELLED_MESSAGE = 'Payment cancelled';
type ManualUpi = NonNullable<
Awaited<ReturnType<typeof publicEventTicketsApi.checkoutOptions>>['manualUpi']
>;
type Props = {
eventId: string;
formId: string;
eventName?: string;
tickets: PublicTicketCatalogItem[];
attendee: TicketAttendeePayload;
onBack: () => void;
onPaid: () => void;
onPendingReview: (message: string) => void;
};
export function RegistrationTicketStep({
eventId,
formId,
eventName,
tickets,
attendee,
onBack,
onPaid,
onPendingReview,
}: Props) {
const [ticketId, setTicketId] = useState(tickets[0]?._id || '');
const [billingState, setBillingState] = useState('');
const [quote, setQuote] = useState<TicketQuote | null>(null);
const [quoteError, setQuoteError] = useState<string | null>(null);
const [payError, setPayError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [checkoutChannel, setCheckoutChannel] = useState<'online' | 'manual'>('online');
const [onlineAvailable, setOnlineAvailable] = useState(false);
const [manualUpi, setManualUpi] = useState<ManualUpi | null>(null);
const [screenshotDataUrl, setScreenshotDataUrl] = useState('');
const [buyerNote, setBuyerNote] = useState('');
const [dynamicQrDataUrl, setDynamicQrDataUrl] = useState<string | null>(null);
const selected = useMemo(() => tickets.find((t) => t._id === ticketId) || null, [tickets, ticketId]);
const payMode = useMemo(() => {
if (onlineAvailable && manualUpi) return 'both';
if (onlineAvailable) return 'online';
if (manualUpi) return 'manual';
return 'none';
}, [onlineAvailable, manualUpi]);
const isManualCheckout = payMode === 'manual' || (payMode === 'both' && checkoutChannel === 'manual');
useEffect(() => {
let cancelled = false;
void publicEventTicketsApi
.checkoutOptions(eventId)
.then((opts) => {
if (cancelled) return;
setOnlineAvailable(!!opts.onlineAvailable);
setManualUpi(opts.manualUpi || null);
if (!opts.onlineAvailable && opts.manualUpi) setCheckoutChannel('manual');
})
.catch(() => {
if (!cancelled) {
setOnlineAvailable(false);
setManualUpi(null);
}
});
return () => {
cancelled = true;
};
}, [eventId]);
useEffect(() => {
if (!ticketId || !selected) {
setQuote(null);
return;
}
if (selected.requiresBillingState && !billingState) {
setQuote(null);
setQuoteError(null);
return;
}
let cancelled = false;
setQuoteError(null);
const run = async () => {
try {
const q = isManualCheckout
? await publicEventTicketsApi.manualQuote(eventId, {
ticketId,
quantity: 1,
billingStateCode: billingState || undefined,
})
: await publicEventTicketsApi.quote(eventId, {
ticketId,
quantity: 1,
billingStateCode: billingState || undefined,
});
if (!cancelled) setQuote(q);
} catch (e) {
if (!cancelled) {
setQuote(null);
setQuoteError(e instanceof Error ? e.message : 'Could not load price');
}
}
};
void run();
return () => {
cancelled = true;
};
}, [eventId, ticketId, billingState, selected, isManualCheckout]);
useEffect(() => {
if (!isManualCheckout || !manualUpi || manualUpi.manualQrMode !== 'dynamic') {
setDynamicQrDataUrl(null);
return;
}
const pa = manualUpi.upiId?.trim();
const evName = (manualUpi.eventName || eventName || '').trim();
const quoteTotal = quote?.totalMinor;
if (!pa || !selected?.name || !evName || quoteTotal == null) {
setDynamicQrDataUrl(null);
return;
}
const pn = `${selected.name} · ${evName}`.slice(0, 99);
const am = (quoteTotal / 100).toFixed(2);
const uri = buildUpiPayUri({ pa, pn, am });
if (!uri) {
setDynamicQrDataUrl(null);
return;
}
let cancelled = false;
void QRCode.toDataURL(uri, { margin: 1, width: 280, errorCorrectionLevel: 'M' }).then((url) => {
if (!cancelled) setDynamicQrDataUrl(url);
});
return () => {
cancelled = true;
};
}, [isManualCheckout, manualUpi, selected?.name, eventName, quote?.totalMinor]);
const onScreenshot = useCallback((file: File | null) => {
if (!file) {
setScreenshotDataUrl('');
return;
}
const reader = new FileReader();
reader.onload = () => setScreenshotDataUrl(typeof reader.result === 'string' ? reader.result : '');
reader.readAsDataURL(file);
}, []);
const pay = async () => {
setPayError(null);
if (!selected || !quote) {
setPayError('Select a ticket and wait for the price.');
return;
}
if (selected.requiresBillingState && !billingState) {
setPayError('Select your billing state.');
return;
}
if (!attendee.email || !attendee.firstName || !attendee.lastName) {
setPayError('Registration details are incomplete. Go back and fill required fields.');
return;
}
if (payMode === 'none') {
setPayError('Payment is not available for this event yet.');
return;
}
if (isManualCheckout && !screenshotDataUrl) {
setPayError('Upload a payment screenshot to continue.');
return;
}
setBusy(true);
try {
const attendees = [attendee];
const pe = attendee.email;
if (isManualCheckout) {
const res = await publicEventTicketsApi.manualSubmit(eventId, {
ticketId,
quantity: 1,
billingStateCode: billingState || undefined,
purchaserEmail: pe,
sourceFormId: formId,
expectedTotalMinor: quote.totalMinor,
screenshotDataUrl,
buyerNote: buyerNote.trim() || undefined,
attendees,
});
onPendingReview(res.message || 'Submitted for review.');
return;
}
const order = await publicEventTicketsApi.checkout(eventId, {
ticketId,
quantity: 1,
billingStateCode: billingState || undefined,
purchaserEmail: pe,
sourceFormId: formId,
expectedTotalMinor: quote.totalMinor,
idempotencyKey: `reg-${formId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
attendees,
});
await loadRazorpayScript();
type RazorpayResponse = { razorpay_order_id: string; razorpay_payment_id: string; razorpay_signature: string };
type RazorpayConstructor = new (opts: {
key: string;
amount: number;
currency: string;
order_id: string;
name: string;
description: string;
handler: (response: RazorpayResponse) => void;
modal?: { ondismiss?: () => void };
theme?: { color?: string };
prefill?: { email?: string; name?: string };
}) => { open: () => void; on: (ev: string, fn: (r: { error: { description?: string } }) => void) => void };
const Rzp = (window as unknown as { Razorpay: RazorpayConstructor }).Razorpay;
await new Promise<void>((resolve, reject) => {
let invoked = false;
const rzp = new Rzp({
key: order.keyId,
amount: order.amount,
currency: order.currency,
order_id: order.orderId,
name: eventName || 'Event',
description: selected.name,
handler: (response: RazorpayResponse) => {
invoked = true;
void publicEventTicketsApi
.verifyPayment(eventId, {
orderId: response.razorpay_order_id,
paymentId: response.razorpay_payment_id,
signature: response.razorpay_signature,
})
.then(() => resolve())
.catch((e) => reject(e instanceof Error ? e : new Error('Verification failed')));
},
modal: {
ondismiss: () => {
if (!invoked) reject(new Error(PAYMENT_CANCELLED_MESSAGE));
},
},
theme: { color: '#0f172a' },
prefill: {
email: pe,
name: `${attendee.firstName} ${attendee.lastName}`.trim(),
},
});
rzp.on('payment.failed', (response: { error: { description?: string } }) => {
reject(new Error(response.error?.description || 'Payment failed'));
});
rzp.open();
});
onPaid();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Checkout failed';
if (msg !== PAYMENT_CANCELLED_MESSAGE) setPayError(msg);
} finally {
setBusy(false);
}
};
if (!tickets.length) {
return (
<div className="form-panel">
<p className="form-error">No tickets are available for this form.</p>
<button type="button" className="form-submit" onClick={onBack}>
Back
</button>
</div>
);
}
return (
<div className="form-panel form-ticket-step">
<p className="form-ticket-step-label">Step 2 of 2 — Choose your ticket</p>
<div className="form-ticket-list">
{tickets.map((t) => {
const active = t._id === ticketId;
return (
<button
key={t._id}
type="button"
className={`form-ticket-card${active ? ' form-ticket-card--active' : ''}`}
onClick={() => setTicketId(t._id)}
>
<span className="form-ticket-card-name">{t.name}</span>
<span className="form-ticket-card-price">
{formatTicketPrice(t.basePriceMinor, t.currency || 'INR')}
</span>
{t.description?.trim() ? (
<span className="form-ticket-card-desc">{t.description.trim()}</span>
) : null}
</button>
);
})}
</div>
{selected?.requiresBillingState ? (
<label className="form-field">
<span className="form-label">Billing state</span>
<select
className="form-input"
value={billingState}
onChange={(e) => setBillingState(e.target.value)}
>
<option value="">Select state…</option>
{INDIAN_STATES.map((s) => (
<option key={s.code} value={s.code}>
{s.name}
</option>
))}
</select>
</label>
) : null}
{payMode === 'both' ? (
<div className="form-ticket-channels">
<button
type="button"
className={`form-ticket-channel${checkoutChannel === 'online' ? ' form-ticket-channel--active' : ''}`}
onClick={() => setCheckoutChannel('online')}
>
Card / UPI (Razorpay)
</button>
<button
type="button"
className={`form-ticket-channel${checkoutChannel === 'manual' ? ' form-ticket-channel--active' : ''}`}
onClick={() => setCheckoutChannel('manual')}
>
Manual UPI
</button>
</div>
) : null}
{isManualCheckout && manualUpi ? (
<div className="form-ticket-manual">
{manualUpi.upiId ? <p className="form-ticket-upi-id">UPI ID: {manualUpi.upiId}</p> : null}
{manualUpi.instructions ? <p className="form-desc">{manualUpi.instructions}</p> : null}
<div className="form-ticket-qr-wrap">
{manualUpi.manualQrMode === 'dynamic' ? (
dynamicQrDataUrl ? (
<img src={dynamicQrDataUrl} alt="UPI QR" className="form-ticket-qr" />
) : (
<p className="form-desc">Select a ticket to generate the UPI QR with the exact amount.</p>
)
) : manualUpi.upiQrImageUrl ? (
<img src={manualUpi.upiQrImageUrl} alt="UPI QR" className="form-ticket-qr" />
) : null}
</div>
<label className="form-field">
<span className="form-label">Payment screenshot</span>
<input
type="file"
accept="image/*"
className="form-input"
onChange={(e) => onScreenshot(e.target.files?.[0] || null)}
/>
</label>
<label className="form-field">
<span className="form-label">Note (optional)</span>
<input
className="form-input"
value={buyerNote}
onChange={(e) => setBuyerNote(e.target.value)}
placeholder="Transaction reference"
/>
</label>
</div>
) : null}
{quoteError ? <p className="form-error">{quoteError}</p> : null}
{quote ? (
<p className="form-ticket-total">
Total: <strong>{formatTicketPrice(quote.totalMinor, quote.currency)}</strong>
{quote.taxMinor > 0 ? (
<span className="form-ticket-tax"> (incl. tax {formatTicketPrice(quote.taxMinor, quote.currency)})</span>
) : null}
</p>
) : null}
{payError ? <p className="form-error">{payError}</p> : null}
<div className="form-ticket-actions">
<button type="button" className="form-submit form-submit--secondary" onClick={onBack} disabled={busy}>
Back
</button>
<button type="button" className="form-submit" onClick={() => void pay()} disabled={busy || !quote || payMode === 'none'}>
{busy ? 'Processing…' : isManualCheckout ? 'Submit payment proof' : 'Pay & register'}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,243 @@
import { Card, Button, Container } from 'react-bootstrap';
import { BsFillPersonFill } from 'react-icons/bs';
import { FaFacebook, FaInstagram, FaLinkedin, FaRegCalendarAlt } from 'react-icons/fa';
import { FaLocationDot, FaXTwitter } from 'react-icons/fa6';
import { useState, type ReactNode } from 'react';
import ReactPlayer from 'react-player';
import { str } from './sectionUtils';
import type { MicrositeAboutConfig, MicrositeSectionEnvelope } from '../../types';
import type { MicrositeSectionContext as Ctx } from './sectionUtils';
export type AboutSectionProps = {
section: MicrositeSectionEnvelope<'about', MicrositeAboutConfig>;
ctx: Ctx;
};
function socialIcon(name: string): ReactNode | null {
switch (name.trim().toLowerCase()) {
case 'facebook':
return <FaFacebook />;
case 'instagram':
return <FaInstagram />;
case 'x':
case 'twitter':
return <FaXTwitter />;
case 'linkedin':
return <FaLinkedin />;
default:
return null;
}
}
function AboutMedia({ mediaType, mediaUrl, alt }: { mediaType: string; mediaUrl: string; alt: string }) {
const type = mediaType.trim().toLowerCase() || 'video';
if (type === 'image') {
return (
<img
src={mediaUrl}
alt={alt}
className="w-100 h-100"
style={{ objectFit: 'cover', display: 'block' }}
/>
);
}
// Default / "video"
return (
<ReactPlayer
src={mediaUrl}
width="100%"
height="100%"
controls
style={{ position: 'absolute', inset: 0 }}
/>
);
}
export function AboutSection({ section, ctx }: AboutSectionProps) {
const c = section.config;
const body = str(c.body) || str(c.lead);
const bullets = Array.isArray(c.bullets) ? (c.bullets as string[]) : [];
const socialLinks = ctx.data.socialLinks ?? [];
const eventName = ctx.data.event?.name || '';
const eventDate = ctx.data.event?.date || '';
const eventLocation = ctx.data.event?.location || '';
const organizer = ctx.data.event?.organizerName || ctx.data.event?.organizer || 'EventBuzz';
const mediaUrl = str(c.mediaUrl);
const mediaType = str(c.mediaType, 'video').toLowerCase();
// Other sections are available on the shared payload via ctx
const registerConfig = ctx.data.register_links?.config;
const ticketsConfig = ctx.data.tickets?.config;
const registerTitle = str(registerConfig?.title, 'Register Now');
const registerText = str(registerConfig?.text, 'Welcome! To join the event, please register below.');
const registerLabel = str(registerConfig?.primaryLabel, 'Register Now');
const registerUrl =
ctx.registrationUrl || str(registerConfig?.primaryUrl) || '#';
const ticketsTitle = str(ticketsConfig?.title, 'Get Tickets');
const ticketsText = str(ticketsConfig?.text);
const ticketsLabel = str(ticketsConfig?.primaryLabel, 'Get Tickets');
const ticketsUrl =
ctx.registrationUrl || str(ticketsConfig?.primaryUrl) || registerUrl;
const ticketTiers = Array.isArray(ticketsConfig?.tiers) ? ticketsConfig.tiers : [];
const tierKey = (tier: { id?: string; name?: string }, index: number) =>
str(tier.id) || str(tier.name) || String(index);
const [selectedTicketKey, setSelectedTicketKey] = useState<string>(() =>
ticketTiers.length ? tierKey(ticketTiers[0]!, 0) : '',
);
return (
<section id={section?.id || 'about'} className="about-section section eb-section-ligh bg-transparent">
<Container>
<Card className="card-theme">
<Card.Body>
<div className="row g-4 pt-2">
<div className="col-12 col-md-6">
{mediaUrl ? (
<div className="media-container overflow-hidden rounded-4 ratio ratio-16x9 mb-4 position-relative">
<AboutMedia mediaType={mediaType} mediaUrl={mediaUrl} alt={eventName || 'Event media'} />
</div>
) : null}
<h2 className="about-event-name mb-4">{eventName}</h2>
{body ? <p style={{ lineHeight: 1.7 }}>{body}</p> : null}
{bullets.length > 0 ? (
<ul className="theme-list" style={{ lineHeight: 1.8 }}>
{bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
) : null}
{socialLinks.length > 0 ? (
<div
className="d-flex align-items-center gap-2 pt-3 mt-3"
style={{ borderTop: `1px dashed var(--theme-card-border-color)` }}
>
<span
className="p-1 px-2 shadow-sm rounded-2 font-14 fw-sb"
style={{ backgroundColor: `var(--btn-bg)` }}
>
Follow us:{' '}
</span>
<div className="d-flex align-items-center gap-2 social-links">
{socialLinks.map((link, i) => {
const icon = socialIcon(link.name);
if (!icon || !link.handleUrl) return null;
return (
<a key={i} href={link.handleUrl} aria-label={link.name} target="_blank" rel="noreferrer">
{icon}
</a>
);
})}
</div>
</div>
) : null}
</div>
<div className="col-12 col-md-6">
<div className="d-flex flex-column gap-3">
<div className="d-flex flex-column gap-2 flex-grow-1">
<div className="d-flex align-items-center gap-2">
<div className="d-flex align-items-center gap-2">
<div className="icon-container">
<BsFillPersonFill />
</div>
<div className="d-flex flex-column">
<span className="font-12">Organized by</span>
<span className="font-14 fw-b">{organizer}</span>
</div>
</div>
</div>
<div className="d-flex align-items-center gap-2">
<div className="d-flex align-items-center gap-2">
<div className="icon-container">
<FaRegCalendarAlt />
</div>
<div className="d-flex flex-column">
<span className="font-12">Date</span>
<span className="font-14 fw-b">{eventDate}</span>
</div>
</div>
</div>
<div className="d-flex align-items-center gap-2">
<div className="d-flex align-items-center gap-2">
<div className="icon-container">
<FaLocationDot />
</div>
<div className="d-flex flex-column">
<span className="font-12">Location</span>
<span className="font-14 fw-b">{eventLocation}</span>
</div>
</div>
</div>
</div>
{(registerConfig || ticketsConfig) ? <hr className="dashed-hr my-2" /> : null}
{registerConfig ? (
<Card id="register" className="card-theme card-register overflow-hidden">
<Card.Header>
<div className="card-title">{registerTitle}</div>
</Card.Header>
<Card.Body>
{registerText ? <div className="d-flex fw-b mb-3">{registerText}</div> : null}
<Button className="btn btn-primary w-100" href={registerUrl}>
{registerLabel}
</Button>
</Card.Body>
</Card>
) : null}
{ticketsConfig ? (
<Card id="tickets" className="card-theme card-tickets overflow-hidden">
<Card.Header>
<div className="card-title">{ticketsTitle}</div>
</Card.Header>
<Card.Body>
{ticketsText ? <div className="d-flex fw-b mb-3">{ticketsText}</div> : null}
<div className="d-flex ticket-items flex-column gap-2">
{ticketTiers.map((tier, i) => {
const key = tierKey(tier, i);
const isSelected = selectedTicketKey === key;
return (
<button
key={key}
type="button"
className={`ticket-item text-start${isSelected ? ' selected' : ''}`}
aria-pressed={isSelected}
onClick={() => setSelectedTicketKey(key)}
>
<div className="ticket-item-title fw-b">{str(tier.name)}</div>
<div className="ticket-item-price">{str(tier.price)}</div>
{tier.description ? (
<div className="ticket-item-description">{str(tier.description)}</div>
) : null}
</button>
);
})}
<Button className="btn btn-primary w-100 mt-3" href={ticketsUrl}>
{ticketsLabel}
</Button>
</div>
</Card.Body>
</Card>
) : null}
</div>
</div>
</div>
</Card.Body>
</Card>
</Container>
</section>
);
}

View File

@ -0,0 +1,148 @@
import { useMemo, useState } from 'react';
import type { AgendaItem, MicrositeAgendaConfig, MicrositeSectionEnvelope } from '../../types';
import type { MicrositeSectionContext } from './sectionUtils';
import { str } from './sectionUtils';
import { IoChevronDownOutline, IoChevronUpOutline } from 'react-icons/io5';
export type AgendaSectionProps = {
section: MicrositeSectionEnvelope<'agenda', MicrositeAgendaConfig>;
ctx: MicrositeSectionContext;
};
export function AgendaSection({ section, ctx }: AgendaSectionProps) {
const title = str(section.config.title, 'Schedule');
const list = useMemo((): AgendaItem[] => {
const items = Array.isArray(section.config.agenda) ? section.config.agenda : [];
if (items.length) return items;
return [
{
title: 'Session 1',
time: '9:00 AM',
track: 'Main',
date: '',
},
];
}, [section.config.agenda]);
const dates = useMemo(() => {
const d = list.map((item) => item.date).filter(Boolean) as string[];
return Array.from(new Set(d));
}, [list]);
const [selectedDate, setSelectedDate] = useState<string>(dates.length ? dates[0]! : '');
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const filteredList = dates.length ? list.filter((item) => item.date === selectedDate) : list;
return (
<section id={section?.id || 'agenda'} className="eb-schedule-section section py-5 bg-transparent">
<div className="container">
<h2 className="eb-section-heading text-center mb-4">{title}</h2>
<div className="d-flex justify-content-cente flex-wrap gap-2 mb-3">
{dates.map((date) => (
<button
key={date}
className={`btn btn-theme-tab ${selectedDate === date ? 'btn-primary' : 'btn-primary-outline'}`}
style={{ marginRight: '8px' }}
onClick={() => {
setSelectedDate(date);
setExpandedIndex(null);
}}
>
{date}
</button>
))}
</div>
<div className="theme-accordion-wrapper">
{filteredList.map((item, index) => {
const isExpanded = expandedIndex === index;
const speakers = Array.isArray(item.speakers) ? item.speakers.filter((s) => str(s.name)) : [];
const hasSpeakers = speakers.length > 0;
return (
<div key={index} className="theme-accordion-item">
<button
onClick={() => setExpandedIndex(isExpanded ? null : index)}
className="theme-accordion-button"
>
<div className="theme-accordion-button-content">
<span className="theme-accordion-time">{item.time || 'TBA'}</span>
<div className="flex-grow-1">
<div className="theme-accordion-title">{item.title || 'Untitled Session'}</div>
{item.track ? <span className="theme-accordion-track">{item.track}</span> : null}
</div>
</div>
<div className="theme-accordion-icon">
{isExpanded ? <IoChevronUpOutline /> : <IoChevronDownOutline />}
</div>
</button>
{isExpanded && (
<div className="theme-accordion-content">
{item.description ? (
<div style={{ marginBottom: '1.5rem' }}>
<p>{item.description}</p>
</div>
) : null}
{hasSpeakers ? (
<div className="theme-accordion-speaker-info">
<h4 className="theme-accordion-speaker-info-heading">
{speakers.length > 1 ? 'Speakers' : 'Speaker'}
</h4>
<div className="d-flex gap-3" style={{ marginTop: '0.75rem' }}>
{speakers.map((speaker, si) => (
<div key={si} className="d-flex align-items-center gap-3">
{speaker.photoUrl ? (
<img
src={speaker.photoUrl}
alt={str(speaker.name)}
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
objectFit: 'cover',
border: '2px solid #e2e8f0',
flexShrink: 0,
}}
/>
) : null}
<div className="theme-accordion-speaker-name">{str(speaker.name)}</div>
</div>
))}
</div>
</div>
) : null}
</div>
)}
</div>
);
})}
</div>
<div className="text-center mt-4">
{ctx.registrationUrl ? (
<a
className="btn btn-primary btn-lg me-2"
href={ctx.registrationUrl}
style={{ minWidth: '220px' }}
>
Register
</a>
) : null}
</div>
</div>
<style>{`
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</section>
);
}

View File

@ -0,0 +1,27 @@
import { Container } from 'react-bootstrap';
import type { MicrositeBannerConfig, MicrositeSectionEnvelope } from '../../types';
import type { MicrositeSectionContext as Ctx } from './sectionUtils';
import { str } from './sectionUtils';
export type BannerSectionProps = {
section: MicrositeSectionEnvelope<'banner', MicrositeBannerConfig>;
ctx: Ctx;
};
export function BannerSection({ section }: BannerSectionProps) {
const c = section.config;
const bgImage = str(c.backgroundImage);
return (
<section id={section?.id || 'hero'}>
<Container fluid className="px-0">
<section className={`eb-hero-block position-relative text-white text-center overflow-hidden${bgImage ? ' eb-hero-has-image' : ''}`}>
{bgImage ? <div className="eb-hero-bg" style={{ backgroundImage: `url(${bgImage})` }} /> : null}
<div className="eb-hero-mask" />
<div className="container eb-hero-inner" />
</section>
</Container>
</section>
);
}

View File

@ -0,0 +1,106 @@
import { FaEnvelope, FaPhoneAlt } from 'react-icons/fa';
import { FaLocationDot } from 'react-icons/fa6';
import { Card, Col, Container, Row } from 'react-bootstrap';
import type { MicrositeContactConfig, MicrositeSectionEnvelope } from '../../types';
import type { MicrositeSectionContext as Ctx } from './sectionUtils';
import { str } from './sectionUtils';
export type ContactSectionProps = {
section: MicrositeSectionEnvelope<'contact', MicrositeContactConfig>;
ctx: Ctx;
};
export function ContactSection({ section, ctx }: ContactSectionProps) {
const c = section.config;
const title = str(c.title, 'Contact');
const text = str(c.text);
const email = str(c.email);
const phone = str(c.phone);
const address = str(c.address);
const eventLocation = str(ctx.data.event.location);
const safeEventLocation = encodeURIComponent(
// "1600 Amphitheatre Parkway, Mountain View, CA"
eventLocation
);
return (
<section id={section?.id || 'contact'} className="section">
<Container>
<Row className="g-4">
<Col xs={12} md={6}>
<Card className="h-100 card-theme card-contact-details">
<Card.Header>
<div className="card-title">{title}</div>
</Card.Header>
<Card.Body>
{text ? <p className="card-text mb-3">{text}</p> : null}
<div className="d-flex flex-column gap-2">
{email ? (
<div className="d-flex align-items-center contact-link-item gap-2">
<div className="icon-container">
<FaEnvelope />
</div>
<div className="d-flex flex-column">
<span className="font-12">Email</span>
<span className="font-14 fw-b contact-person">
<a href={`mailto:${email}`}>{email}</a>
</span>
</div>
</div>
) : null}
{phone ? (
<div className="d-flex align-items-center contact-link-item gap-2">
<div className="icon-container">
<FaPhoneAlt />
</div>
<div className="d-flex flex-column">
<span className="font-12">Phone</span>
<span className="font-14 fw-b contact-person">
<a href={`tel:${phone.replace(/\s+/g, '')}`}>{phone}</a>
</span>
</div>
</div>
) : null}
{address ? (
<div className="d-flex align-items-center contact-link-item gap-2">
<div className="icon-container">
<FaLocationDot />
</div>
<div className="d-flex flex-column">
<span className="font-12">Address</span>
<span className="font-14 fw-b">{address}</span>
</div>
</div>
) : null}
</div>
</Card.Body>
</Card>
</Col>
<Col xs={12} md={6}>
<Card className="h-100 card-theme">
<Card.Header>
<div className="card-title">Location</div>
</Card.Header>
<Card.Body>
{eventLocation ? <div className="font-14 mb-2">{eventLocation}</div> : null}
<div className="map-container ratio ratio-16x9 rounded-3 overflow-hidden">
<iframe
title="map"
width="100%"
height="450"
loading="lazy"
src={`https://maps.google.com/maps?q=${safeEventLocation}&z=15&output=embed`}
/>
</div>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
</section>
);
}

View File

@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react';
import type { MicrositeCountdownConfig, MicrositeSectionEnvelope } from '../../types';
import type { MicrositeSectionContext } from './sectionUtils';
import { str } from './sectionUtils';
export type CountdownSectionProps = {
section: MicrositeSectionEnvelope<'countdown', MicrositeCountdownConfig>;
ctx: MicrositeSectionContext;
};
const EVENT_TIMEZONE = 'Asia/Kolkata';
type TimeLeft = {
days: number;
hours: number;
minutes: number;
seconds: number;
expired: boolean;
};
function isNoonUtcDateMarker(date: Date): boolean {
return (
!Number.isNaN(date.getTime()) &&
date.getUTCHours() === 12 &&
date.getUTCMinutes() === 0 &&
date.getUTCSeconds() === 0 &&
date.getUTCMilliseconds() === 0
);
}
/** Match backend: noon-UTC date markers → 12:00 Asia/Kolkata on that calendar day. */
function resolveCountdownInstant(iso?: string | null): number {
if (!iso) return NaN;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return NaN;
if (isNoonUtcDateMarker(d)) {
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 6, 30, 0, 0);
}
return d.getTime();
}
function formatCountdownHeading(startMs: number, timeZone = EVENT_TIMEZONE): string {
if (!Number.isFinite(startMs)) return '';
return new Date(startMs).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone,
});
}
function getTimeLeft(startMs: number): TimeLeft {
const diff = startMs - Date.now();
if (!Number.isFinite(startMs) || diff <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
}
const totalSeconds = Math.floor(diff / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return { days, hours, minutes, seconds, expired: false };
}
function pad(n: number) {
return String(n).padStart(2, '0');
}
export function CountdownSection({ section, ctx }: CountdownSectionProps) {
const startMs = useMemo(() => {
const fromConfig = str(section.config.startsAt);
if (fromConfig) {
const ms = new Date(fromConfig).getTime();
if (Number.isFinite(ms)) return ms;
}
return resolveCountdownInstant(ctx.data.event.startDate);
}, [section.config.startsAt, ctx.data.event.startDate]);
const title = useMemo(() => {
const configured = str(section.config.headingLine);
if (Number.isFinite(startMs)) {
return `Event starts · ${formatCountdownHeading(startMs)}`;
}
return configured || 'Event starts soon';
}, [section.config.headingLine, startMs]);
const [timeLeft, setTimeLeft] = useState<TimeLeft>(() => getTimeLeft(startMs));
useEffect(() => {
if (!Number.isFinite(startMs)) {
setTimeLeft({ days: 0, hours: 0, minutes: 0, seconds: 0, expired: true });
return;
}
const tick = () => setTimeLeft(getTimeLeft(startMs));
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [startMs]);
if (!Number.isFinite(startMs)) return null;
const units = [
{ label: 'Days', value: timeLeft.days },
{ label: 'Hours', value: timeLeft.hours },
{ label: 'Minutes', value: timeLeft.minutes },
{ label: 'Seconds', value: timeLeft.seconds },
];
return (
<section id={section.id || 'countdown'} className="eb-countdown-section" aria-label="Countdown to event">
<div className="container">
{title ? <h2 className="eb-countdown-title">{title}</h2> : null}
{timeLeft.expired ? (
<p className="eb-countdown-expired mb-0" role="status" aria-live="polite">
We're live — welcome to {ctx.data.event.name || 'the event'}!
</p>
) : (
<div className="eb-countdown-units" role="timer" aria-live="polite">
{units.map((unit, i) => (
<div key={unit.label} className="d-flex align-items-center">
<div className="eb-countdown-unit">
<div className="eb-countdown-value">{pad(unit.value)}</div>
<div className="eb-countdown-label">{unit.label}</div>
</div>
{i < units.length - 1 ? <span className="eb-countdown-sep" aria-hidden="true">:</span> : null}
</div>
))}
</div>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,32 @@
import type { MicrositeFooterConfig, MicrositeSectionEnvelope } from '../../types';
import type { MicrositeSectionContext as Ctx } from './sectionUtils';
import { str } from './sectionUtils';
export type FooterSectionProps = {
section: MicrositeSectionEnvelope<'footer', MicrositeFooterConfig>;
ctx: Ctx;
};
export function FooterSection({ section, ctx }: FooterSectionProps) {
const copyrightText = str(
section.config.copyrightText,
`© ${new Date().getFullYear()} ${ctx.data.event.name}. All rights reserved.`,
);
return (
<footer id={section?.id || 'footer'} className="mt-3 footer-section">
<div className="container py-3">
<div className="d-flex flex-column gap-3 flex-md-row justify-content-md-between">
<div className="flex-grow-1">
<span className="font-13 opacity-75">{copyrightText}</span>
</div>
<div className="flex-shrink-0">
<span className="font-13 opacity-75">
Powered by <a href="https://eventbuzz.live/" target="_blank" className="fw-sb theme-link">EventBuzz</a>
</span>
</div>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,62 @@
import { Container, Nav, Navbar, Offcanvas } from 'react-bootstrap';
import type { MicrositeSectionEnvelope, MicrositeHeaderConfig } from '../../types';
import type { MicrositeSectionContext } from './sectionUtils';
import { str } from './sectionUtils';
export type HeaderSectionProps = {
section: MicrositeSectionEnvelope<'header', MicrositeHeaderConfig>;
ctx: MicrositeSectionContext;
};
export function HeaderSection({ section, ctx }: HeaderSectionProps) {
const c = section.config;
const logoText = str(c.logoText, ctx.data.event.name);
const logoUrl = str(c.logoUrl || ctx.data.website.logo || ctx.data.event.logoUrl || '', '');
const menuItems = (Array.isArray(c.menuItems) ? c.menuItems : []) as { label?: string; url?: string }[];
return (
<Navbar id={section?.id || 'header'} fixed="top" expand="lg" className="theme-navbar mb-3">
<Container fluid>
<Navbar.Brand href="#">
<div className="eb-header-logo-container">{logoUrl && <img src={logoUrl} alt={logoText} className="eb-header-logo" />}</div>
<span>{logoText}</span>
{/* <span>ERS India SUMMIT 2026</span> */}
</Navbar.Brand>
<Navbar.Toggle aria-controls={`offcanvasNavbar-expand-lg`} />
<Navbar.Offcanvas
id={`offcanvasNavbar-expand-lg`}
aria-labelledby={`offcanvasNavbarLabel-expand-lg`}
placement="end"
className="theme-navbar-offcanvas"
>
<Offcanvas.Header closeButton>
<Offcanvas.Title id={`offcanvasNavbarLabel-expand-lg`}>Menu</Offcanvas.Title>
</Offcanvas.Header>
<Offcanvas.Body>
<Nav className="justify-content-end flex-grow-1 pe-3">
{menuItems.map((item, i) => {
const label = str(item.label);
const url = str(item.url, '#');
const isReg = label.toLowerCase() === 'register';
if (isReg && !ctx.registrationUrl) return null;
return (
<Nav.Link
key={i}
href={isReg ? ctx.registrationUrl : url}
className={isReg ? 'btn btn-sm btn-primary btn-register ms-2' : ''}
>
{label}
</Nav.Link>
);
})}
</Nav>
</Offcanvas.Body>
</Navbar.Offcanvas>
</Container>
</Navbar>
);
}

View File

@ -0,0 +1,97 @@
import { useState } from 'react';
import { Card, Col, Modal, Row } from 'react-bootstrap';
import type { MicrositeSectionContext as Ctx } from './sectionUtils';
import { str } from './sectionUtils';
import type { MicrositeKeyPerson, MicrositeKeyPersonsConfig, MicrositeSectionEnvelope } from '../../types';
export type KeyPersonsSectionProps = {
section: MicrositeSectionEnvelope<'key_persons', MicrositeKeyPersonsConfig>;
ctx: Ctx;
};
export function KeyPersonsSection({ section }: KeyPersonsSectionProps) {
const title = str(section.config.title, 'Key Persons');
const limit = Number(section.config.limit) || 12;
const list = (
Array.isArray(section.config.keyPersons) ? (section.config.keyPersons as MicrositeKeyPerson[]) : []
).slice(0, limit);
const [selected, setSelected] = useState<MicrositeKeyPerson | null>(null);
return (
<>
{list.length > 0 && (
<section id={section?.id || 'key-persons'} className="eb-key-persons-section section py-5">
<div className="container">
<h2 className="eb-section-heading text-center mb-5">{title}</h2>
<Row className="g-4">
{list.map((s, i) => (
<Col key={i} className="col-12 col-sm-12 col-lg-6">
<Card
className="card-theme card-key-person h-100 shadow-sm"
role="button"
tabIndex={0}
onClick={() => setSelected(s)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelected(s);
}
}}
aria-label={`View details for ${s.name}`}
>
<Card.Body>
<div className="d-flex flex-column flex-sm-row gap-3">
<div className="flex-shrink-0 img-container">
{s.photoUrl ? <img src={s.photoUrl} className="card-img-to" alt={s.name} /> : null}
</div>
<div className="flex-grow-1 pt-2">
<h5 className="card-title">{s.name}</h5>
{s.designation ? <p className="card-subtitle text-highlight">{s.designation}</p> : null}
{s.bio ? <p className="card-text mt-2">{s.bio}</p> : null}
</div>
</div>
</Card.Body>
</Card>
</Col>
))}
</Row>
</div>
</section>
)}
<Modal
show={!!selected}
onHide={() => setSelected(null)}
centered
size="lg"
className="eb-speaker-modal"
contentClassName="eb-speaker-modal-content"
>
{selected ? (
<>
<Modal.Header closeButton>
<Modal.Title>{selected.name}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="eb-speaker-modal-body">
<div className="eb-speaker-modal-photo">
{selected.photoUrl ? (
<img src={selected.photoUrl} alt="" />
) : (
<div className="eb-speaker-modal-photo-placeholder" aria-hidden="true" />
)}
</div>
<div className="eb-speaker-modal-info">
{selected.designation ? (
<p className="eb-speaker-modal-designation text-highlight">{selected.designation}</p>
) : null}
{selected.bio ? <p className="eb-speaker-modal-bio mb-0">{selected.bio}</p> : null}
</div>
</div>
</Modal.Body>
</>
) : null}
</Modal>
</>
);
}

View File

@ -1,5 +1,5 @@
import { useEffect, type CSSProperties, type ReactNode } from 'react';
import { conferenceCssVars, loadConferenceFont, resolveThemeClass } from '../../theme/conferenceTheme';
import { useEffect, type ReactNode } from 'react';
import { conferenceCssVars, loadConferenceFont, resolveThemeClasses } from '../../theme/conferenceTheme';
import type { PublicMicrositePayload } from '../../types';
type Props = {
@ -7,6 +7,31 @@ type Props = {
children: ReactNode;
};
const THEME_CLASS_PREFIX = 'theme-';
const PATTERN_CLASS_PREFIX = 'pattern-';
function clearThemeClasses(el: HTMLElement) {
const toRemove: string[] = [];
el.classList.forEach((cls) => {
if (cls.startsWith(THEME_CLASS_PREFIX) || cls.startsWith(PATTERN_CLASS_PREFIX)) {
toRemove.push(cls);
}
});
toRemove.forEach((cls) => el.classList.remove(cls));
}
function applyCssVars(el: HTMLElement, vars: Record<string, string>) {
for (const [key, value] of Object.entries(vars)) {
el.style.setProperty(key, value);
}
}
function clearCssVars(el: HTMLElement, keys: string[]) {
for (const key of keys) {
el.style.removeProperty(key);
}
}
export function MicrositeLayout({ data, children }: Props) {
useEffect(() => {
loadConferenceFont(data.website);
@ -23,17 +48,27 @@ export function MicrositeLayout({ data, children }: Props) {
}
}, [data]);
const style = conferenceCssVars(data.website) as CSSProperties;
const themeClass = resolveThemeClass(data.website.theme);
useEffect(() => {
const html = document.documentElement;
const vars = conferenceCssVars(data.website);
const themeClasses = resolveThemeClasses(data.website.theme);
const varKeys = Object.keys(vars);
clearThemeClasses(html);
themeClasses.forEach((cls) => html.classList.add(cls));
applyCssVars(html, vars);
return () => {
clearThemeClasses(html);
clearCssVars(html, varKeys);
};
}, [data]);
return (
<div className={['eb-microsite-root', themeClass].filter(Boolean).join(' ')} style={style}>
<div className="eb-microsite-root">
<main className="eb-microsite-main">
{children}
<div className="eb-microsite-footer-spacer"></div>
<footer className="eb-static-footer">
Powered by <strong>EventBuzz</strong>
</footer>
</main>
</div>
);

View File

@ -0,0 +1,42 @@
import type { MicrositeRegisterLinksConfig, MicrositeSectionEnvelope } from '../../types';
import type { MicrositeSectionContext } from './sectionUtils';
import { str } from './sectionUtils';
export type RegisterLinksSectionProps = {
section: MicrositeSectionEnvelope<'register_links', MicrositeRegisterLinksConfig>;
ctx: MicrositeSectionContext;
};
export function RegisterLinksSection({ section, ctx }: RegisterLinksSectionProps) {
if (!ctx.registrationUrl) return null;
const c = section.config;
const title = str(c.title, 'Register');
const primaryLabel = str(c.primaryLabel, 'Register Now');
const primaryUrl = ctx.registrationUrl || str(c.primaryUrl);
const secondaryLabel = str(c.secondaryLabel);
const secondaryUrl = str(c.secondaryUrl, '#');
const text = str(c.text, 'Welcome! To join the event, please register below.');
return (
<section id={section?.id || 'register'} className="section register-section">
<div className="container text-center">
<h2 className="eb-section-heading mb-4">{title}</h2>
<p className="font-18 fw-l mb-3">{text}</p>
<a className="btn btn-primary btn-lg" href={primaryUrl}>
{primaryLabel}
</a>
{secondaryLabel ? (
<a
className="btn btn-outline-secondary btn-lg ms-2"
href={secondaryUrl}
style={{ marginLeft: '0.5rem' }}
>
{secondaryLabel}
</a>
) : null}
</div>
</section>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
import { useState } from 'react';
import { Card, Modal } from 'react-bootstrap';
import type { MicrositeSectionContext } from './sectionUtils';
import type { MicrositeSectionEnvelope, MicrositeSpeakersConfig, MicrositeSpeaker } from '../../types';
import { str } from './sectionUtils';
export type SpeakersSectionProps = {
section: MicrositeSectionEnvelope<'speakers', MicrositeSpeakersConfig>;
ctx: MicrositeSectionContext;
};
export function SpeakersSection({ section }: SpeakersSectionProps) {
const title = str(section.config.title, 'Speakers');
const limit = Number(section.config.limit) || 12;
const list = (Array.isArray(section.config.speakers) ? (section.config.speakers as MicrositeSpeaker[]) : []).slice(0, limit);
const [selected, setSelected] = useState<MicrositeSpeaker | null>(null);
return (
<>
{list.length > 0 && (
<section id={section?.id || 'speakers'} className="eb-speakers-section section py-5">
<div className="container">
<h2 className="eb-section-heading text-center mb-5">{title}</h2>
<div className="row">
{list.map((s, i) => (
<div key={i} className="col-12 col-sm-6 col-md-6 col-lg-3 mb-4">
<Card
className="card-theme card-speaker h-100 shadow-sm"
role="button"
tabIndex={0}
onClick={() => setSelected(s)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelected(s);
}
}}
aria-label={`View details for ${s.name}`}
>
<div className="img-container img-container-sm mx-auto">
{s.photoUrl ? (
<img src={s.photoUrl} className="card-img-to" alt={s.name} />
) : (
<div className="card-img-top" style={{ background: '#cbd5e1', minHeight: 120 }} />
)}
</div>
<div className="card-body text-center">
<h5 className="card-title">{s.name}</h5>
{s.designation ? <p className="card-subtitle text-highlight">{s.designation}</p> : null}
</div>
</Card>
</div>
))}
</div>
</div>
</section>
)}
<Modal
show={!!selected}
onHide={() => setSelected(null)}
centered
size="lg"
className="eb-speaker-modal"
contentClassName="eb-speaker-modal-content"
>
{selected ? (
<>
<Modal.Header closeButton>
<Modal.Title>{selected.name}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="eb-speaker-modal-body">
<div className="eb-speaker-modal-photo">
{selected.photoUrl ? (
<img src={selected.photoUrl} alt="" />
) : (
<div className="eb-speaker-modal-photo-placeholder" aria-hidden="true" />
)}
</div>
<div className="eb-speaker-modal-info">
{selected.designation ? (
<p className="eb-speaker-modal-designation text-highlight">{selected.designation}</p>
) : null}
{selected.bio ? <p className="eb-speaker-modal-bio mb-0">{selected.bio}</p> : null}
</div>
</div>
</Modal.Body>
</>
) : null}
</Modal>
</>
);
}

View File

@ -0,0 +1,43 @@
import type { MicrositeSectionContext } from './sectionUtils';
import { str } from './sectionUtils';
import type { MicrositeSectionEnvelope, MicrositeTicketsConfig } from '../../types';
export type TicketsSectionProps = {
section: MicrositeSectionEnvelope<'tickets', MicrositeTicketsConfig>;
ctx: MicrositeSectionContext;
};
export function TicketsSection({ section, ctx }: TicketsSectionProps) {
const c = section.config;
const title = str(c.title, 'Tickets');
const tiers = (Array.isArray(c.tiers) ? (c.tiers as { name?: string; price?: string; description?: string }[]) : []) as {
name?: string;
price?: string;
description?: string;
}[];
return (
<section id={section?.id || 'tickets'} className="py-5">
<div className="container">
<h2 className="eb-section-heading text-center mb-5">{title}</h2>
<div className="row justify-content-center">
{(tiers.length ? tiers : [{ name: 'General Admission', price: 'Free', description: 'Register to attend.' }]).map((tier, i) => (
<div key={i} className="col-md-4 mb-4">
<div className="card h-100 shadow-sm" style={{ padding: '1.25rem' }}>
<h3 style={{ marginTop: 0 }}>{str(tier.name)}</h3>
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--eb-conf-primary)' }}>{str(tier.price)}</p>
{tier.description ? <p className="text-muted">{str(tier.description)}</p> : null}
{ctx.registrationUrl ? (
<a className="btn btn-primary" href={ctx.registrationUrl}>
Register
</a>
) : null}
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,26 @@
import type { PublicMicrositePayload } from '../../types';
export type MicrositeSectionContext = {
data: PublicMicrositePayload;
eventId: string;
registrationUrl: string;
};
export function str(v: unknown, fallback = ''): string {
return v != null ? String(v).trim() : fallback;
}
export function isRegisterLabel(label: string): boolean {
return /\bregister\b/i.test(label);
}
export function looksLikeRegistrationUrl(url: string): boolean {
return /\/register\/?$/i.test(url) || /\/form\/[a-f0-9]{24}\/?$/i.test(url);
}
export function resolveRegistrationCtaUrl(label: string, configuredUrl: unknown, registrationUrl: string): string {
const configured = str(configuredUrl);
const wantsRegistration = isRegisterLabel(label) || looksLikeRegistrationUrl(configured);
if (wantsRegistration) return registrationUrl;
return configured || '#';
}

View File

@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/conference.css';
import './styles/forms.css';

View File

@ -4,7 +4,15 @@ import { micrositeApi } from '../api/microsite';
import { PUBLIC_WEB_BASE } from '../api/client';
import type { PublicMicrositePayload } from '../types';
import { MicrositeLayout } from '../components/microsite/MicrositeLayout';
import { SectionRenderer } from '../components/microsite/SectionRenderer';
import { HeaderSection } from '../components/microsite/HeaderSection';
import { BannerSection } from '../components/microsite/BannerSection';
import { CountdownSection } from '../components/microsite/CountdownSection';
import { AboutSection } from '../components/microsite/AboutSection';
import { AgendaSection } from '../components/microsite/AgendaSection';
import { SpeakersSection } from '../components/microsite/SpeakersSection';
import { KeyPersonsSection } from '../components/microsite/KeyPersonsSection';
import { ContactSection } from '../components/microsite/ContactSection';
import { FooterSection } from '../components/microsite/FooterSection';
export function EventMicrositePage() {
const { eventId = '' } = useParams();
@ -44,8 +52,8 @@ export function EventMicrositePage() {
}
const registrationUrl = data.registrationFormId
? `${PUBLIC_WEB_BASE}/event/${eventId}/form/${data.registrationFormId}`
: '';
? data.registrationUrl || `${PUBLIC_WEB_BASE}/event/${eventId}/form/${data.registrationFormId}`
: data.registrationUrl || '';
const ctx = { data, eventId, registrationUrl };
if (data.micrositeHtml) {
@ -56,15 +64,22 @@ export function EventMicrositePage() {
);
}
const sections = [...(data.sections || [])]
.filter((section) => section.type !== 'sponsors')
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return (
<MicrositeLayout data={data}>
{sections.map((section) => (
<SectionRenderer key={section.id || section.type} section={section} ctx={ctx} />
))}
{data.header ? <HeaderSection section={data.header} ctx={ctx} /> : null}
{data.banner ? <BannerSection section={data.banner} ctx={ctx} /> : null}
{data.countdown ? <CountdownSection section={data.countdown} ctx={ctx} /> : null}
{data.about ? <AboutSection section={data.about} ctx={ctx} /> : null}
{data.agenda ? <AgendaSection section={data.agenda} ctx={ctx} /> : null}
{data.speakers ? <SpeakersSection section={data.speakers} ctx={ctx} /> : null}
{data.key_persons ? <KeyPersonsSection section={data.key_persons} ctx={ctx} /> : null}
{/* register_links + tickets render inside AboutSection via ctx.data */}
{data.contact ? <ContactSection section={data.contact} ctx={ctx} /> : null}
{data.footer ? <FooterSection section={data.footer} ctx={ctx} /> : null}
</MicrositeLayout>
);
}

351
src/pages/LandingPage.tsx Normal file
View File

@ -0,0 +1,351 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import {
loadRazorpayScript,
publicEventTicketsApi,
type PublicTicketCatalogItem,
type TicketQuote,
} from '../api/publicEventTickets';
import { formatTicketPrice } from '../utils/registrationTicketAttendee';
import logoWhite from '../assets/logo-white.png';
import logoColor from '../assets/logo.png';
import heroBanner from '../assets/main_banner.png';
import '../styles/landing.css';
const DEMO_EVENT_ID =
(import.meta.env.VITE_DEMO_EVENT_ID as string | undefined)?.trim() || '6a620657ef2449311c51fd6b';
const DEMO_TICKET_ID =
(import.meta.env.VITE_DEMO_TICKET_ID as string | undefined)?.trim() || '6a6b37ba9ce2326fe3e591f6';
const PAYMENT_CANCELLED = 'Payment cancelled';
const CONSOLE_URL = 'https://console.eventbuzz.live';
const MARKETING_URL = 'https://eventbuzz.live';
type Buyer = {
firstName: string;
lastName: string;
email: string;
phone: string;
};
const emptyBuyer: Buyer = { firstName: '', lastName: '', email: '', phone: '' };
export function LandingPage() {
const [ticket, setTicket] = useState<PublicTicketCatalogItem | null>(null);
const [eventName, setEventName] = useState('EventBuzz');
const [quote, setQuote] = useState<TicketQuote | null>(null);
const [gatewayId, setGatewayId] = useState<string | undefined>();
const [onlineAvailable, setOnlineAvailable] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [payError, setPayError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [paid, setPaid] = useState(false);
const [buyer, setBuyer] = useState<Buyer>(emptyBuyer);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [summary, tickets, options] = await Promise.all([
publicEventTicketsApi.getEventSummary(DEMO_EVENT_ID),
publicEventTicketsApi.listTickets(DEMO_EVENT_ID),
publicEventTicketsApi.checkoutOptions(DEMO_EVENT_ID),
]);
if (cancelled) return;
setEventName(summary.name || 'EventBuzz');
const selected =
tickets.find((t) => t._id === DEMO_TICKET_ID) || tickets[0] || null;
setTicket(selected);
setOnlineAvailable(!!options.onlineAvailable);
const primary =
options.onlineGateways?.find((g) => g.isPrimary && g.ticketCheckoutSupported) ||
options.onlineGateways?.find((g) => g.ticketCheckoutSupported);
setGatewayId(primary?.id);
setLoadError(null);
} catch (e) {
if (!cancelled) {
setLoadError(e instanceof Error ? e.message : 'Could not load the test product.');
}
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!ticket) {
setQuote(null);
return;
}
let cancelled = false;
void publicEventTicketsApi
.quote(DEMO_EVENT_ID, { ticketId: ticket._id, quantity: 1 })
.then((q) => {
if (!cancelled) setQuote(q);
})
.catch((e) => {
if (!cancelled) setLoadError(e instanceof Error ? e.message : 'Could not price ticket.');
});
return () => {
cancelled = true;
};
}, [ticket]);
const displayPrice = useMemo(() => {
if (quote) return formatTicketPrice(quote.totalMinor, quote.currency);
if (ticket) return formatTicketPrice(ticket.basePriceMinor, ticket.currency || 'INR');
return '—';
}, [quote, ticket]);
const productTitle = ticket?.name?.trim() === 'test' ? 'EventBuzz Test Pass' : ticket?.name || 'Test Pass';
async function onPay(e: FormEvent) {
e.preventDefault();
if (!ticket || !quote) return;
const firstName = buyer.firstName.trim();
const lastName = buyer.lastName.trim();
const email = buyer.email.trim().toLowerCase();
if (!firstName || !lastName || !email) {
setPayError('Please enter your name and email.');
return;
}
if (!onlineAvailable) {
setPayError('Online checkout is not available for this product right now.');
return;
}
setBusy(true);
setPayError(null);
try {
const order = await publicEventTicketsApi.checkout(DEMO_EVENT_ID, {
ticketId: ticket._id,
quantity: 1,
purchaserEmail: email,
expectedTotalMinor: quote.totalMinor,
idempotencyKey: `landing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
orgGatewayId: gatewayId,
attendees: [
{
firstName,
lastName,
email,
phone: buyer.phone.trim() || undefined,
},
],
});
await loadRazorpayScript();
type RazorpayResponse = {
razorpay_order_id: string;
razorpay_payment_id: string;
razorpay_signature: string;
};
type RazorpayConstructor = new (opts: {
key: string;
amount: number;
currency: string;
order_id: string;
name: string;
description: string;
handler: (response: RazorpayResponse) => void;
modal?: { ondismiss?: () => void };
theme?: { color?: string };
prefill?: { email?: string; name?: string; contact?: string };
}) => {
open: () => void;
on: (ev: string, fn: (r: { error: { description?: string } }) => void) => void;
};
const Rzp = (window as unknown as { Razorpay: RazorpayConstructor }).Razorpay;
await new Promise<void>((resolve, reject) => {
let invoked = false;
const rzp = new Rzp({
key: order.keyId,
amount: order.amount,
currency: order.currency,
order_id: order.orderId,
name: 'EventBuzz',
description: productTitle,
handler: (response: RazorpayResponse) => {
invoked = true;
void publicEventTicketsApi
.verifyPayment(DEMO_EVENT_ID, {
orderId: response.razorpay_order_id,
paymentId: response.razorpay_payment_id,
signature: response.razorpay_signature,
})
.then(() => resolve())
.catch((err) => reject(err instanceof Error ? err : new Error('Verification failed')));
},
modal: {
ondismiss: () => {
if (!invoked) reject(new Error(PAYMENT_CANCELLED));
},
},
theme: { color: '#1d66ef' },
prefill: {
email,
name: `${firstName} ${lastName}`.trim(),
contact: buyer.phone.trim() || undefined,
},
});
rzp.on('payment.failed', (response: { error: { description?: string } }) => {
reject(new Error(response.error?.description || 'Payment failed'));
});
rzp.open();
});
setPaid(true);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Checkout failed';
if (msg !== PAYMENT_CANCELLED) setPayError(msg);
} finally {
setBusy(false);
}
}
return (
<div className="eb-landing">
<header className="eb-landing-nav">
<img src={logoWhite} alt="EventBuzz" className="eb-landing-nav-logo" />
<a className="eb-landing-nav-cta" href={CONSOLE_URL}>
Open console
</a>
</header>
<section className="eb-landing-hero">
<img src={heroBanner} alt="" className="eb-landing-hero-media" aria-hidden />
<div className="eb-landing-hero-shade" aria-hidden />
<div className="eb-landing-hero-inner">
<img src={logoWhite} alt="EventBuzz" className="eb-landing-brand" />
<h1>Run live events with confidence</h1>
<p>
Registration, ticketing, agendas, and real-time engagement — built for organizers who
need one place to host every moment.
</p>
<div className="eb-landing-hero-actions">
<a className="eb-landing-btn eb-landing-btn--primary" href="#buy">
Try a ₹1 test purchase
</a>
<a className="eb-landing-btn eb-landing-btn--ghost" href={MARKETING_URL}>
Learn more
</a>
</div>
</div>
</section>
<section className="eb-landing-shop" id="buy" aria-labelledby="buy-heading">
<div className="eb-landing-shop-inner">
<div className="eb-landing-shop-copy">
<h2 id="buy-heading">Test product</h2>
<p>
Complete a real checkout against EventBuzz ticketing (Razorpay test mode). Use this
to verify payments end-to-end before going live with your own event.
</p>
{ticket ? (
<div className="eb-landing-product">
<div className="eb-landing-product-name">{productTitle}</div>
<div className="eb-landing-product-price">{displayPrice}</div>
<div className="eb-landing-product-meta">
Linked to {eventName}
{ticket.description?.trim() ? ` · ${ticket.description.trim()}` : null}
</div>
</div>
) : loadError ? (
<p className="eb-landing-error">{loadError}</p>
) : (
<p className="eb-landing-product-meta">Loading product…</p>
)}
</div>
<div className="eb-landing-checkout">
{paid ? (
<div className="eb-landing-success">
<img src={logoColor} alt="" width={160} height={30} style={{ margin: '0 auto 8px' }} />
<h3>Payment successful</h3>
<p>Your test purchase went through. You’re all set to use EventBuzz ticketing.</p>
<a className="eb-landing-btn eb-landing-btn--solid" href={CONSOLE_URL}>
Go to console
</a>
</div>
) : (
<form onSubmit={(e) => void onPay(e)}>
<h3>Buy {productTitle}</h3>
<p className="eb-landing-checkout-lead">
Enter buyer details, then pay with Razorpay. Amount: {displayPrice}.
</p>
{loadError ? <p className="eb-landing-error">{loadError}</p> : null}
<div className="eb-landing-fields">
<div className="eb-landing-row">
<label className="eb-landing-field">
<span>First name</span>
<input
required
autoComplete="given-name"
value={buyer.firstName}
onChange={(e) => setBuyer((b) => ({ ...b, firstName: e.target.value }))}
/>
</label>
<label className="eb-landing-field">
<span>Last name</span>
<input
required
autoComplete="family-name"
value={buyer.lastName}
onChange={(e) => setBuyer((b) => ({ ...b, lastName: e.target.value }))}
/>
</label>
</div>
<label className="eb-landing-field">
<span>Email</span>
<input
required
type="email"
autoComplete="email"
value={buyer.email}
onChange={(e) => setBuyer((b) => ({ ...b, email: e.target.value }))}
/>
</label>
<label className="eb-landing-field">
<span>Phone (optional)</span>
<input
type="tel"
autoComplete="tel"
value={buyer.phone}
onChange={(e) => setBuyer((b) => ({ ...b, phone: e.target.value }))}
/>
</label>
</div>
{quote ? (
<p className="eb-landing-total">
Total: <strong>{formatTicketPrice(quote.totalMinor, quote.currency)}</strong>
</p>
) : null}
{payError ? <p className="eb-landing-error">{payError}</p> : null}
<button
type="submit"
className="eb-landing-btn eb-landing-btn--solid"
disabled={busy || !ticket || !quote || !onlineAvailable}
>
{busy ? 'Opening checkout…' : `Pay ${displayPrice}`}
</button>
</form>
)}
</div>
</div>
</section>
<footer className="eb-landing-footer">
<div className="eb-landing-footer-inner">
<span>© {new Date().getFullYear()} EventBuzz</span>
<span>
<a href={MARKETING_URL}>eventbuzz.live</a>
{' · '}
<a href={CONSOLE_URL}>console</a>
{' · '}
<a href="mailto:hello@eventbuzz.live">hello@eventbuzz.live</a>
</span>
</div>
</footer>
</div>
);
}

View File

@ -5,7 +5,10 @@ export function NotFoundPage() {
<div className="form-page-center">
<div style={{ textAlign: 'center' }}>
<h1 style={{ margin: '0 0 8px' }}>Page not found</h1>
<p style={{ color: '#64748b' }}>The page you are looking for does not exist.</p>
<p style={{ color: '#64748b', margin: '0 0 16px' }}>The page you are looking for does not exist.</p>
<Link to="/" style={{ color: '#1d66ef', fontWeight: 600 }}>
Back to EventBuzz
</Link>
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@ -348,23 +348,25 @@
}
.form-event-banner {
display: flex;
justify-content: center;
margin-bottom: 16px;
width: 100%;
margin: 0 0 16px;
border-radius: 10px;
overflow: hidden;
max-height: 120px;
max-height: 180px;
line-height: 0;
}
.form-event-banner img {
width: 100%;
height: auto;
max-height: 120px;
max-height: 180px;
object-fit: cover;
display: block;
}
.form-event-logo {
display: block;
max-height: 120px;
max-height: 96px;
width: auto;
height: auto;
max-width: 100%;
@ -480,3 +482,134 @@
opacity: 0.6;
cursor: not-allowed;
}
.form-ticket-step-label {
margin: 0 0 12px;
font-size: 0.8125rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-ticket-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.form-ticket-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
text-align: left;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #fff;
cursor: pointer;
color: inherit;
font: inherit;
}
.form-ticket-card--active {
border-color: #0f172a;
box-shadow: 0 0 0 1px #0f172a;
}
.form-ticket-card-name {
font-weight: 600;
font-size: 0.95rem;
}
.form-ticket-card-price {
font-size: 0.875rem;
color: #334155;
}
.form-ticket-card-desc {
font-size: 0.8125rem;
color: #64748b;
}
.form-ticket-channels {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.form-ticket-channel {
flex: 1;
min-width: 120px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #f8fafc;
cursor: pointer;
font-size: 0.8125rem;
}
.form-ticket-channel--active {
border-color: #0f172a;
background: #0f172a;
color: #fff;
}
.form-ticket-manual {
margin-bottom: 12px;
}
.form-ticket-qr {
display: block;
max-width: 180px;
margin: 0 auto 10px;
border-radius: 8px;
}
.form-ticket-qr-wrap {
display: flex;
justify-content: center;
margin: 0 0 12px;
padding: 12px;
background: #fff;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.form-ticket-upi-id {
margin: 0 0 8px;
font-size: 0.875rem;
font-weight: 600;
text-align: center;
}
.form-ticket-total {
margin: 0 0 12px;
font-size: 0.95rem;
}
.form-ticket-tax {
color: #64748b;
font-weight: 400;
}
.form-ticket-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.form-submit--secondary {
background: #fff;
color: #0f172a;
border: 1px solid #cbd5e1;
}
.form-field {
display: block;
margin-bottom: 12px;
}

397
src/styles/landing.css Normal file
View File

@ -0,0 +1,397 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700&family=Source+Sans+3:wght@400;500;600;700&display=swap');
.eb-landing {
--eb-blue: #1d66ef;
--eb-navy: #0f3072;
--eb-ink: #0c1222;
--eb-muted: #5b657a;
--eb-surface: #ffffff;
--eb-line: rgba(15, 48, 114, 0.12);
--eb-font-display: 'Outfit', sans-serif;
--eb-font-body: 'Source Sans 3', sans-serif;
min-height: 100vh;
color: var(--eb-ink);
font-family: var(--eb-font-body);
background: #eef2f8;
}
.eb-landing *,
.eb-landing *::before,
.eb-landing *::after {
box-sizing: border-box;
}
.eb-landing a {
color: inherit;
text-decoration: none;
}
.eb-landing-nav {
position: absolute;
inset: 0 0 auto;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px clamp(20px, 5vw, 56px);
}
.eb-landing-nav-logo {
display: block;
height: 36px;
width: auto;
}
.eb-landing-nav-cta {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 18px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-family: var(--eb-font-display);
font-size: 0.95rem;
font-weight: 600;
backdrop-filter: blur(8px);
transition: background 0.2s ease, transform 0.2s ease;
}
.eb-landing-nav-cta:hover {
background: rgba(255, 255, 255, 0.22);
transform: translateY(-1px);
}
.eb-landing-hero {
position: relative;
min-height: min(92vh, 820px);
display: flex;
align-items: flex-end;
overflow: hidden;
color: #fff;
background: #0f3072;
}
.eb-landing-hero-media {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.48;
transform: scale(1.03);
animation: eb-landing- ken 18s ease-in-out infinite alternate;
}
.eb-landing-hero-shade {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(12, 18, 34, 0.35) 0%, rgba(12, 18, 34, 0.72) 55%, rgba(12, 18, 34, 0.92) 100%),
linear-gradient(115deg, rgba(29, 102, 239, 0.45), transparent 55%);
}
.eb-landing-hero-inner {
position: relative;
z-index: 1;
width: min(720px, 100%);
padding: clamp(120px, 18vh, 180px) clamp(20px, 5vw, 56px) clamp(48px, 8vh, 72px);
}
.eb-landing-brand {
display: block;
height: clamp(44px, 7vw, 64px);
width: auto;
margin-bottom: 28px;
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.35));
animation: eb-landing-rise 0.7s ease both;
}
.eb-landing-hero h1 {
margin: 0 0 14px;
font-family: var(--eb-font-display);
font-size: clamp(2.4rem, 6vw, 4.2rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.03em;
animation: eb-landing-rise 0.8s ease 0.08s both;
}
.eb-landing-hero p {
margin: 0 0 28px;
max-width: 34rem;
font-size: clamp(1.05rem, 2.2vw, 1.25rem);
line-height: 1.55;
color: rgba(255, 255, 255, 0.88);
animation: eb-landing-rise 0.8s ease 0.16s both;
}
.eb-landing-hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
animation: eb-landing-rise 0.8s ease 0.24s both;
}
.eb-landing-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0 22px;
border: 0;
border-radius: 999px;
font-family: var(--eb-font-display);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.eb-landing-btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.eb-landing-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.eb-landing-btn--primary {
background: #fff;
color: var(--eb-blue);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.22);
}
.eb-landing-btn--primary:hover:not(:disabled) {
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.28);
}
.eb-landing-btn--ghost {
background: transparent;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.45);
}
.eb-landing-btn--solid {
width: 100%;
background: var(--eb-blue);
color: #fff;
}
.eb-landing-btn--solid:hover:not(:disabled) {
background: #1557d4;
}
.eb-landing-shop {
padding: clamp(48px, 8vw, 88px) clamp(20px, 5vw, 56px);
}
.eb-landing-shop-inner {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: clamp(28px, 5vw, 56px);
align-items: start;
max-width: 1120px;
margin: 0 auto;
}
.eb-landing-shop-copy h2 {
margin: 0 0 12px;
font-family: var(--eb-font-display);
font-size: clamp(1.8rem, 3.5vw, 2.6rem);
letter-spacing: -0.02em;
color: var(--eb-navy);
}
.eb-landing-shop-copy p {
margin: 0 0 18px;
color: var(--eb-muted);
font-size: 1.05rem;
line-height: 1.6;
max-width: 36rem;
}
.eb-landing-product {
display: grid;
gap: 10px;
padding: 22px 0 0;
}
.eb-landing-product-name {
font-family: var(--eb-font-display);
font-size: 1.35rem;
font-weight: 600;
color: var(--eb-ink);
}
.eb-landing-product-price {
font-family: var(--eb-font-display);
font-size: 2rem;
font-weight: 700;
color: var(--eb-blue);
}
.eb-landing-product-meta {
color: var(--eb-muted);
font-size: 0.95rem;
line-height: 1.5;
}
.eb-landing-checkout {
background: var(--eb-surface);
border: 1px solid var(--eb-line);
border-radius: 20px;
padding: clamp(20px, 3vw, 28px);
box-shadow: 0 18px 40px rgba(15, 48, 114, 0.08);
scroll-margin-top: 24px;
}
.eb-landing-checkout h3 {
margin: 0 0 6px;
font-family: var(--eb-font-display);
font-size: 1.25rem;
color: var(--eb-navy);
}
.eb-landing-checkout-lead {
margin: 0 0 18px;
color: var(--eb-muted);
font-size: 0.95rem;
}
.eb-landing-fields {
display: grid;
gap: 12px;
}
.eb-landing-field {
display: grid;
gap: 6px;
}
.eb-landing-field span {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--eb-muted);
}
.eb-landing-field input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid #d7deea;
border-radius: 10px;
background: #fff;
color: var(--eb-ink);
font: inherit;
}
.eb-landing-field input:focus {
outline: 2px solid rgba(29, 102, 239, 0.28);
border-color: var(--eb-blue);
}
.eb-landing-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.eb-landing-total {
margin: 16px 0 14px;
font-size: 1rem;
color: var(--eb-ink);
}
.eb-landing-total strong {
font-family: var(--eb-font-display);
}
.eb-landing-error {
margin: 0 0 12px;
color: #b42318;
font-size: 0.92rem;
}
.eb-landing-success {
display: grid;
gap: 10px;
text-align: center;
padding: 18px 8px;
}
.eb-landing-success h3 {
margin: 0;
}
.eb-landing-success p {
margin: 0;
color: var(--eb-muted);
}
.eb-landing-footer {
padding: 28px clamp(20px, 5vw, 56px) 40px;
border-top: 1px solid var(--eb-line);
color: var(--eb-muted);
font-size: 0.92rem;
}
.eb-landing-footer-inner {
max-width: 1120px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
justify-content: space-between;
align-items: center;
}
.eb-landing-footer a {
color: var(--eb-blue);
font-weight: 600;
}
@keyframes eb-landing-rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes eb-landing-ken {
from {
transform: scale(1.03) translate3d(0, 0, 0);
}
to {
transform: scale(1.08) translate3d(-1.5%, -1%, 0);
}
}
@media (max-width: 860px) {
.eb-landing-shop-inner {
grid-template-columns: 1fr;
}
.eb-landing-nav-logo {
display: none;
}
}
@media (max-width: 560px) {
.eb-landing-row {
grid-template-columns: 1fr;
}
.eb-landing-hero {
min-height: 78vh;
}
}

View File

@ -1,36 +1,85 @@
import type { MicrositeThemeConfig } from '../types';
import { getThemePatternSpec, resolveThemePattern } from './themePatterns';
const DEFAULT_PRIMARY = '#5c6ac4';
const DEFAULT_ACCENT = '#00c2d1';
const DEFAULT_FONT_PAIR = 'Montserrat + Roboto';
const DEFAULT_THEME_FONT = 'Open Sans';
/** Colors that have matching `.theme-simple-*` rules in conference.css */
const SIMPLE_COLORS = new Set(['green', 'purple', 'gray', 'yellow']);
/** Colors that have matching `html.theme-simple-*` / `html.theme-pattern-*` palette rules */
const SIMPLE_COLORS = new Set(['green', 'purple', 'gray', 'yellow', 'blue', 'red', 'orange']);
/** Colors that have matching `.theme-gradient-*` rules in conference.css */
/** Default --theme-primary-color for simple/pattern presets when primaryColor is empty. */
const SIMPLE_PRIMARY_COLORS: Record<string, string> = {
blue: '#112c59',
red: '#4c1429',
green: '#073428',
purple: '#2a235d',
gray: '#2a2e33',
yellow: '#4f4109',
orange: '#683606',
};
/** Default --theme-button-color for simple/pattern presets when buttonColor is empty. */
const SIMPLE_BUTTON_COLORS: Record<string, string> = {
blue: '#50a2ed',
green: '#46b295',
purple: '#6954c9',
gray: '#5f656d',
yellow: '#ac9943',
red: '#8f3958',
orange: '#c7782e',
};
/** Default --theme-text-highlight-color for simple/pattern presets when textHighlightColor is empty. */
const SIMPLE_TEXT_HIGHLIGHT_COLORS: Record<string, string> = {
blue: '#6ba8e3',
green: '#7dc9b2',
purple: '#9f94ec',
gray: '#c2cad3',
yellow: '#d4c169',
red: '#e27fa3',
orange: '#e5b587',
};
/** Colors that have matching `html.theme-gradient-*` rules in conference.css */
const GRADIENT_COLORS = new Set(['green', 'warm', 'warm2']);
type FontFaceSpec = { href: string; heading: string; body: string };
function usesSimplePalette(mode: string): boolean {
return mode === 'simple' || mode === 'pattern';
}
/**
* Maps API theme config → a single class on `.eb-microsite-root`.
* Unknown mode/color returns '' so the default root styles apply.
* Maps API theme config → class names on `<html>`.
* - simple: theme-simple-{color}
* - gradient: theme-gradient-{color}
* - pattern: theme-pattern-{color} pattern-{name}
*/
export function resolveThemeClass(config?: MicrositeThemeConfig | null): string {
if (!config?.theme || !config?.color) return '';
export function resolveThemeClasses(config?: MicrositeThemeConfig | null): string[] {
if (!config?.theme || !config?.color) return [];
const mode = String(config.theme).trim().toLowerCase();
const color = String(config.color).trim().toLowerCase();
if (!mode || !color) return '';
if (!mode || !color) return [];
if (mode === 'simple' && SIMPLE_COLORS.has(color)) {
return `theme-simple-${color}`;
return [`theme-simple-${color}`];
}
if (mode === 'gradient' && GRADIENT_COLORS.has(color)) {
return `theme-gradient-${color}`;
return [`theme-gradient-${color}`];
}
return '';
if (mode === 'pattern' && SIMPLE_COLORS.has(color)) {
const classes = [`theme-pattern-${color}`];
const pattern = resolveThemePattern(config.pattern) || 'dots';
classes.push(`pattern-${pattern}`);
return classes;
}
return [];
}
/** @deprecated Prefer resolveThemeClasses — kept for single-class call sites. */
export function resolveThemeClass(config?: MicrositeThemeConfig | null): string {
return resolveThemeClasses(config).join(' ');
}
/** Single-family fonts for theme.font (heading + body share the same family). */
@ -117,6 +166,63 @@ function normalizeHex(input?: string | null): string | null {
return s;
}
/** Accepts hex / rgb() / rgba() / named colors as CSS color strings. */
function normalizeCssColor(input?: string | null): string | null {
if (input == null) return null;
const s = String(input).trim();
if (!s) return null;
const hex = normalizeHex(s);
if (hex) return hex;
// rgb/rgba/hsl/hsla or other CSS color values from the API
if (/^(rgb|rgba|hsl|hsla)\(/i.test(s) || /^[a-z]+$/i.test(s)) return s;
return s;
}
function themePaletteVars(theme?: MicrositeThemeConfig | null): Record<string, string> {
if (!theme) return {};
const vars: Record<string, string> = {};
const mode = String(theme.theme || '').trim().toLowerCase();
const colorKey = String(theme.color || '').trim().toLowerCase();
const primary = normalizeCssColor(theme.primaryColor);
const secondary = normalizeCssColor(theme.secondaryColor);
const header = normalizeCssColor(theme.headerColor);
const button = normalizeCssColor(theme.buttonColor);
const text = normalizeCssColor(theme.textColor);
const textHighlight = normalizeCssColor(theme.textHighlightColor);
const paletteFallback = usesSimplePalette(mode);
const simplePrimaryFallback =
paletteFallback && colorKey ? SIMPLE_PRIMARY_COLORS[colorKey] : undefined;
const simpleButtonFallback =
paletteFallback && colorKey ? SIMPLE_BUTTON_COLORS[colorKey] : undefined;
const simpleTextHighlightFallback =
paletteFallback && colorKey ? SIMPLE_TEXT_HIGHLIGHT_COLORS[colorKey] : undefined;
vars['--theme-primary-color'] = primary || simplePrimaryFallback || SIMPLE_PRIMARY_COLORS.blue;
if (secondary) {
vars['--theme-secondary-color'] = secondary;
vars['--theme-secondary-color-light'] = lightenCssColor(secondary, 0.2);
} else {
vars['--theme-secondary-color'] = 'rgba(255,255,255, 0.08)';
vars['--theme-secondary-color-light'] = 'rgba(255, 255, 255, 0.1)';
}
vars['--theme-header-color'] = header || 'rgba(255, 255, 255, 0.075)';
vars['--theme-button-color'] = button || simpleButtonFallback || SIMPLE_BUTTON_COLORS.blue;
vars['--theme-text-color'] = text || 'rgba(255,255,255,0.9)';
vars['--theme-text-highlight-color'] =
textHighlight || simpleTextHighlightFallback || SIMPLE_TEXT_HIGHLIGHT_COLORS.blue;
// Pattern tile overlay (solid primary underneath via background-color)
if (mode === 'pattern') {
const spec = getThemePatternSpec(theme.pattern) || getThemePatternSpec('dots');
if (spec) {
vars['--theme-bg-pattern'] = spec.image;
vars['--theme-bg-pattern-size'] = spec.size;
}
}
return vars;
}
function hexToRgb(hex: string) {
const n = parseInt(hex.slice(1), 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
@ -127,33 +233,33 @@ function rgbToHex(r: number, g: number, b: number) {
return `#${c(r)}${c(g)}${c(b)}`;
}
function darken(hex: string, amount: number) {
const rgb = hexToRgb(hex);
const f = 1 - amount;
return rgbToHex(rgb.r * f, rgb.g * f, rgb.b * f);
}
function lighten(hex: string, amount: number) {
const rgb = hexToRgb(hex);
return rgbToHex(rgb.r + (255 - rgb.r) * amount, rgb.g + (255 - rgb.g) * amount, rgb.b + (255 - rgb.b) * amount);
}
function relativeLuminance(hex: string) {
function parseCssRgb(input: string): { r: number; g: number; b: number; a: number } | null {
const hex = normalizeHex(input);
if (hex) {
const { r, g, b } = hexToRgb(hex);
const lin = (c: number) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
return { r, g, b, a: 1 };
}
const m = input.match(
/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i,
);
if (!m) return null;
return {
r: Number(m[1]),
g: Number(m[2]),
b: Number(m[3]),
a: m[4] != null ? Number(m[4]) : 1,
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function accentForeground(accentHex: string) {
return relativeLuminance(accentHex) > 0.45 ? '#0d2137' : '#ffffff';
}
function hexToRgba(hex: string, alpha: number) {
const { r, g, b } = hexToRgb(hex);
return `rgba(${r},${g},${b},${alpha})`;
/** Lightens hex/rgb/rgba toward white. Falls back to the original string if unparseable. */
function lightenCssColor(input: string, amount: number): string {
const parsed = parseCssRgb(input);
if (!parsed) return input;
const r = Math.round(parsed.r + (255 - parsed.r) * amount);
const g = Math.round(parsed.g + (255 - parsed.g) * amount);
const b = Math.round(parsed.b + (255 - parsed.b) * amount);
if (parsed.a < 1) return `rgba(${r}, ${g}, ${b}, ${parsed.a})`;
return rgbToHex(r, g, b);
}
export function fontSpec(fontPair?: string) {
@ -181,34 +287,15 @@ export function resolveWebsiteFont(website: {
}
export function conferenceCssVars(website: {
primaryColor?: string;
accentColor?: string;
fontPair?: string;
theme?: MicrositeThemeConfig | null;
}) {
const primary = normalizeHex(website.primaryColor) || DEFAULT_PRIMARY;
const accent = normalizeHex(website.accentColor) || DEFAULT_ACCENT;
const primaryDark = darken(primary, 0.22);
const accentHover = lighten(accent, 0.12);
const gradStop1 = darken(primary, 0.35);
const gradStop2 = primaryDark;
const navBg = darken(primary, 0.42);
const footerBg = darken(primary, 0.48);
const spec = resolveWebsiteFont(website);
return {
'--eb-conf-primary': primary,
'--eb-conf-primary-dark': primaryDark,
'--eb-conf-accent': accent,
'--eb-conf-accent-hover': accentHover,
'--eb-conf-accent-foreground': accentForeground(accent),
'--eb-conf-hero-gradient': `linear-gradient(135deg, ${gradStop1} 0%, ${gradStop2} 45%, ${primary} 100%)`,
'--eb-conf-heading-font': spec.heading,
'--eb-conf-body-font': spec.body,
'--eb-conf-nav-bg': navBg,
'--eb-conf-footer-bg': footerBg,
'--eb-static-footer-bg': darken(primary, 0.52),
'--eb-conf-card-glow': hexToRgba(primary, 0.28),
'--theme-heading-font': spec.heading,
'--theme-body-font': spec.body,
...themePaletteVars(website.theme),
} as Record<string, string>;
}

105
src/theme/themePatterns.ts Normal file
View File

@ -0,0 +1,105 @@
/**
* SVG background patterns for theme.mode === 'pattern'.
*
* How to add a pattern:
* 1. Add a key here (e.g. 'hexagon')
* 2. Provide a data-URI (or /public path) SVG that is mostly transparent
* 3. Use light fills/strokes (white @ ~10–18% opacity) so they work on dark primary colors
* 4. Set tile size via `size` (CSS background-size)
*
* No per-pattern CSS rules are required — JS sets:
* --theme-bg-pattern
* --theme-bg-pattern-size
*/
export type ThemePatternName = 'dots' | 'grid' | 'zigzag' | 'diagonal' | 'hexa' | 'plus';
export type ThemePatternSpec = {
/** CSS background-image value (usually a data-URI SVG) */
image: string;
/** CSS background-size, e.g. '20px 20px' */
size: string;
};
/** Encode a raw SVG string as a CSS data-URI background-image. */
export function svgToDataUri(svg: string): string {
const encoded = encodeURIComponent(svg.replace(/\s+/g, ' ').trim())
.replace(/'/g, '%27')
.replace(/"/g, '%22');
return `url("data:image/svg+xml,${encoded}")`;
}
const DOTS_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<circle cx="3" cy="3" r="1.75" fill="rgba(255,255,255,0.04)"/>
</svg>`;
// const GRID_SVG = `
// <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
// <path d="M24 0H0V24" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>
// </svg>`;
const GRID_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="55.425" viewBox="0 0 32 55.425">
<path d="M16 0L32 9.238v18.475L16 36.95 0 27.713V9.238zM16 36.95l16 9.238v9.237M16 36.95L0 46.188v9.237M16 0v18.475M32 9.238L16 18.475 0 9.238" fill="none" stroke="rgba(255, 255, 255, 0.08)" stroke-width="1"/>
</svg>`;
const ZIGZAG_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20">
<path d="M0 14 L10 6 L20 14 L30 6 L40 14" fill="none" stroke="rgba(255,255,255,0.04)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
const DIAGONAL_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M-2 18 L18 -2" fill="none" stroke="rgba(255,255,255,0.03)" stroke-width="1"/>
</svg>`;
// const DIAGONAL_SVG = `
// <svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60">
// <g fill="none" stroke="rgba(255, 255, 255, 0.05)" stroke-width="1" transform="translate(10,10)">
// <circle cx="20" cy="20" r="18" stroke-dasharray="2 2"/>
// <rect x="6" y="6" width="28" height="28" rx="2"/>
// <rect x="6" y="6" width="28" height="28" rx="2" transform="rotate(45 20 20)"/>
// <circle cx="20" cy="20" r="6" fill="rgba(255, 255, 255, 0.03)"/>
// <circle cx="20" cy="20" r="1.5" fill="rgba(255, 255, 255, 0.08)"/>
// </g>
// </svg>
// `;
const HEXA_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M-2 18 L18 -2" fill="none" stroke="rgba(255,255,255,0.02)" stroke-width="10"/>
</svg>`;
const PLUS_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M12 6v12M6 12h12" fill="none" stroke="rgba(255,255,255,0.04)" stroke-width="1.25" stroke-linecap="round"/>
</svg>`;
/** Registry of available background patterns. */
export const THEME_PATTERNS: Record<ThemePatternName, ThemePatternSpec> = {
dots: { image: svgToDataUri(DOTS_SVG), size: '20px 20px' },
grid: { image: svgToDataUri(GRID_SVG), size: '24px 24px' },
zigzag: { image: svgToDataUri(ZIGZAG_SVG), size: '40px 20px' },
diagonal: { image: svgToDataUri(DIAGONAL_SVG), size: '16px 16px' },
hexa: { image: svgToDataUri(HEXA_SVG), size: '16px 16px' },
plus: { image: svgToDataUri(PLUS_SVG), size: '24px 24px' },
};
const PATTERN_ALIASES: Record<string, ThemePatternName> = Object.fromEntries(
(Object.keys(THEME_PATTERNS) as ThemePatternName[]).map((name) => [name.toLowerCase(), name]),
);
/** Resolves API pattern string → registry key. Unknown → null. */
export function resolveThemePattern(pattern?: string | null): ThemePatternName | null {
if (pattern == null) return null;
const key = String(pattern).trim().toLowerCase();
if (!key) return null;
return PATTERN_ALIASES[key] || null;
}
export function getThemePatternSpec(pattern?: string | null): ThemePatternSpec | null {
const name = resolveThemePattern(pattern);
return name ? THEME_PATTERNS[name] : null;
}

View File

@ -16,6 +16,7 @@ export interface FormField {
}
export interface PublicFormEventSummary {
_id?: string;
name: string;
subtitle?: string;
description?: string;
@ -27,6 +28,26 @@ export interface PublicFormEventSummary {
locationType?: string;
logoUrl?: string | null;
imageUrl?: string | null;
// Used by microsite JSX (About/Contact/Footer)
organizer?: string;
organizerName?: string;
facebookUrl?: string;
instagramUrl?: string;
twitterUrl?: string;
linkedinUrl?: string;
}
export interface PublicFormTicketOption {
_id: string;
name: string;
description?: string;
keyPoints?: string[];
basePriceMinor: number;
currency: string;
maxPerOrder?: number | null;
requiresBillingState?: boolean;
}
export interface PublicFormDefinition {
@ -36,13 +57,17 @@ export interface PublicFormDefinition {
type?: string;
event?: PublicFormEventSummary | null;
fields?: FormField[];
tickets?: PublicFormTicketOption[];
settings?: {
thankYouMessage?: string;
showThankYou?: boolean;
collectEmail?: boolean;
allowAnonymous?: boolean;
paymentEnabled?: boolean;
ticketIds?: string[];
eventHeader?: {
showLogo?: boolean;
showBanner?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
@ -52,13 +77,6 @@ export interface PublicFormDefinition {
};
}
export interface MicrositeSection {
id: string;
type: string;
order: number;
config: Record<string, unknown>;
}
export interface MicrositeSpeaker {
name: string;
designation?: string;
@ -66,18 +84,57 @@ export interface MicrositeSpeaker {
bio?: string;
}
export interface MicrositeKeyPerson {
name: string;
designation?: string;
photoUrl?: string;
bio?: string;
}
export interface AgendaSpeaker {
name?: string;
photoUrl?: string;
}
export interface AgendaItem {
title?: string;
date?: string;
time?: string;
track?: string;
description?: string;
/** Legacy v1 fields (kept for any remaining consumers) */
speakerName?: string;
speakerPhotoUrl?: string;
/** Defaultsite agenda speakers */
speakers?: AgendaSpeaker[];
}
/** Page background preset. Pattern / confetti / emoji come later. */
export type MicrositeThemeMode = 'simple' | 'gradient';
export type MicrositeSectionEnvelope<
TType extends string = string,
TConfig extends Record<string, unknown> = Record<string, unknown>
> = {
id: string;
type: TType;
order: number;
config: TConfig;
};
/** @deprecated Prefer MicrositeSectionEnvelope — kept for transitional references */
export interface MicrositeSection {
id: string;
type: string;
order: number;
config: Record<string, unknown>;
}
// =========================
// Theme typing (luma-like)
// =========================
/** Page background preset. Confetti / emoji come later. */
export type MicrositeThemeMode = 'simple' | 'gradient' | 'pattern';
/** SVG tile pattern names for theme === 'pattern' (see themePatterns.ts). */
export type MicrositeThemePattern = 'dots' | 'grid' | 'zigzag' | 'diagonal' | 'hexa' | 'plus';
/** Single-family fonts driven by theme.font (separate from website.fontPair). */
export type MicrositeThemeFont = 'Open Sans' | 'Montserrat' | 'Roboto' | 'Poppins' | 'Raleway';
@ -85,10 +142,120 @@ export type MicrositeThemeFont = 'Open Sans' | 'Montserrat' | 'Roboto' | 'Poppin
export interface MicrositeThemeConfig {
theme: MicrositeThemeMode;
color: string;
/** Required for theme === 'pattern'; e.g. dots | grid | zigzag */
pattern?: MicrositeThemePattern | string | null;
/** Optional; defaults to Open Sans when theme is present. */
font?: MicrositeThemeFont | string | null;
/** Dynamic palette from API — mapped to --theme-* CSS vars */
primaryColor?: string | null;
secondaryColor?: string | null;
headerColor?: string | null;
buttonColor?: string | null;
textColor?: string | null;
textHighlightColor?: string | null;
}
// =========================
// Section configs
// =========================
export type SocialLink = { name: string; handleUrl: string };
export type MicrositeHeaderMenuItem = { label?: string; url?: string };
export type MicrositeHeaderConfig = {
logoText?: string;
logoUrl?: string;
menuItems?: MicrositeHeaderMenuItem[];
};
export type MicrositeBannerConfig = {
headline?: string;
subtext?: string;
ctaText?: string;
ctaUrl?: string;
secondaryCtaText?: string;
secondaryCtaUrl?: string;
backgroundClass?: string;
backgroundImage?: string;
heroMetaLine?: string;
};
export type MicrositeCountdownConfig = {
headingLine?: string;
/** ISO instant for the countdown target (IST-aware from /defaultsite). */
startsAt?: string;
};
export type MicrositeAboutConfig = {
title?: string;
lead?: string;
body?: string;
bulletsTitle?: string;
bullets?: string[];
ctaLabel?: string;
ctaUrl?: string;
ctaText?: string;
mediaUrl?: string;
mediaType?: 'image' | 'video' | string;
};
export type MicrositeAgendaConfig = {
title?: string;
agenda?: AgendaItem[];
};
export type MicrositeSpeakersConfig = {
title?: string;
limit?: number;
speakers?: MicrositeSpeaker[];
};
export type MicrositeKeyPersonsConfig = {
title?: string;
limit?: number;
keyPersons?: MicrositeKeyPerson[];
};
export type MicrositeRegisterLinksConfig = {
title?: string;
text?: string;
primaryLabel?: string;
primaryUrl?: string;
secondaryLabel?: string;
secondaryUrl?: string;
};
export type MicrositeFooterConfig = {
copyrightText?: string;
// Optional; older config uses `links` here
links?: { label?: string; url?: string }[];
};
export type MicrositeContactConfig = {
title?: string;
text?: string;
email?: string;
phone?: string;
address?: string;
};
export type MicrositeTicketTier = {
id?: string;
name?: string;
price?: string;
description?: string;
};
export type MicrositeTicketsConfig = {
title?: string;
text?: string;
primaryLabel?: string;
primaryUrl?: string;
tiers?: MicrositeTicketTier[];
};
// =========================
// Defaultsite payload (named sections)
// =========================
export interface PublicMicrositePayload {
event: PublicFormEventSummary & { _id: string };
website: {
@ -103,15 +270,30 @@ export interface PublicMicrositePayload {
fontPair: string;
layoutTemplate: string;
seoDescription: string;
/** Luma-style background preset: maps to theme-{simple|gradient}-{color} on the root. */
/** Luma-style background preset: maps to theme-{simple|gradient|pattern}-{color} on html. */
theme?: MicrositeThemeConfig | null;
};
title: string;
sections: MicrositeSection[];
agenda: AgendaItem[];
speakers: MicrositeSpeaker[];
// Explicit top-level sections (no `sections[]` array)
header?: MicrositeSectionEnvelope<'header', MicrositeHeaderConfig>;
banner?: MicrositeSectionEnvelope<'banner', MicrositeBannerConfig>;
countdown?: MicrositeSectionEnvelope<'countdown', MicrositeCountdownConfig>;
about?: MicrositeSectionEnvelope<'about', MicrositeAboutConfig>;
agenda?: MicrositeSectionEnvelope<'agenda', MicrositeAgendaConfig>;
speakers?: MicrositeSectionEnvelope<'speakers', MicrositeSpeakersConfig>;
key_persons?: MicrositeSectionEnvelope<'key_persons', MicrositeKeyPersonsConfig>;
register_links?: MicrositeSectionEnvelope<'register_links', MicrositeRegisterLinksConfig>;
tickets?: MicrositeSectionEnvelope<'tickets', MicrositeTicketsConfig>;
contact?: MicrositeSectionEnvelope<'contact', MicrositeContactConfig>;
footer?: MicrositeSectionEnvelope<'footer', MicrositeFooterConfig>;
// Misc
socialLinks?: SocialLink[];
registrationFormId: string | null;
registrationUrl: string;
micrositeHtml?: string;
}

View File

@ -1,5 +1,6 @@
export interface FormEventHeaderDisplay {
showLogo?: boolean;
showBanner?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
@ -14,6 +15,7 @@ export function resolveFormEventHeaderDisplay(
): ResolvedFormEventHeaderDisplay {
return {
showLogo: display?.showLogo !== false,
showBanner: display?.showBanner === true,
showTitle: display?.showTitle !== false,
showDateRange: display?.showDateRange !== false,
showEventType: display?.showEventType !== false,

View File

@ -0,0 +1,89 @@
import type { FormField } from '../types';
function fieldKey(f: FormField): string {
return String(f.name || f.label || '').trim();
}
function mappedValue(fields: FormField[], values: Record<string, unknown>, mapsTo: string): string {
const f = fields.find((x) => x.mapsTo === mapsTo);
if (!f) return '';
const raw = values[fieldKey(f)];
return raw != null ? String(raw).trim() : '';
}
/** Build ticket checkout attendee + formData from registration form answers. */
export function buildTicketAttendeeFromForm(
fields: FormField[],
values: Record<string, unknown>,
submitData: Record<string, unknown>,
) {
return {
firstName: mappedValue(fields, values, 'firstName') || mappedValue(fields, submitData, 'firstName'),
lastName: mappedValue(fields, values, 'lastName') || mappedValue(fields, submitData, 'lastName'),
email: (
mappedValue(fields, values, 'email') || mappedValue(fields, submitData, 'email')
).toLowerCase(),
phone: mappedValue(fields, values, 'phone') || mappedValue(fields, submitData, 'phone') || undefined,
organization:
mappedValue(fields, values, 'organization')
|| mappedValue(fields, submitData, 'organization')
|| undefined,
designation:
mappedValue(fields, values, 'designation')
|| mappedValue(fields, submitData, 'designation')
|| undefined,
displayName:
mappedValue(fields, values, 'displayName')
|| mappedValue(fields, submitData, 'displayName')
|| undefined,
formData: submitData,
};
}
export function formatTicketPrice(minor: number, currency = 'INR'): string {
const major = (Number(minor) || 0) / 100;
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(major);
} catch {
return `${currency} ${major.toFixed(2)}`;
}
}
export const INDIAN_STATES = [
{ code: 'AN', name: 'Andaman and Nicobar Islands' },
{ code: 'AP', name: 'Andhra Pradesh' },
{ code: 'AR', name: 'Arunachal Pradesh' },
{ code: 'AS', name: 'Assam' },
{ code: 'BR', name: 'Bihar' },
{ code: 'CH', name: 'Chandigarh' },
{ code: 'CT', name: 'Chhattisgarh' },
{ code: 'DN', name: 'Dadra and Nagar Haveli and Daman and Diu' },
{ code: 'DL', name: 'Delhi' },
{ code: 'GA', name: 'Goa' },
{ code: 'GJ', name: 'Gujarat' },
{ code: 'HR', name: 'Haryana' },
{ code: 'HP', name: 'Himachal Pradesh' },
{ code: 'JK', name: 'Jammu and Kashmir' },
{ code: 'JH', name: 'Jharkhand' },
{ code: 'KA', name: 'Karnataka' },
{ code: 'KL', name: 'Kerala' },
{ code: 'LA', name: 'Ladakh' },
{ code: 'LD', name: 'Lakshadweep' },
{ code: 'MP', name: 'Madhya Pradesh' },
{ code: 'MH', name: 'Maharashtra' },
{ code: 'MN', name: 'Manipur' },
{ code: 'ML', name: 'Meghalaya' },
{ code: 'MZ', name: 'Mizoram' },
{ code: 'NL', name: 'Nagaland' },
{ code: 'OR', name: 'Odisha' },
{ code: 'PY', name: 'Puducherry' },
{ code: 'PB', name: 'Punjab' },
{ code: 'RJ', name: 'Rajasthan' },
{ code: 'SK', name: 'Sikkim' },
{ code: 'TN', name: 'Tamil Nadu' },
{ code: 'TG', name: 'Telangana' },
{ code: 'TR', name: 'Tripura' },
{ code: 'UP', name: 'Uttar Pradesh' },
{ code: 'UT', name: 'Uttarakhand' },
{ code: 'WB', name: 'West Bengal' },
];

11
src/utils/upiPayUri.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* UPI deep link for QR / intent. Amount is major currency units (e.g. "1234.56").
*/
export function buildUpiPayUri(params: { pa: string; pn: string; am: string; cu?: string }): string {
const cu = params.cu ?? 'INR';
const pa = params.pa.trim();
const pn = params.pn.trim().slice(0, 99);
const am = params.am.trim();
if (!pa || !pn || !am) return '';
return `upi://pay?pa=${encodeURIComponent(pa)}&pn=${encodeURIComponent(pn)}&am=${encodeURIComponent(am)}&cu=${encodeURIComponent(cu)}`;
}