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:
parent
d9843a5fb1
commit
c9519ac24c
|
|
@ -6,3 +6,7 @@ VITE_PUBLIC_WEB_BASE=http://localhost:5175
|
||||||
# Production (see .env.production — used automatically by `npm run build`)
|
# Production (see .env.production — used automatically by `npm run build`)
|
||||||
# VITE_API_BASE_URL=https://api.eventbuzz.live/api
|
# VITE_API_BASE_URL=https://api.eventbuzz.live/api
|
||||||
# VITE_PUBLIC_WEB_BASE=https://web.eventbuzz.live
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
182
deploy.sh
|
|
@ -1,181 +1,3 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
# Deprecated wrapper — use the repo-root deploy script instead.
|
||||||
# EventBuzz production deploy
|
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/deploy.sh" "$@"
|
||||||
#
|
|
||||||
# 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."
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,13 +9,19 @@
|
||||||
"preview": "vite preview --port 5175"
|
"preview": "vite preview --port 5175"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.7.0",
|
||||||
|
"react-player": "^3.4.0",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { LandingPage } from './pages/LandingPage';
|
||||||
import { EventMicrositePage } from './pages/EventMicrositePage';
|
import { EventMicrositePage } from './pages/EventMicrositePage';
|
||||||
import { RegisterPage } from './pages/RegisterPage';
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
import { FormPage } from './pages/FormPage';
|
import { FormPage } from './pages/FormPage';
|
||||||
|
|
@ -8,6 +9,7 @@ export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/event/:eventId" element={<EventMicrositePage />} />
|
<Route path="/event/:eventId" element={<EventMicrositePage />} />
|
||||||
<Route path="/event/:eventId/register" element={<RegisterPage />} />
|
<Route path="/event/:eventId/register" element={<RegisterPage />} />
|
||||||
<Route path="/event/:eventId/form/:formId" element={<FormPage />} />
|
<Route path="/event/:eventId/form/:formId" element={<FormPage />} />
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { PublicMicrositePayload, RegistrationInfo } from '../types';
|
||||||
export const micrositeApi = {
|
export const micrositeApi = {
|
||||||
get(eventId: string): Promise<PublicMicrositePayload> {
|
get(eventId: string): Promise<PublicMicrositePayload> {
|
||||||
//Real API call
|
//Real API call
|
||||||
return apiGet<PublicMicrositePayload>(`/public/events/${eventId}/microsite`);
|
return apiGet<PublicMicrositePayload>(`/public/events/${eventId}/defaultsite`);
|
||||||
|
|
||||||
//Mock: Testing data
|
//Mock: Testing data
|
||||||
// return Promise.resolve({
|
// return Promise.resolve({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
|
|
@ -6,6 +6,8 @@ import { DynamicFormFields, buildSubmitData, validateFormFields } from './Dynami
|
||||||
import { FormCaptchaField, validateCaptchaAnswer } from './FormCaptchaField';
|
import { FormCaptchaField, validateCaptchaAnswer } from './FormCaptchaField';
|
||||||
import { assignUniqueFieldNames } from '../../utils/formFieldNames';
|
import { assignUniqueFieldNames } from '../../utils/formFieldNames';
|
||||||
import { ensureRegistrationCoreFields } from '../../utils/registrationFormFields';
|
import { ensureRegistrationCoreFields } from '../../utils/registrationFormFields';
|
||||||
|
import { RegistrationTicketStep } from './RegistrationTicketStep';
|
||||||
|
import { buildTicketAttendeeFromForm } from '../../utils/registrationTicketAttendee';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
|
@ -25,11 +27,15 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
const [doneMessage, setDoneMessage] = useState<string | null>(null);
|
||||||
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||||
const [captchaToken, setCaptchaToken] = useState('');
|
const [captchaToken, setCaptchaToken] = useState('');
|
||||||
const [captchaMode, setCaptchaMode] = useState<'server' | 'local'>('server');
|
const [captchaMode, setCaptchaMode] = useState<'server' | 'local'>('server');
|
||||||
const [localCaptchaAnswer, setLocalCaptchaAnswer] = useState<string | null>(null);
|
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) => {
|
const handleCaptchaModeChange = useCallback((mode: 'server' | 'local', localAnswer: string | null) => {
|
||||||
setCaptchaMode(mode);
|
setCaptchaMode(mode);
|
||||||
setLocalCaptchaAnswer(localAnswer);
|
setLocalCaptchaAnswer(localAnswer);
|
||||||
|
|
@ -69,6 +75,12 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
|
||||||
setSubmitError(captchaErr);
|
setSubmitError(captchaErr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ticketsEnabled) {
|
||||||
|
setStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await formsApi.submit(formId, {
|
await formsApi.submit(formId, {
|
||||||
|
|
@ -103,7 +115,10 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (done) {
|
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;
|
const ev = form.event;
|
||||||
return (
|
return (
|
||||||
<div className={embed ? 'form-page-center form-page-center--embed' : 'form-page-center'}>
|
<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 ev = form.event;
|
||||||
const pageTitle = titleOverride || form.name;
|
const pageTitle = titleOverride || form.name;
|
||||||
|
const attendee = buildTicketAttendeeFromForm(
|
||||||
|
form.fields || [],
|
||||||
|
values,
|
||||||
|
buildSubmitData(form.fields || [], values),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={embed ? 'form-page form-page--embed' : 'form-page'}>
|
<div className={embed ? 'form-page form-page--embed' : 'form-page'}>
|
||||||
|
|
@ -136,26 +156,46 @@ export function FormShell({ eventId, formId, titleOverride }: Props) {
|
||||||
</h1>
|
</h1>
|
||||||
{form.description ? <p className="form-desc">{form.description}</p> : <div style={{ marginBottom: 24 }} />}
|
{form.description ? <p className="form-desc">{form.description}</p> : <div style={{ marginBottom: 24 }} />}
|
||||||
|
|
||||||
<form className="form-panel" onSubmit={(e) => void onSubmit(e)}>
|
{step === 1 ? (
|
||||||
<DynamicFormFields
|
<form className="form-panel" onSubmit={(e) => void onSubmit(e)}>
|
||||||
fields={form.fields || []}
|
{ticketsEnabled ? <p className="form-ticket-step-label">Step 1 of 2 — Your details</p> : null}
|
||||||
values={values}
|
<DynamicFormFields
|
||||||
fileNames={fileNames}
|
fields={form.fields || []}
|
||||||
onChange={(key, v) => setValues((prev) => ({ ...prev, [key]: v }))}
|
values={values}
|
||||||
onFileNamesChange={(key, name) => setFileNames((prev) => ({ ...prev, [key]: name }))}
|
fileNames={fileNames}
|
||||||
|
onChange={(key, v) => setValues((prev) => ({ ...prev, [key]: v }))}
|
||||||
|
onFileNamesChange={(key, name) => setFileNames((prev) => ({ ...prev, [key]: name }))}
|
||||||
|
/>
|
||||||
|
<FormCaptchaField
|
||||||
|
value={captchaAnswer}
|
||||||
|
onChange={setCaptchaAnswer}
|
||||||
|
onTokenChange={setCaptchaToken}
|
||||||
|
onModeChange={handleCaptchaModeChange}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
{submitError ? <p className="form-error">{submitError}</p> : null}
|
||||||
|
<button type="submit" className="form-submit" disabled={submitting}>
|
||||||
|
{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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<FormCaptchaField
|
)}
|
||||||
value={captchaAnswer}
|
|
||||||
onChange={setCaptchaAnswer}
|
|
||||||
onTokenChange={setCaptchaToken}
|
|
||||||
onModeChange={handleCaptchaModeChange}
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
{submitError ? <p className="form-error">{submitError}</p> : null}
|
|
||||||
<button type="submit" className="form-submit" disabled={submitting}>
|
|
||||||
{submitting ? 'Submitting…' : 'Submit'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function PublicFormEventHeader({
|
||||||
const showLoc = d.showVenue && shouldShowLocation(event);
|
const showLoc = d.showVenue && shouldShowLocation(event);
|
||||||
const embedded = variant === 'embedded';
|
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 showLogo = d.showLogo && !!event.logoUrl;
|
||||||
const showTitle = d.showTitle && !!event.name;
|
const showTitle = d.showTitle && !!event.name;
|
||||||
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
|
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
|
||||||
|
|
@ -91,14 +91,14 @@ export function PublicFormEventHeader({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`form-event-header${embedded ? ' form-event-header--embedded' : ''}`}>
|
<div className={`form-event-header${embedded ? ' form-event-header--embedded' : ''}`}>
|
||||||
|
{showLogo ? (
|
||||||
|
<img src={event.logoUrl!} alt={event.name} className="form-event-logo" />
|
||||||
|
) : null}
|
||||||
{showBanner ? (
|
{showBanner ? (
|
||||||
<div className="form-event-banner">
|
<div className="form-event-banner">
|
||||||
<img src={event.imageUrl!} alt="" />
|
<img src={event.imageUrl!} alt="" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{showLogo ? (
|
|
||||||
<img src={event.logoUrl!} alt={event.name} className="form-event-logo" />
|
|
||||||
) : null}
|
|
||||||
{showTitle || showSubtitle || metaItems.length > 0 ? (
|
{showTitle || showSubtitle || metaItems.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
{showTitle ? <h1 className="form-event-name">{event.name}</h1> : null}
|
{showTitle ? <h1 className="form-event-name">{event.name}</h1> : null}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, type CSSProperties, type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { conferenceCssVars, loadConferenceFont, resolveThemeClass } from '../../theme/conferenceTheme';
|
import { conferenceCssVars, loadConferenceFont, resolveThemeClasses } from '../../theme/conferenceTheme';
|
||||||
import type { PublicMicrositePayload } from '../../types';
|
import type { PublicMicrositePayload } from '../../types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -7,6 +7,31 @@ type Props = {
|
||||||
children: ReactNode;
|
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) {
|
export function MicrositeLayout({ data, children }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConferenceFont(data.website);
|
loadConferenceFont(data.website);
|
||||||
|
|
@ -23,17 +48,27 @@ export function MicrositeLayout({ data, children }: Props) {
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const style = conferenceCssVars(data.website) as CSSProperties;
|
useEffect(() => {
|
||||||
const themeClass = resolveThemeClass(data.website.theme);
|
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 (
|
return (
|
||||||
<div className={['eb-microsite-root', themeClass].filter(Boolean).join(' ')} style={style}>
|
<div className="eb-microsite-root">
|
||||||
<main className="eb-microsite-main">
|
<main className="eb-microsite-main">
|
||||||
{children}
|
{children}
|
||||||
<div className="eb-microsite-footer-spacer"></div>
|
<div className="eb-microsite-footer-spacer"></div>
|
||||||
<footer className="eb-static-footer">
|
|
||||||
Powered by <strong>EventBuzz</strong>
|
|
||||||
</footer>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 || '#';
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import './styles/conference.css';
|
import './styles/conference.css';
|
||||||
import './styles/forms.css';
|
import './styles/forms.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,15 @@ import { micrositeApi } from '../api/microsite';
|
||||||
import { PUBLIC_WEB_BASE } from '../api/client';
|
import { PUBLIC_WEB_BASE } from '../api/client';
|
||||||
import type { PublicMicrositePayload } from '../types';
|
import type { PublicMicrositePayload } from '../types';
|
||||||
import { MicrositeLayout } from '../components/microsite/MicrositeLayout';
|
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() {
|
export function EventMicrositePage() {
|
||||||
const { eventId = '' } = useParams();
|
const { eventId = '' } = useParams();
|
||||||
|
|
@ -44,8 +52,8 @@ export function EventMicrositePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const registrationUrl = data.registrationFormId
|
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 };
|
const ctx = { data, eventId, registrationUrl };
|
||||||
|
|
||||||
if (data.micrositeHtml) {
|
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 (
|
return (
|
||||||
<MicrositeLayout data={data}>
|
<MicrositeLayout data={data}>
|
||||||
{sections.map((section) => (
|
{data.header ? <HeaderSection section={data.header} ctx={ctx} /> : null}
|
||||||
<SectionRenderer key={section.id || section.type} section={section} ctx={ctx} />
|
{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>
|
</MicrositeLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,10 @@ export function NotFoundPage() {
|
||||||
<div className="form-page-center">
|
<div className="form-page-center">
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<h1 style={{ margin: '0 0 8px' }}>Page not found</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -348,23 +348,25 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-event-banner {
|
.form-event-banner {
|
||||||
display: flex;
|
width: 100%;
|
||||||
justify-content: center;
|
margin: 0 0 16px;
|
||||||
margin-bottom: 16px;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 120px;
|
max-height: 180px;
|
||||||
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-event-banner img {
|
.form-event-banner img {
|
||||||
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 120px;
|
max-height: 180px;
|
||||||
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-event-logo {
|
.form-event-logo {
|
||||||
display: block;
|
display: block;
|
||||||
max-height: 120px;
|
max-height: 96px;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -480,3 +482,134 @@
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,85 @@
|
||||||
import type { MicrositeThemeConfig } from '../types';
|
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_FONT_PAIR = 'Montserrat + Roboto';
|
||||||
const DEFAULT_THEME_FONT = 'Open Sans';
|
const DEFAULT_THEME_FONT = 'Open Sans';
|
||||||
|
|
||||||
/** Colors that have matching `.theme-simple-*` rules in conference.css */
|
/** Colors that have matching `html.theme-simple-*` / `html.theme-pattern-*` palette rules */
|
||||||
const SIMPLE_COLORS = new Set(['green', 'purple', 'gray', 'yellow']);
|
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']);
|
const GRADIENT_COLORS = new Set(['green', 'warm', 'warm2']);
|
||||||
|
|
||||||
type FontFaceSpec = { href: string; heading: string; body: string };
|
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`.
|
* Maps API theme config → class names on `<html>`.
|
||||||
* Unknown mode/color returns '' so the default root styles apply.
|
* - simple: theme-simple-{color}
|
||||||
|
* - gradient: theme-gradient-{color}
|
||||||
|
* - pattern: theme-pattern-{color} pattern-{name}
|
||||||
*/
|
*/
|
||||||
export function resolveThemeClass(config?: MicrositeThemeConfig | null): string {
|
export function resolveThemeClasses(config?: MicrositeThemeConfig | null): string[] {
|
||||||
if (!config?.theme || !config?.color) return '';
|
if (!config?.theme || !config?.color) return [];
|
||||||
|
|
||||||
const mode = String(config.theme).trim().toLowerCase();
|
const mode = String(config.theme).trim().toLowerCase();
|
||||||
const color = String(config.color).trim().toLowerCase();
|
const color = String(config.color).trim().toLowerCase();
|
||||||
if (!mode || !color) return '';
|
if (!mode || !color) return [];
|
||||||
|
|
||||||
if (mode === 'simple' && SIMPLE_COLORS.has(color)) {
|
if (mode === 'simple' && SIMPLE_COLORS.has(color)) {
|
||||||
return `theme-simple-${color}`;
|
return [`theme-simple-${color}`];
|
||||||
}
|
}
|
||||||
if (mode === 'gradient' && GRADIENT_COLORS.has(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). */
|
/** 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;
|
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) {
|
function hexToRgb(hex: string) {
|
||||||
const n = parseInt(hex.slice(1), 16);
|
const n = parseInt(hex.slice(1), 16);
|
||||||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
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)}`;
|
return `#${c(r)}${c(g)}${c(b)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function darken(hex: string, amount: number) {
|
function parseCssRgb(input: string): { r: number; g: number; b: number; a: number } | null {
|
||||||
const rgb = hexToRgb(hex);
|
const hex = normalizeHex(input);
|
||||||
const f = 1 - amount;
|
if (hex) {
|
||||||
return rgbToHex(rgb.r * f, rgb.g * f, rgb.b * f);
|
const { r, g, b } = hexToRgb(hex);
|
||||||
}
|
return { r, g, b, a: 1 };
|
||||||
|
}
|
||||||
function lighten(hex: string, amount: number) {
|
const m = input.match(
|
||||||
const rgb = hexToRgb(hex);
|
/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i,
|
||||||
return rgbToHex(rgb.r + (255 - rgb.r) * amount, rgb.g + (255 - rgb.g) * amount, rgb.b + (255 - rgb.b) * amount);
|
);
|
||||||
}
|
if (!m) return null;
|
||||||
|
return {
|
||||||
function relativeLuminance(hex: string) {
|
r: Number(m[1]),
|
||||||
const { r, g, b } = hexToRgb(hex);
|
g: Number(m[2]),
|
||||||
const lin = (c: number) => {
|
b: Number(m[3]),
|
||||||
const v = c / 255;
|
a: m[4] != null ? Number(m[4]) : 1,
|
||||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
||||||
};
|
};
|
||||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function accentForeground(accentHex: string) {
|
/** Lightens hex/rgb/rgba toward white. Falls back to the original string if unparseable. */
|
||||||
return relativeLuminance(accentHex) > 0.45 ? '#0d2137' : '#ffffff';
|
function lightenCssColor(input: string, amount: number): string {
|
||||||
}
|
const parsed = parseCssRgb(input);
|
||||||
|
if (!parsed) return input;
|
||||||
function hexToRgba(hex: string, alpha: number) {
|
const r = Math.round(parsed.r + (255 - parsed.r) * amount);
|
||||||
const { r, g, b } = hexToRgb(hex);
|
const g = Math.round(parsed.g + (255 - parsed.g) * amount);
|
||||||
return `rgba(${r},${g},${b},${alpha})`;
|
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) {
|
export function fontSpec(fontPair?: string) {
|
||||||
|
|
@ -181,34 +287,15 @@ export function resolveWebsiteFont(website: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function conferenceCssVars(website: {
|
export function conferenceCssVars(website: {
|
||||||
primaryColor?: string;
|
|
||||||
accentColor?: string;
|
|
||||||
fontPair?: string;
|
fontPair?: string;
|
||||||
theme?: MicrositeThemeConfig | null;
|
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);
|
const spec = resolveWebsiteFont(website);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'--eb-conf-primary': primary,
|
'--theme-heading-font': spec.heading,
|
||||||
'--eb-conf-primary-dark': primaryDark,
|
'--theme-body-font': spec.body,
|
||||||
'--eb-conf-accent': accent,
|
...themePaletteVars(website.theme),
|
||||||
'--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),
|
|
||||||
} as Record<string, string>;
|
} as Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ export interface FormField {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicFormEventSummary {
|
export interface PublicFormEventSummary {
|
||||||
|
_id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
@ -27,6 +28,26 @@ export interface PublicFormEventSummary {
|
||||||
locationType?: string;
|
locationType?: string;
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
imageUrl?: 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 {
|
export interface PublicFormDefinition {
|
||||||
|
|
@ -36,13 +57,17 @@ export interface PublicFormDefinition {
|
||||||
type?: string;
|
type?: string;
|
||||||
event?: PublicFormEventSummary | null;
|
event?: PublicFormEventSummary | null;
|
||||||
fields?: FormField[];
|
fields?: FormField[];
|
||||||
|
tickets?: PublicFormTicketOption[];
|
||||||
settings?: {
|
settings?: {
|
||||||
thankYouMessage?: string;
|
thankYouMessage?: string;
|
||||||
showThankYou?: boolean;
|
showThankYou?: boolean;
|
||||||
collectEmail?: boolean;
|
collectEmail?: boolean;
|
||||||
allowAnonymous?: boolean;
|
allowAnonymous?: boolean;
|
||||||
|
paymentEnabled?: boolean;
|
||||||
|
ticketIds?: string[];
|
||||||
eventHeader?: {
|
eventHeader?: {
|
||||||
showLogo?: boolean;
|
showLogo?: boolean;
|
||||||
|
showBanner?: boolean;
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
showDateRange?: boolean;
|
showDateRange?: boolean;
|
||||||
showEventType?: 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 {
|
export interface MicrositeSpeaker {
|
||||||
name: string;
|
name: string;
|
||||||
designation?: string;
|
designation?: string;
|
||||||
|
|
@ -66,18 +84,57 @@ export interface MicrositeSpeaker {
|
||||||
bio?: string;
|
bio?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MicrositeKeyPerson {
|
||||||
|
name: string;
|
||||||
|
designation?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
bio?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgendaSpeaker {
|
||||||
|
name?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgendaItem {
|
export interface AgendaItem {
|
||||||
title?: string;
|
title?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
time?: string;
|
time?: string;
|
||||||
track?: string;
|
track?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Legacy v1 fields (kept for any remaining consumers) */
|
||||||
speakerName?: string;
|
speakerName?: string;
|
||||||
speakerPhotoUrl?: string;
|
speakerPhotoUrl?: string;
|
||||||
|
/** Defaultsite agenda speakers */
|
||||||
|
speakers?: AgendaSpeaker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Page background preset. Pattern / confetti / emoji come later. */
|
export type MicrositeSectionEnvelope<
|
||||||
export type MicrositeThemeMode = 'simple' | 'gradient';
|
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). */
|
/** Single-family fonts driven by theme.font (separate from website.fontPair). */
|
||||||
export type MicrositeThemeFont = 'Open Sans' | 'Montserrat' | 'Roboto' | 'Poppins' | 'Raleway';
|
export type MicrositeThemeFont = 'Open Sans' | 'Montserrat' | 'Roboto' | 'Poppins' | 'Raleway';
|
||||||
|
|
@ -85,10 +142,120 @@ export type MicrositeThemeFont = 'Open Sans' | 'Montserrat' | 'Roboto' | 'Poppin
|
||||||
export interface MicrositeThemeConfig {
|
export interface MicrositeThemeConfig {
|
||||||
theme: MicrositeThemeMode;
|
theme: MicrositeThemeMode;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** Required for theme === 'pattern'; e.g. dots | grid | zigzag */
|
||||||
|
pattern?: MicrositeThemePattern | string | null;
|
||||||
/** Optional; defaults to Open Sans when theme is present. */
|
/** Optional; defaults to Open Sans when theme is present. */
|
||||||
font?: MicrositeThemeFont | string | null;
|
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 {
|
export interface PublicMicrositePayload {
|
||||||
event: PublicFormEventSummary & { _id: string };
|
event: PublicFormEventSummary & { _id: string };
|
||||||
website: {
|
website: {
|
||||||
|
|
@ -103,15 +270,30 @@ export interface PublicMicrositePayload {
|
||||||
fontPair: string;
|
fontPair: string;
|
||||||
layoutTemplate: string;
|
layoutTemplate: string;
|
||||||
seoDescription: 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;
|
theme?: MicrositeThemeConfig | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
title: string;
|
title: string;
|
||||||
sections: MicrositeSection[];
|
|
||||||
agenda: AgendaItem[];
|
// Explicit top-level sections (no `sections[]` array)
|
||||||
speakers: MicrositeSpeaker[];
|
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;
|
registrationFormId: string | null;
|
||||||
registrationUrl: string;
|
registrationUrl: string;
|
||||||
|
|
||||||
micrositeHtml?: string;
|
micrositeHtml?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export interface FormEventHeaderDisplay {
|
export interface FormEventHeaderDisplay {
|
||||||
showLogo?: boolean;
|
showLogo?: boolean;
|
||||||
|
showBanner?: boolean;
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
showDateRange?: boolean;
|
showDateRange?: boolean;
|
||||||
showEventType?: boolean;
|
showEventType?: boolean;
|
||||||
|
|
@ -14,6 +15,7 @@ export function resolveFormEventHeaderDisplay(
|
||||||
): ResolvedFormEventHeaderDisplay {
|
): ResolvedFormEventHeaderDisplay {
|
||||||
return {
|
return {
|
||||||
showLogo: display?.showLogo !== false,
|
showLogo: display?.showLogo !== false,
|
||||||
|
showBanner: display?.showBanner === true,
|
||||||
showTitle: display?.showTitle !== false,
|
showTitle: display?.showTitle !== false,
|
||||||
showDateRange: display?.showDateRange !== false,
|
showDateRange: display?.showDateRange !== false,
|
||||||
showEventType: display?.showEventType !== false,
|
showEventType: display?.showEventType !== false,
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
];
|
||||||
|
|
@ -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)}`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue