first commit

This commit is contained in:
Ravi Gulhane 2026-07-08 13:33:45 +05:30
commit 4bfc64f56d
39 changed files with 5426 additions and 0 deletions

181
deploy.sh Executable file
View File

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

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EventBuzz</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
nginx.example.conf Normal file
View File

@ -0,0 +1,43 @@
# nginx example for web.eventbuzz.live
#
# Deploy the Vite build output from `web/dist` and keep custom microsites on disk
# at MICROSITE_STATIC_ROOT (same layout as today).
#
# Order matters: slug-based static locations must not capture /event/... paths.
server {
listen 443 ssl http2;
server_name web.eventbuzz.live;
# ssl_certificate ...
# ssl_certificate_key ...
# Custom static microsites (publishFolder slugs, optional _draft suffix)
location ~ ^/[a-z0-9-]+(_draft)?/ {
root /var/www/microsites;
try_files $uri $uri/ $uri/index.html =404;
}
# React SPA — event-centric routes and assets
root /var/www/eventbuzz-web/dist;
index index.html;
location /event/ {
try_files $uri $uri/ /index.html;
}
location /assets/ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}
# Local development
# - API: http://localhost:3001/api
# - Web SPA: cd web && npm run dev (port 5175)
# - Dashboard: cd frontend && npm run dev

1898
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "eventbuzz-web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --port 5175",
"build": "vite build",
"preview": "vite preview --port 5175"
},
"dependencies": {
"flatpickr": "^4.6.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.1"
}
}

18
src/App.tsx Normal file
View File

@ -0,0 +1,18 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { EventMicrositePage } from './pages/EventMicrositePage';
import { RegisterPage } from './pages/RegisterPage';
import { FormPage } from './pages/FormPage';
import { NotFoundPage } from './pages/NotFoundPage';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/event/:eventId" element={<EventMicrositePage />} />
<Route path="/event/:eventId/register" element={<RegisterPage />} />
<Route path="/event/:eventId/form/:formId" element={<FormPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}

74
src/api/client.ts Normal file
View File

@ -0,0 +1,74 @@
const configuredApiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim();
function resolveApiBaseUrl(): string {
if (configuredApiBase) {
// Relative /api only works in dev (Vite proxy). In production it hits the web host.
if (import.meta.env.PROD && configuredApiBase === '/api') {
return 'https://api.eventbuzz.live/api';
}
return configuredApiBase.replace(/\/$/, '');
}
return import.meta.env.DEV ? '/api' : 'https://api.eventbuzz.live/api';
}
export const API_BASE_URL = resolveApiBaseUrl();
export const PUBLIC_WEB_BASE =
(import.meta.env.VITE_PUBLIC_WEB_BASE as string | undefined)?.replace(/\/$/, '') ||
(typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5175');
type ApiEnvelope<T> = {
success?: boolean;
data?: T;
error?: { message?: string; code?: string };
};
export async function apiGet<T>(path: string): Promise<T> {
const url = path.startsWith('http') ? path : `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
let res: Response;
try {
res = await fetch(url);
} catch {
throw new Error('Could not reach the API. If this persists, the server may be down or blocking cross-origin requests.');
}
let body: ApiEnvelope<T>;
try {
body = await res.json();
} catch {
if (res.status === 502 || res.status === 503 || res.status === 504) {
throw new Error('The API is temporarily unavailable. Please try again in a few minutes.');
}
throw new Error(`Unexpected server response (${res.status}).`);
}
if (!body.success || body.data === undefined) {
throw new Error(body.error?.message || 'Request failed');
}
return body.data;
}
export async function apiPost<T>(path: string, payload: unknown): Promise<T> {
const url = path.startsWith('http') ? path : `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch {
throw new Error('Could not reach the API. If this persists, the server may be down or blocking cross-origin requests.');
}
let body: ApiEnvelope<T>;
try {
body = await res.json();
} catch {
if (res.status === 502 || res.status === 503 || res.status === 504) {
throw new Error('The API is temporarily unavailable. Please try again in a few minutes.');
}
throw new Error(`Unexpected server response (${res.status}).`);
}
if (!body.success) {
throw new Error(body.error?.message || 'Request failed');
}
return body.data as T;
}

29
src/api/forms.ts Normal file
View File

@ -0,0 +1,29 @@
import { apiGet, apiPost } from './client';
import type { PublicFormDefinition } from '../types';
export type CaptchaChallenge = {
question: string;
captchaToken: string;
};
export const formsApi = {
getEventForm(eventId: string, formId: string): Promise<PublicFormDefinition> {
return apiGet<PublicFormDefinition>(`/public/events/${eventId}/forms/${formId}`);
},
getCaptchaChallenge(): Promise<CaptchaChallenge> {
return apiGet<CaptchaChallenge>('/public/captcha/challenge');
},
submit(
formId: string,
payload: {
data: Record<string, unknown>;
participantId?: string;
captchaToken?: string;
captchaAnswer?: string;
},
): Promise<unknown> {
return apiPost(`/forms/${formId}/submit`, payload);
},
};

12
src/api/microsite.ts Normal file
View File

@ -0,0 +1,12 @@
import { apiGet } from './client';
import type { PublicMicrositePayload, RegistrationInfo } from '../types';
export const micrositeApi = {
get(eventId: string): Promise<PublicMicrositePayload> {
return apiGet<PublicMicrositePayload>(`/public/events/${eventId}/microsite`);
},
getRegistration(eventId: string): Promise<RegistrationInfo> {
return apiGet<RegistrationInfo>(`/public/events/${eventId}/registration`);
},
};

17
src/api/visitor.ts Normal file
View File

@ -0,0 +1,17 @@
import { apiPost } from './client';
export type MicrositeContactPayload = {
eventId: string;
notifyEmail: string;
firstName?: string;
lastName?: string;
email: string;
message?: string;
_gotcha?: string;
};
export const visitorApi = {
submitMicrositeContact(payload: MicrositeContactPayload): Promise<{ sent?: boolean }> {
return apiPost('/visitor/microsite/contact', payload);
},
};

View File

@ -0,0 +1,397 @@
import type { FormField } from '../../types';
import { FormDatePicker } from './FormDatePicker';
import { FormTimePicker } from './FormTimePicker';
import { FormSelectField } from './FormSelectField';
import { FormCountryStateField } from './FormCountryStateField';
import { FormFieldRemarks } from './FormFieldRemarks';
import { fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from '../../utils/formFieldNames';
import { statesForCountry } from '../../data/countryStates';
/** @deprecated use fieldDataKey */
export function dataKey(label?: string) {
return (label || '').trim();
}
export function fieldValueToData(field: FormField, values: Record<string, unknown>): Record<string, unknown> {
if (field.type === 'section') return {};
if (isCountryStateField(field)) {
const { country, state } = countryStateKeys(field);
return { [country]: values[country] ?? '', [state]: values[state] ?? '' };
}
const k = fieldDataKey(field);
if (!k) return {};
const value = values[k];
if (field.type === 'checkboxes' && Array.isArray(value)) return { [k]: value };
if (field.type === 'checkbox') return { [k]: value === true || value === 'true' ? 'Yes' : '' };
return { [k]: value };
}
export function isStarRatingField(field: FormField): boolean {
if (field.type === 'stars') return true;
if (field.type !== 'select' || !field.options?.length) return false;
const o = field.options.map((x) => String(x));
return o.length === 5 && o.every((v, i) => v === String(i + 1));
}
export function isTextareaField(field: FormField): boolean {
const normalized = String(field.type || '')
.toLowerCase()
.trim()
.replace(/[\s_-]+/g, '');
return normalized === 'textarea' || normalized === 'longtext';
}
export function starRatingMax(field: FormField): number {
if (field.type === 'stars') {
const n = field.options?.length;
return n && n > 0 ? n : 5;
}
return 5;
}
type Props = {
fields: FormField[];
values: Record<string, unknown>;
fileNames: Record<string, string>;
onChange: (key: string, value: unknown) => void;
onFileNamesChange: (key: string, name: string) => void;
};
export function DynamicFormFields({ fields, values, fileNames, onChange, onFileNamesChange }: Props) {
return (
<>
{fields.map((field, index) => {
if (field.type === 'section') {
const title = (field.label || '').trim();
if (!title) return null;
return (
<div key={`section-${index}`} className="form-section-divider" role="separator" aria-label={title}>
{index > 0 ? <hr className="form-section-rule" /> : null}
<h3 className="form-section-title">{title}</h3>
</div>
);
}
const k = fieldDataKey(field);
const display = fieldDisplayLabel(field);
if (!k) return null;
const req = field.required;
if (field.type === 'text' || field.type === 'email' || field.type === 'phone') {
return (
<div key={`field-${index}-${k}`}>
<label className="form-label">
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</label>
<input
className="form-input"
type={field.type === 'email' ? 'email' : field.type === 'phone' ? 'tel' : 'text'}
value={String(values[k] ?? '')}
onChange={(e) => onChange(k, e.target.value)}
placeholder={field.placeholder}
required={!!req}
/>
<FormFieldRemarks text={field.remarks} />
</div>
);
}
if (field.type === 'date') {
return (
<div key={`field-${index}-${k}`}>
<label className="form-label" htmlFor={`field-${k}-date`}>
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</label>
<FormDatePicker
id={`field-${k}-date`}
value={String(values[k] ?? '')}
onChange={(v) => onChange(k, v)}
required={!!req}
/>
<FormFieldRemarks text={field.remarks} />
</div>
);
}
if (field.type === 'time') {
return (
<div key={`field-${index}-${k}`}>
<label className="form-label" htmlFor={`field-${k}-time`}>
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</label>
<FormTimePicker
id={`field-${k}-time`}
value={String(values[k] ?? '')}
onChange={(v) => onChange(k, v)}
required={!!req}
/>
<FormFieldRemarks text={field.remarks} />
</div>
);
}
if (isTextareaField(field)) {
return (
<div key={`field-${index}-${k}`}>
<label className="form-label">
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</label>
<textarea
className="form-input form-textarea"
value={String(values[k] ?? '')}
onChange={(e) => onChange(k, e.target.value)}
placeholder={field.placeholder}
required={!!req}
rows={4}
/>
<FormFieldRemarks text={field.remarks} />
</div>
);
}
if (isStarRatingField(field)) {
const max = starRatingMax(field);
const raw = values[k];
const v = raw != null && raw !== '' ? parseInt(String(raw), 10) : 0;
const selected = !Number.isNaN(v) && v >= 1 && v <= max ? v : 0;
return (
<div key={`field-${index}-${k}`}>
<div className="form-label" id={`star-legend-${k}`}>
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</div>
<div role="radiogroup" aria-labelledby={`star-legend-${k}`} style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{Array.from({ length: max }, (_, i) => {
const starN = i + 1;
const filled = selected >= starN;
return (
<button
key={starN}
type="button"
role="radio"
aria-checked={selected === starN}
aria-label={`${starN} of ${max} stars`}
onClick={() => onChange(k, String(starN))}
style={{
fontSize: '2rem',
lineHeight: 1,
width: 44,
height: 44,
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: 0,
color: filled ? '#ca8a04' : '#e2e8f0',
}}
>
★
</button>
);
})}
</div>
<FormFieldRemarks text={field.remarks} />
</div>
);
}
if (field.type === 'select' && (field.options?.length ?? 0) > 0) {
return (
<div key={`field-${index}-${k}`}>
<label className="form-label">
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</label>
<FormSelectField
value={String(values[k] ?? '')}
options={field.options!}
onChange={(v) => onChange(k, v)}
allowOther={field.allowOther}
otherLabel={field.otherLabel}
required={!!req}
/>
<FormFieldRemarks text={field.remarks} />
</div>
);
}
if (field.type === 'radio' && (field.options?.length ?? 0) > 0) {
return (
<fieldset key={`field-${index}-${k}`} style={{ border: 'none', padding: 0, margin: 0 }}>
<legend className="form-label" style={{ marginBottom: 8 }}>
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</legend>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{field.options!.map((o) => (
<label key={o} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.9rem', cursor: 'pointer' }}>
<input type="radio" name={k} value={o} checked={values[k] === o} onChange={() => onChange(k, o)} />
{o}
</label>
))}
</div>
<FormFieldRemarks text={field.remarks} />
</fieldset>
);
}
if (field.type === 'checkboxes' && (field.options?.length ?? 0) > 0) {
const cur = (values[k] as string[]) || [];
return (
<fieldset key={`field-${index}-${k}`} style={{ border: 'none', padding: 0, margin: 0 }}>
<legend className="form-label" style={{ marginBottom: 8 }}>
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</legend>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{field.options!.map((o) => {
const checked = cur.includes(o);
return (
<label key={o} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.9rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange(k, checked ? cur.filter((x) => x !== o) : [...cur, o])}
/>
{o}
</label>
);
})}
</div>
<FormFieldRemarks text={field.remarks} />
</fieldset>
);
}
if (field.type === 'checkbox') {
return (
<div key={`field-${index}-${k}`}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.9rem', cursor: 'pointer' }}>
<input type="checkbox" checked={!!values[k]} onChange={(e) => onChange(k, e.target.checked)} />
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</label>
<FormFieldRemarks text={field.remarks} />
</div>
);
}
if (field.type === 'file') {
return (
<div key={`field-${index}-${k}`}>
<span className="form-label">
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</span>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
onChange(k, '');
onFileNamesChange(k, '');
return;
}
onFileNamesChange(k, file.name);
const reader = new FileReader();
reader.onload = () => onChange(k, String(reader.result || ''));
reader.onerror = () => onChange(k, '');
reader.readAsDataURL(file);
}}
style={{ fontSize: '0.85rem' }}
/>
{fileNames[k] ? <p style={{ margin: '6px 0 0', fontSize: '0.8rem', color: '#64748b' }}>{fileNames[k]}</p> : null}
<FormFieldRemarks text={field.remarks} />
</div>
);
}
if (isCountryStateField(field)) {
const { country: countryKey, state: stateKey } = countryStateKeys(field);
return (
<div key={`field-${index}-${k}`}>
<div className="form-label">
{display}
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
</div>
<FormCountryStateField
country={String(values[countryKey] ?? '')}
state={String(values[stateKey] ?? '')}
onCountryChange={(v) => onChange(countryKey, v)}
onStateChange={(v) => onChange(stateKey, v)}
required={!!req}
/>
<FormFieldRemarks text={field.remarks} />
</div>
);
}
return (
<div key={`field-${index}-${k}`}>
<label className="form-label">{display}</label>
<input
className="form-input"
type="text"
value={String(values[k] ?? '')}
onChange={(e) => onChange(k, e.target.value)}
/>
<FormFieldRemarks text={field.remarks} />
</div>
);
})}
</>
);
}
export function validateFormFields(fields: FormField[], values: Record<string, unknown>): string | null {
for (const field of fields) {
if (field.type === 'section') continue;
const display = fieldDisplayLabel(field);
if (!field.required) continue;
if (isCountryStateField(field)) {
const { country, state } = countryStateKeys(field);
const countryVal = values[country];
const stateVal = values[state];
if (countryVal == null || (typeof countryVal === 'string' && countryVal.trim() === '')) {
return `“${display}” country is required.`;
}
const stateOptions = statesForCountry(String(countryVal));
if (
stateOptions.length > 0 &&
(stateVal == null || (typeof stateVal === 'string' && stateVal.trim() === ''))
) {
return `“${display}” state is required.`;
}
continue;
}
const k = fieldDataKey(field);
if (!k) continue;
const t = field.type;
if (t === 'checkboxes') {
const arr = values[k];
if (!Array.isArray(arr) || arr.length === 0) return `“${display}” is required.`;
continue;
}
if (t === 'checkbox') {
if (!values[k]) return `“${display}” is required.`;
continue;
}
const v = values[k];
if (v == null || (typeof v === 'string' && v.trim() === '')) return `“${display}” is required.`;
}
return null;
}
export function buildSubmitData(fields: FormField[], values: Record<string, unknown>): Record<string, unknown> {
const data: Record<string, unknown> = {};
for (const field of fields) {
if (field.type === 'section') continue;
Object.assign(data, fieldValueToData(field, values));
}
return data;
}

View File

@ -0,0 +1,121 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { formsApi } from '../../api/forms';
type CaptchaMode = 'server' | 'local';
type Props = {
value: string;
onChange: (value: string) => void;
onTokenChange: (token: string) => void;
onModeChange?: (mode: CaptchaMode, localExpectedAnswer: string | null) => void;
disabled?: boolean;
};
function createLocalChallenge() {
const a = Math.floor(Math.random() * 9) + 1;
const b = Math.floor(Math.random() * 9) + 1;
return {
question: `${a} + ${b} = ?`,
answer: String(a + b),
};
}
export function FormCaptchaField({ value, onChange, onTokenChange, onModeChange, disabled }: Props) {
const [question, setQuestion] = useState('Loading…');
const [loading, setLoading] = useState(false);
const onChangeRef = useRef(onChange);
const onTokenChangeRef = useRef(onTokenChange);
const onModeChangeRef = useRef(onModeChange);
useEffect(() => {
onChangeRef.current = onChange;
onTokenChangeRef.current = onTokenChange;
onModeChangeRef.current = onModeChange;
});
const applyLocalCaptcha = useCallback(() => {
const challenge = createLocalChallenge();
setQuestion(challenge.question);
onTokenChangeRef.current('');
onModeChangeRef.current?.('local', challenge.answer);
onChangeRef.current('');
}, []);
const reload = useCallback(async () => {
setLoading(true);
try {
const challenge = await formsApi.getCaptchaChallenge();
setQuestion(challenge.question);
onTokenChangeRef.current(challenge.captchaToken);
onModeChangeRef.current?.('server', null);
onChangeRef.current('');
} catch {
applyLocalCaptcha();
} finally {
setLoading(false);
}
}, [applyLocalCaptcha]);
const reloadRef = useRef(reload);
reloadRef.current = reload;
useEffect(() => {
void reloadRef.current();
}, []);
return (
<div className="form-group">
<label className="form-label" htmlFor="form-captcha-answer">
Security check
</label>
<div className="form-captcha-row">
<span className="form-captcha-question" aria-live="polite">
{question}
</span>
<input
id="form-captcha-answer"
className="form-input form-captcha-input"
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="Answer"
value={value}
disabled={disabled || loading}
onChange={(e) => onChange(e.target.value)}
required
/>
<button
type="button"
className="form-captcha-reload"
onClick={() => void reload()}
disabled={disabled || loading}
aria-label="Reload captcha"
>
↻
</button>
</div>
</div>
);
}
export function validateCaptchaAnswer(
mode: CaptchaMode,
answer: string,
token: string,
localExpectedAnswer: string | null,
): string | null {
const trimmed = answer.trim();
if (!trimmed) {
return 'Please solve the captcha.';
}
if (mode === 'server') {
if (!token) {
return 'Captcha is not ready. Please reload and try again.';
}
return null;
}
if (trimmed !== localExpectedAnswer) {
return 'Incorrect captcha answer. Please try again.';
}
return null;
}

View File

@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from '../../data/countryStates';
import { FormSelectField } from './FormSelectField';
type Props = {
country: string;
state: string;
onCountryChange: (value: string) => void;
onStateChange: (value: string) => void;
required?: boolean;
};
export function FormCountryStateField({
country,
state,
onCountryChange,
onStateChange,
required,
}: Props) {
useEffect(() => {
if (!country) onCountryChange(DEFAULT_COUNTRY);
// eslint-disable-next-line react-hooks/exhaustive-deps -- default once on mount
}, []);
const effectiveCountry = country || DEFAULT_COUNTRY;
const stateOptions = statesForCountry(effectiveCountry);
const handleCountryChange = (next: string) => {
onCountryChange(next);
const nextStates = statesForCountry(next);
if (state && !nextStates.includes(state)) onStateChange('');
};
return (
<div className="form-country-state">
<div className="form-country-state-row">
<label className="form-label form-country-state-label">
Country
{required ? <span style={{ color: '#dc2626' }}> *</span> : null}
</label>
<FormSelectField
value={effectiveCountry}
options={COUNTRY_NAMES}
onChange={handleCountryChange}
required={!!required}
placeholder="Select country…"
/>
</div>
<div className="form-country-state-row">
<label className="form-label form-country-state-label">
State
{required ? <span style={{ color: '#dc2626' }}> *</span> : null}
</label>
<FormSelectField
value={state}
options={stateOptions}
onChange={onStateChange}
required={!!required && stateOptions.length > 0}
placeholder={stateOptions.length ? 'Select state…' : 'No states available'}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,63 @@
import { useEffect, useRef } from 'react';
import flatpickr from 'flatpickr';
import type { Instance } from 'flatpickr/dist/types/instance';
import 'flatpickr/dist/flatpickr.min.css';
type Props = {
id: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
placeholder?: string;
};
export function FormDatePicker({ id, value, onChange, required, placeholder = 'Select date' }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const fpRef = useRef<Instance | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
const el = inputRef.current;
if (!el) return;
fpRef.current = flatpickr(el, {
dateFormat: 'Y-m-d',
altInput: true,
altFormat: 'M j, Y',
altInputClass: 'form-input form-picker-input',
defaultDate: value || undefined,
disableMobile: true,
onChange: (_dates, dateStr) => {
onChangeRef.current(dateStr);
},
});
return () => {
fpRef.current?.destroy();
fpRef.current = null;
};
}, []);
useEffect(() => {
const fp = fpRef.current;
if (!fp) return;
const current = fp.selectedDates.length ? fp.formatDate(fp.selectedDates[0], 'Y-m-d') : '';
if (current !== value) {
fp.setDate(value || '', false);
}
}, [value]);
return (
<input
ref={inputRef}
id={id}
type="text"
className="form-picker-source"
readOnly
required={required}
placeholder={placeholder}
aria-required={required || undefined}
/>
);
}

View File

@ -0,0 +1,9 @@
type Props = {
text?: string;
};
export function FormFieldRemarks({ text }: Props) {
const trimmed = text?.trim();
if (!trimmed) return null;
return <p className="form-field-remarks">{trimmed}</p>;
}

View File

@ -0,0 +1,180 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import {
SELECT_OTHER_VALUE,
normalizeSelectOptions,
otherTextFromValue,
selectControlValue,
type SelectOptionItem,
} from './selectWithOtherUtils';
type Props = {
value: string;
options: string[] | SelectOptionItem[];
onChange: (value: string) => void;
required?: boolean;
placeholder?: string;
allowOther?: boolean;
otherLabel?: string;
otherInputPlaceholder?: string;
};
export function FormSelectField({
value,
options,
onChange,
required,
placeholder = 'Select…',
allowOther,
otherLabel = 'Other',
otherInputPlaceholder = 'Please specify',
}: Props) {
const normalized = useMemo(() => normalizeSelectOptions(options), [options]);
const optionValues = useMemo(() => normalized.map((o) => o.value), [normalized]);
const otherValue = SELECT_OTHER_VALUE;
const stored = String(value ?? '');
const [open, setOpen] = useState(false);
const [menuValue, setMenuValue] = useState(() =>
selectControlValue(stored, optionValues, allowOther, otherValue),
);
const [otherText, setOtherText] = useState(() => otherTextFromValue(stored, optionValues, allowOther));
const wrapRef = useRef<HTMLDivElement>(null);
const listId = useId();
useEffect(() => {
setMenuValue(selectControlValue(stored, optionValues, allowOther, otherValue));
setOtherText(otherTextFromValue(stored, optionValues, allowOther));
}, [stored, allowOther, otherValue, optionValues]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const pick = (next: string) => {
setMenuValue(next);
setOpen(false);
if (next === otherValue) {
onChange(otherText.trim());
} else {
setOtherText('');
onChange(next);
}
};
const handleOtherTextChange = (text: string) => {
setOtherText(text);
if (menuValue === otherValue) {
onChange(text);
}
};
const triggerLabel =
menuValue === otherValue ? otherLabel || 'Other'
: stored ? stored
: placeholder;
const isPlaceholder = !stored && menuValue !== otherValue;
const requiredValue = menuValue === otherValue ? otherText.trim() : stored;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div ref={wrapRef} className={`form-select-wrap${open ? ' form-select-wrap--open' : ''}`}>
<button
type="button"
className="form-input form-select-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
onClick={() => setOpen((v) => !v)}
>
<span className={`form-select-value${isPlaceholder ? ' form-select-value--placeholder' : ''}`}>
{triggerLabel}
</span>
<span className="form-select-chevron" aria-hidden>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</button>
{required ? (
<input
tabIndex={-1}
aria-hidden
className="form-select-native-required"
value={requiredValue}
required
onChange={() => {}}
/>
) : null}
{open ? (
<ul id={listId} className="form-select-menu" role="listbox">
{!required ? (
<li role="option" aria-selected={!stored && menuValue !== otherValue}>
<button
type="button"
className={`form-select-option${!stored && menuValue !== otherValue ? ' form-select-option--active' : ''}`}
onClick={() => pick('')}
>
{placeholder}
</button>
</li>
) : null}
{normalized.map((o) => (
<li key={o.value} role="option" aria-selected={menuValue === o.value}>
<button
type="button"
className={`form-select-option${menuValue === o.value ? ' form-select-option--active' : ''}`}
onClick={() => pick(o.value)}
>
{o.label}
</button>
</li>
))}
{allowOther ? (
<li role="option" aria-selected={menuValue === otherValue}>
<button
type="button"
className={`form-select-option${menuValue === otherValue ? ' form-select-option--active' : ''}`}
onClick={() => pick(otherValue)}
>
{otherLabel || 'Other'}
</button>
</li>
) : null}
</ul>
) : null}
</div>
{allowOther && menuValue === otherValue ? (
<input
type="text"
className="form-input"
value={otherText}
onChange={(e) => handleOtherTextChange(e.target.value)}
placeholder={otherInputPlaceholder}
required={!!required}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,156 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { formsApi } from '../../api/forms';
import type { PublicFormDefinition } from '../../types';
import { PublicFormEventHeader } from './PublicFormEventHeader';
import { DynamicFormFields, buildSubmitData, validateFormFields } from './DynamicFormFields';
import { FormCaptchaField, validateCaptchaAnswer } from './FormCaptchaField';
import { assignUniqueFieldNames } from '../../utils/formFieldNames';
import { ensureRegistrationCoreFields } from '../../utils/registrationFormFields';
type Props = {
eventId: string;
formId: string;
titleOverride?: string;
};
export function FormShell({ eventId, formId, titleOverride }: Props) {
const embed = useMemo(
() => typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('embed') === '1',
[],
);
const [form, setForm] = useState<PublicFormDefinition | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [fileNames, setFileNames] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [captchaToken, setCaptchaToken] = useState('');
const [captchaMode, setCaptchaMode] = useState<'server' | 'local'>('server');
const [localCaptchaAnswer, setLocalCaptchaAnswer] = useState<string | null>(null);
const handleCaptchaModeChange = useCallback((mode: 'server' | 'local', localAnswer: string | null) => {
setCaptchaMode(mode);
setLocalCaptchaAnswer(localAnswer);
}, []);
const load = useCallback(async () => {
if (!eventId || !formId) return;
setLoadError(null);
try {
const f = await formsApi.getEventForm(eventId, formId);
const rawFields = f.type === 'registration' ? ensureRegistrationCoreFields(f.fields || []) : f.fields || [];
setForm({ ...f, fields: assignUniqueFieldNames(rawFields) });
} catch (e) {
setLoadError(e instanceof Error ? e.message : 'Could not load form.');
}
}, [eventId, formId]);
useEffect(() => {
void load();
}, [load]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formId || !form) return;
setSubmitError(null);
const err = validateFormFields(form.fields || [], values);
if (err) {
setSubmitError(err);
return;
}
if (!captchaAnswer.trim()) {
setSubmitError('Please solve the captcha.');
return;
}
const captchaErr = validateCaptchaAnswer(captchaMode, captchaAnswer, captchaToken, localCaptchaAnswer);
if (captchaErr) {
setSubmitError(captchaErr);
return;
}
setSubmitting(true);
try {
await formsApi.submit(formId, {
data: buildSubmitData(form.fields || [], values),
...(captchaMode === 'server' && captchaToken
? { captchaToken, captchaAnswer: captchaAnswer.trim() }
: {}),
});
setDone(true);
} catch (e) {
setSubmitError(e instanceof Error ? e.message : 'Submit failed.');
setCaptchaAnswer('');
} finally {
setSubmitting(false);
}
};
if (loadError) {
return (
<div className="form-page-center">
<p style={{ margin: 0 }}>{loadError}</p>
</div>
);
}
if (!form) {
return (
<div className="form-page-center">
<p style={{ margin: 0, color: '#64748b' }}>Loading…</p>
</div>
);
}
if (done) {
const msg = form.settings?.thankYouMessage?.trim() || 'Thank you for your response.';
const ev = form.event;
return (
<div className={embed ? 'form-page-center form-page-center--embed' : 'form-page-center'}>
<div className="form-panel" style={{ maxWidth: 520 }}>
{ev ? <PublicFormEventHeader event={ev} variant="embedded" /> : null}
<h1 className="form-title" style={{ fontSize: '1.2rem' }}>
{titleOverride || form.name}
</h1>
<p style={{ margin: 0, fontSize: '0.95rem', lineHeight: 1.5 }}>{msg}</p>
</div>
</div>
);
}
const ev = form.event;
const pageTitle = titleOverride || form.name;
return (
<div className={embed ? 'form-page form-page--embed' : 'form-page'}>
<div className="form-card">
{ev ? <PublicFormEventHeader event={ev} /> : null}
<h1 className="form-title" style={{ fontSize: ev ? '1.25rem' : '1.5rem' }}>
{pageTitle}
</h1>
{form.description ? <p className="form-desc">{form.description}</p> : <div style={{ marginBottom: 24 }} />}
<form className="form-panel" onSubmit={(e) => void onSubmit(e)}>
<DynamicFormFields
fields={form.fields || []}
values={values}
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…' : 'Submit'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react';
import flatpickr from 'flatpickr';
import type { Instance } from 'flatpickr/dist/types/instance';
import 'flatpickr/dist/flatpickr.min.css';
type Props = {
id: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
placeholder?: string;
};
export function FormTimePicker({ id, value, onChange, required, placeholder = 'Select time' }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const fpRef = useRef<Instance | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
const el = inputRef.current;
if (!el) return;
fpRef.current = flatpickr(el, {
enableTime: true,
noCalendar: true,
dateFormat: 'H:i',
altInput: true,
altFormat: 'h:i K',
altInputClass: 'form-input form-picker-input form-picker-input--time',
time_24hr: false,
defaultDate: value || undefined,
disableMobile: true,
onChange: (_dates, timeStr) => {
onChangeRef.current(timeStr);
},
});
return () => {
fpRef.current?.destroy();
fpRef.current = null;
};
}, []);
useEffect(() => {
const fp = fpRef.current;
if (!fp) return;
const current = fp.selectedDates.length ? fp.formatDate(fp.selectedDates[0], 'H:i') : '';
if (current !== value) {
fp.setDate(value || '', false);
}
}, [value]);
return (
<input
ref={inputRef}
id={id}
type="text"
className="form-picker-source"
readOnly
required={required}
placeholder={placeholder}
aria-required={required || undefined}
/>
);
}

View File

@ -0,0 +1,96 @@
import type { PublicFormEventSummary } from '../../types';
const EVENT_TYPE_LABEL: Record<string, string> = {
conference: 'Conference',
workshop: 'Workshop',
summit: 'Summit',
cme: 'CME',
webinar: 'Webinar',
};
function formatEventType(t?: string): string {
if (!t) return '';
return EVENT_TYPE_LABEL[t] || t.replace(/^\w/, (c) => c.toUpperCase());
}
function formatLocationKind(locationType?: string): string {
if (locationType === 'virtual') return 'Virtual';
if (locationType === 'hybrid') return 'Hybrid';
return 'In person';
}
function formatEventDateRange(ev: PublicFormEventSummary): string {
const { startDate, endDate } = ev;
if (!startDate) return '—';
const s = new Date(startDate);
const e = endDate ? new Date(endDate) : s;
if (Number.isNaN(s.getTime())) return '—';
const sameDay = !Number.isNaN(e.getTime()) && s.toDateString() === e.toDateString();
const dOpt: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
if (sameDay) return s.toLocaleDateString(undefined, { weekday: 'short', ...dOpt });
return `${s.toLocaleDateString(undefined, dOpt)} – ${e.toLocaleDateString(undefined, dOpt)}`;
}
function shouldShowLocation(ev: PublicFormEventSummary): boolean {
const loc = ev.location?.trim();
if (!loc) return false;
return ev.locationType === 'physical' || ev.locationType === 'hybrid';
}
function locationKindClass(locationType?: string): string {
if (locationType === 'virtual') return 'form-event-meta-kind--virtual';
if (locationType === 'hybrid') return 'form-event-meta-kind--hybrid';
return 'form-event-meta-kind--physical';
}
export function PublicFormEventHeader({
event,
variant = 'default',
}: {
event: PublicFormEventSummary;
variant?: 'default' | 'embedded';
}) {
const typeLabel = formatEventType(event.eventType);
const dateLine = formatEventDateRange(event);
const kind = formatLocationKind(event.locationType);
const showLoc = shouldShowLocation(event);
const embedded = variant === 'embedded';
return (
<div className={`form-event-header${embedded ? ' form-event-header--embedded' : ''}`}>
{event.imageUrl && !event.logoUrl ? (
<div className="form-event-banner">
<img src={event.imageUrl} alt="" />
</div>
) : null}
{event.logoUrl ? <img src={event.logoUrl} alt={event.name} className="form-event-logo" /> : null}
<div>
<h1 className="form-event-name">{event.name}</h1>
{event.subtitle ? <p className="form-event-subtitle">{event.subtitle}</p> : null}
<div className="form-event-meta">
<span className="form-event-meta-date">{dateLine}</span>
{typeLabel ? (
<>
<span aria-hidden="true" style={{ opacity: 0.5 }}>
·
</span>
<span>{typeLabel}</span>
</>
) : null}
<span aria-hidden="true" style={{ opacity: 0.5 }}>
·
</span>
<span className={`form-event-meta-kind ${locationKindClass(event.locationType)}`}>{kind}</span>
{showLoc ? (
<>
<span aria-hidden="true" style={{ opacity: 0.5 }}>
·
</span>
<span className="form-event-meta-location">{event.location!.trim()}</span>
</>
) : null}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
/** Internal select value when respondent picks the configurable Other option. Never stored in submissions. */
export const SELECT_OTHER_VALUE = '__other__';
export type SelectOptionItem = string | { value: string; label: string };
export function normalizeSelectOptions(options: SelectOptionItem[] | undefined): { value: string; label: string }[] {
return (options ?? []).map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
}
export function isCustomSelectValue(
value: unknown,
optionValues: string[],
allowOther?: boolean,
): boolean {
if (!allowOther) return false;
const stored = String(value ?? '').trim();
if (!stored) return false;
return !optionValues.includes(stored);
}
export function selectControlValue(
value: unknown,
optionValues: string[],
allowOther?: boolean,
otherValue = SELECT_OTHER_VALUE,
): string {
const stored = String(value ?? '');
if (isCustomSelectValue(stored, optionValues, allowOther)) return otherValue;
return stored;
}
export function otherTextFromValue(
value: unknown,
optionValues: string[],
allowOther?: boolean,
): string {
if (!isCustomSelectValue(value, optionValues, allowOther)) return '';
return String(value ?? '');
}

View File

@ -0,0 +1,36 @@
import { useEffect, type CSSProperties, type ReactNode } from 'react';
import { conferenceCssVars, loadConferenceFont } from '../../theme/conferenceTheme';
import type { PublicMicrositePayload } from '../../types';
type Props = {
data: PublicMicrositePayload;
children: ReactNode;
};
export function MicrositeLayout({ data, children }: Props) {
useEffect(() => {
loadConferenceFont(data.website.fontPair);
const title = data.website.title || data.event.name || 'Event';
document.title = title;
if (data.website.favicon) {
let link = document.querySelector<HTMLLinkElement>("link[rel='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = data.website.favicon;
}
}, [data]);
const style = conferenceCssVars(data.website) as CSSProperties;
return (
<div className="eb-microsite-root" style={style}>
{children}
<footer className="eb-static-footer">
Powered by <strong>EventBuzz</strong>
</footer>
</div>
);
}

View File

@ -0,0 +1,567 @@
import { useEffect, useState } from 'react';
import type { AgendaItem, MicrositeSection, MicrositeSpeaker, PublicMicrositePayload } from '../../types';
import { visitorApi } from '../../api/visitor';
type Ctx = {
data: PublicMicrositePayload;
eventId: string;
registrationUrl: string;
};
function str(v: unknown, fallback = ''): string {
return v != null ? String(v).trim() : fallback;
}
function HeaderSection({ section, ctx }: { section: MicrositeSection; ctx: Ctx }) {
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 (
<header className="eb-conference-header fixed-top">
<nav className="navbar navbar-expand-lg navbar-dark">
<div className="container-fluid px-3 px-lg-4" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between' }}>
<a className="navbar-brand" href="#hero" style={{ textDecoration: 'none', color: '#fff' }}>
{logoUrl ? <img src={logoUrl} alt={logoText} className="eb-header-logo" /> : <span>{logoText}</span>}
</a>
<ul className="navbar-nav" style={{ display: 'flex', gap: '0.75rem', listStyle: 'none', margin: 0, padding: 0, flexWrap: 'wrap' }}>
{menuItems.map((item, i) => {
const label = str(item.label);
const url = str(item.url, '#');
const isReg = label.toLowerCase() === 'register';
return (
<li key={i}>
<a
className={isReg ? 'nav-link btn btn-sm btn-outline-light' : 'nav-link'}
href={isReg ? ctx.registrationUrl : url}
style={{ textDecoration: 'none', color: '#fff' }}
>
{label}
</a>
</li>
);
})}
</ul>
</div>
</nav>
</header>
);
}
function BannerSection({ section }: { section: MicrositeSection }) {
const c = section.config;
const headline = str(c.headline, 'Welcome');
const subtext = str(c.subtext);
const heroMetaLine = str(c.heroMetaLine);
const ctaText = str(c.ctaText);
const ctaUrl = str(c.ctaUrl, '#');
const bgImage = str(c.backgroundImage);
const secText = str(c.secondaryCtaText);
const secUrl = str(c.secondaryCtaUrl, '#schedule');
return (
<section id="hero" 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">
<h1 className="eb-hero-heading mb-4">{headline}</h1>
{heroMetaLine ? <div className="eb-hero-meta mb-3">{heroMetaLine}</div> : null}
{subtext ? <p className="eb-hero-intro mb-4">{subtext}</p> : null}
{(ctaText || secText) && (
<div className="eb-hero-cta">
{ctaText ? (
<a className="btn btn-primary btn-lg" href={ctaUrl}>
{ctaText}
</a>
) : null}
{secText ? (
<a className="btn btn-outline-light btn-lg" href={secUrl}>
{secText}
</a>
) : null}
</div>
)}
</div>
</section>
);
}
function CountdownSection({ section, ctx }: { section: MicrositeSection; ctx: Ctx }) {
const headingLine = str(section.config.headingLine);
const start = ctx.data.event.startDate ? new Date(ctx.data.event.startDate).getTime() : NaN;
const [phrase, setPhrase] = useState('…');
useEffect(() => {
const eventName = ctx.data.event.name || 'Event';
const tick = () => {
if (!Number.isFinite(start)) {
setPhrase('');
return;
}
const t = start - Date.now();
if (t <= 0) {
setPhrase(`We're live — welcome to ${eventName}!`);
return;
}
const sec = Math.floor(t / 1000);
const days = Math.floor(sec / 86400);
const hours = Math.floor((sec % 86400) / 3600);
const mins = Math.floor((sec % 3600) / 60);
const toGo = (n: number, unit: string) => (n === 1 ? `1 ${unit} to go` : `${n} ${unit}s to go`);
if (days >= 1) setPhrase(toGo(days, 'day'));
else if (hours >= 1) setPhrase(toGo(hours, 'hour'));
else if (mins >= 1) setPhrase(toGo(mins, 'minute'));
else setPhrase('Starting soon');
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [start, ctx.data.event.name]);
return (
<section className="eb-static-stats py-5 text-center" aria-label="Countdown to event">
<div className="container">
{headingLine ? <p className="eb-countdown-heading small text-uppercase mb-3">{headingLine}</p> : null}
<p className="eb-countdown-phrase mb-0" role="status" aria-live="polite">
{phrase}
</p>
</div>
</section>
);
}
function AboutSection({ section }: { section: MicrositeSection }) {
const c = section.config;
const title = str(c.title, 'About');
const body = str(c.body);
const bullets = Array.isArray(c.bullets) ? (c.bullets as string[]) : [];
const ctaText = str(c.ctaText);
const ctaUrl = str(c.ctaUrl, '#');
return (
<section id="about" className="py-5 eb-section-light">
<div className="container" style={{ maxWidth: 820 }}>
<h2 className="eb-section-heading text-center mb-4">{title}</h2>
{body ? <p style={{ lineHeight: 1.7, color: '#475569' }}>{body}</p> : null}
{bullets.length > 0 ? (
<ul style={{ lineHeight: 1.8, color: '#475569' }}>
{bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
) : null}
{ctaText ? (
<div className="text-center mt-4">
<a className="btn btn-primary btn-lg" href={ctaUrl}>
{ctaText}
</a>
</div>
) : null}
</div>
</section>
);
}
function AgendaSection({ section, items }: { section: MicrositeSection; items: AgendaItem[] }) {
const title = str(section.config.title, 'Schedule');
const list = items.length ? items : [{ title: 'Session 1', time: '9:00 AM', track: 'Main' }];
return (
<section id="schedule" className="eb-schedule-section section py-5">
<div className="container">
<h2 className="eb-section-heading text-center mb-5">{title}</h2>
<div className="eb-schedule-list mx-auto" style={{ maxWidth: 820 }}>
{list.map((item, i) => {
const hasSp = !!str(item.speakerName);
return (
<div key={i} className={`eb-schedule-item py-3 ${hasSp ? 'item-talk' : 'item-other'}`}>
<div className="row align-items-start">
<div className="col-md-3">
<div className="eb-schedule-time">{item.time || ''}</div>
{hasSp && item.speakerPhotoUrl ? (
<img src={item.speakerPhotoUrl} className="rounded-circle mt-2" width={56} height={56} alt="" />
) : null}
{hasSp ? <div className="small text-muted mt-1">{item.speakerName}</div> : null}
</div>
<div className="col-md-9">
<h3 className="eb-schedule-title h5">{item.title || ''}</h3>
{item.track ? <div className="eb-schedule-track text-muted mb-2">{item.track}</div> : null}
{item.description ? <p className="mb-0 text-muted small">{item.description}</p> : null}
</div>
</div>
</div>
);
})}
</div>
<div className="text-center mt-4">
<a className="btn btn-primary btn-lg me-2" href="#register">
Register
</a>
<a className="btn btn-outline-secondary btn-lg" href="#hero">
Back to top
</a>
</div>
</div>
</section>
);
}
function SpeakersSection({ section, speakers }: { section: MicrositeSection; speakers: MicrositeSpeaker[] }) {
const title = str(section.config.title, 'Speakers');
const limit = Number(section.config.limit) || 12;
const list = speakers.slice(0, limit);
return (
<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.length ? (
list.map((s, i) => (
<div key={i} className="col-6 col-lg-3 mb-4">
<div className="card h-100 shadow-sm">
{s.photoUrl ? (
<img src={s.photoUrl} className="card-img-top" alt={s.name} />
) : (
<div className="card-img-top" style={{ background: '#cbd5e1', minHeight: 120 }} />
)}
<div className="card-body">
<h5 className="card-title">{s.name}</h5>
{s.designation ? <p className="card-text text-muted small">{s.designation}</p> : null}
</div>
</div>
</div>
))
) : (
<div className="col-12 text-center text-muted py-4">No speakers added yet.</div>
)}
</div>
</div>
</section>
);
}
function RegisterLinksSection({ section, ctx }: { section: MicrositeSection; ctx: Ctx }) {
const c = section.config;
const title = str(c.title, 'Register');
const primaryLabel = str(c.primaryLabel, 'Register Now');
const primaryUrl = str(c.primaryUrl, ctx.registrationUrl);
const secondaryLabel = str(c.secondaryLabel);
const secondaryUrl = str(c.secondaryUrl, '#');
return (
<section id="register" className="py-5">
<div className="container text-center">
<h2 className="eb-section-heading mb-4">{title}</h2>
<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>
);
}
function ContactFormSection({ section, ctx }: { section: MicrositeSection; ctx: Ctx }) {
const c = section.config;
const title = str(c.title, 'Contact Us');
const submitLabel = str(c.submitLabel, 'Send');
const notifyEmail = str(c.notifyEmail);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState('');
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!notifyEmail) {
setError('Contact form is not configured.');
setStatus('error');
return;
}
setStatus('sending');
setError('');
try {
await visitorApi.submitMicrositeContact({
eventId: ctx.eventId,
notifyEmail,
firstName,
lastName,
email,
message,
_gotcha: '',
});
setStatus('sent');
setFirstName('');
setLastName('');
setEmail('');
setMessage('');
} catch (err) {
setStatus('error');
setError(err instanceof Error ? err.message : 'Could not send message.');
}
};
return (
<section id="contact" className="py-5 eb-section-contact">
<div className="container">
<h2 className="eb-section-heading text-center mb-4">{title}</h2>
<form className="g-3 mx-auto" style={{ maxWidth: 600 }} onSubmit={(e) => void onSubmit(e)}>
<div className="g-3">
<label className="form-label" htmlFor="contact-name">
Name
</label>
<input
id="contact-name"
className="form-control"
value={`${firstName}${lastName ? ` ${lastName}` : ''}`.trim()}
onChange={(e) => {
const parts = e.target.value.split(' ');
setFirstName(parts[0] || '');
setLastName(parts.slice(1).join(' '));
}}
required
/>
</div>
<div className="g-3">
<label className="form-label" htmlFor="contact-email">
Email
</label>
<input id="contact-email" type="email" className="form-control" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<div className="g-3">
<label className="form-label" htmlFor="contact-message">
Message
</label>
<textarea id="contact-message" className="form-control" rows={4} value={message} onChange={(e) => setMessage(e.target.value)} required />
</div>
{status === 'sent' ? <p style={{ color: '#15803d' }}>Message sent. Thank you!</p> : null}
{status === 'error' && error ? <p className="form-error">{error}</p> : null}
<button type="submit" className="btn btn-primary" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : submitLabel}
</button>
</form>
</div>
</section>
);
}
function FooterSection({ section, ctx }: { section: MicrositeSection; ctx: Ctx }) {
const c = section.config;
const copyrightText = str(c.copyrightText, `© ${new Date().getFullYear()} ${ctx.data.event.name}. All rights reserved.`);
const links = (Array.isArray(c.links) ? c.links : []) as { label?: string; url?: string }[];
return (
<footer id="footer" className="py-4 mt-5" style={{ background: 'var(--eb-conf-footer-bg)', color: '#fff' }}>
<div className="container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.85 }}>{copyrightText}</span>
<div>
{links.map((link, i) => (
<a key={i} href={str(link.url, '#')} style={{ color: 'rgba(255,255,255,0.8)', marginRight: '1rem' }}>
{str(link.label)}
</a>
))}
</div>
</div>
</footer>
);
}
function SponsorsSection({ section }: { section: MicrositeSection }) {
const c = section.config;
const title = str(c.title, 'Sponsors');
const sponsors = (Array.isArray(c.sponsors) ? c.sponsors : []) as { name?: string; logoUrl?: string; url?: string }[];
const cells =
sponsors.length > 0
? sponsors
: Array.from({ length: 6 }, (_, i) => ({ name: `Sponsor ${String.fromCharCode(65 + i)}` }));
return (
<section id="sponsors" className="eb-static-sponsors section py-5">
<div className="container">
<h2 className="eb-section-heading text-center mb-5">{title}</h2>
<div className="row g-3 justify-content-center">
{cells.map((s, i) => (
<div key={i} className="col-6 col-md-4 col-lg-2 text-center">
<a href={str(s.url, '#')} style={{ textDecoration: 'none', color: '#475569' }}>
{s.logoUrl ? <img src={s.logoUrl} alt={str(s.name)} style={{ maxHeight: 48 }} className="img-fluid" /> : str(s.name)}
</a>
</div>
))}
</div>
</div>
</section>
);
}
function CtaSection({ section, ctx }: { section: MicrositeSection; ctx: Ctx }) {
const c = section.config;
const headline = str(c.headline, 'Join us');
const subtext = str(c.subtext);
const buttonLabel = str(c.buttonLabel, 'Register');
const buttonUrl = str(c.buttonUrl, ctx.registrationUrl);
return (
<section id="cta" className="py-5 text-white text-center" style={{ background: 'var(--eb-conf-primary)' }}>
<div className="container">
<h2 className="eb-section-heading text-white mb-3">{headline}</h2>
{subtext ? <p className="lead mb-4">{subtext}</p> : null}
<a className="btn btn-light btn-lg" href={buttonUrl}>
{buttonLabel}
</a>
</div>
</section>
);
}
function VenueSection({ section }: { section: MicrositeSection }) {
const c = section.config;
const title = str(c.title, 'Venue');
const body = str(c.body);
const mapEmbedUrl = str(c.mapEmbedUrl);
return (
<section id="venue" className="py-5 eb-section-light">
<div className="container">
<h2 className="eb-section-heading text-center mb-4">{title}</h2>
{body ? <p style={{ maxWidth: 720, margin: '0 auto 1.5rem', lineHeight: 1.7 }}>{body}</p> : null}
{mapEmbedUrl ? (
<div style={{ maxWidth: 900, margin: '0 auto' }}>
<iframe title="Venue map" src={mapEmbedUrl} width="100%" height="360" style={{ border: 0, borderRadius: 8 }} loading="lazy" />
</div>
) : null}
</div>
</section>
);
}
function FaqSection({ section }: { section: MicrositeSection }) {
const c = section.config;
const title = str(c.title, 'FAQ');
const items = (Array.isArray(c.items) ? c.items : []) as { question?: string; answer?: string }[];
return (
<section id="faq" className="py-5">
<div className="container" style={{ maxWidth: 760 }}>
<h2 className="eb-section-heading text-center mb-4">{title}</h2>
{items.map((item, i) => (
<details key={i} style={{ marginBottom: '1rem', borderBottom: '1px solid #e2e8f0', paddingBottom: '0.75rem' }}>
<summary style={{ fontWeight: 600, cursor: 'pointer' }}>{str(item.question)}</summary>
<p style={{ marginTop: '0.5rem', color: '#475569', lineHeight: 1.6 }}>{str(item.answer)}</p>
</details>
))}
</div>
</section>
);
}
function GallerySection({ section }: { section: MicrositeSection }) {
const c = section.config;
const title = str(c.title, 'Gallery');
const images = (Array.isArray(c.images) ? c.images : []) as string[];
return (
<section id="gallery" className="py-5 eb-section-light">
<div className="container">
<h2 className="eb-section-heading text-center mb-4">{title}</h2>
<div className="row">
{images.length ? (
images.map((src, i) => (
<div key={i} className="col-6 col-md-4 mb-3">
<img src={src} alt="" style={{ width: '100%', borderRadius: 8, objectFit: 'cover', aspectRatio: '4/3' }} />
</div>
))
) : (
<div className="col-12 text-center text-muted">No gallery images yet.</div>
)}
</div>
</div>
</section>
);
}
function CustomHtmlSection({ section }: { section: MicrositeSection }) {
const html = str(section.config.html);
if (!html) return null;
return (
<section id="custom-html" className="py-5 eb-section-light">
<div className="container">
<div className="custom-section" dangerouslySetInnerHTML={{ __html: html }} />
</div>
</section>
);
}
function TicketsSection({ section, ctx }: { section: MicrositeSection; ctx: Ctx }) {
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 }[];
return (
<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">{tier.description}</p> : null}
<a className="btn btn-primary" href={ctx.registrationUrl}>
Register
</a>
</div>
</div>
))}
</div>
</div>
</section>
);
}
export function SectionRenderer({ section, ctx }: { section: MicrositeSection; ctx: Ctx }) {
switch (section.type) {
case 'header':
return <HeaderSection section={section} ctx={ctx} />;
case 'banner':
return <BannerSection section={section} />;
case 'countdown':
return <CountdownSection section={section} ctx={ctx} />;
case 'about':
return <AboutSection section={section} />;
case 'agenda':
return <AgendaSection section={section} items={ctx.data.agenda} />;
case 'speakers':
return <SpeakersSection section={section} speakers={ctx.data.speakers} />;
case 'register_links':
return <RegisterLinksSection section={section} ctx={ctx} />;
case 'contact_form':
return <ContactFormSection section={section} ctx={ctx} />;
case 'footer':
return <FooterSection section={section} ctx={ctx} />;
case 'sponsors':
return <SponsorsSection section={section} />;
case 'cta':
return <CtaSection section={section} ctx={ctx} />;
case 'venue':
return <VenueSection section={section} />;
case 'faq':
return <FaqSection section={section} />;
case 'gallery':
return <GallerySection section={section} />;
case 'custom_html':
return <CustomHtmlSection section={section} />;
case 'tickets':
return <TicketsSection section={section} ctx={ctx} />;
default:
return null;
}
}

File diff suppressed because one or more lines are too long

13
src/data/countryStates.ts Normal file
View File

@ -0,0 +1,13 @@
import countriesData from './country-states.json';
export type CountryStatesMap = Record<string, string[]>;
export const COUNTRY_STATES = countriesData as CountryStatesMap;
export const DEFAULT_COUNTRY = 'India';
export const COUNTRY_NAMES = Object.keys(COUNTRY_STATES).sort((a, b) => a.localeCompare(b));
export function statesForCountry(country: string): string[] {
return COUNTRY_STATES[country] ?? [];
}

11
src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/conference.css';
import './styles/forms.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { micrositeApi } from '../api/microsite';
import { PUBLIC_WEB_BASE } from '../api/client';
import type { PublicMicrositePayload } from '../types';
import { MicrositeLayout } from '../components/microsite/MicrositeLayout';
import { SectionRenderer } from '../components/microsite/SectionRenderer';
export function EventMicrositePage() {
const { eventId = '' } = useParams();
const [data, setData] = useState<PublicMicrositePayload | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!eventId) return;
setError(null);
try {
const payload = await micrositeApi.get(eventId);
setData(payload);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not load event microsite.');
}
}, [eventId]);
useEffect(() => {
void load();
}, [load]);
if (error) {
return (
<div className="form-page-center">
<p>{error}</p>
</div>
);
}
if (!data) {
return (
<div className="form-page-center">
<p style={{ color: '#64748b' }}>Loading…</p>
</div>
);
}
const registrationUrl = data.registrationUrl || `${PUBLIC_WEB_BASE}/event/${eventId}/register`;
const ctx = { data, eventId, registrationUrl };
if (data.micrositeHtml) {
return (
<MicrositeLayout data={data}>
<div dangerouslySetInnerHTML={{ __html: data.micrositeHtml }} />
</MicrositeLayout>
);
}
const sections = [...(data.sections || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return (
<MicrositeLayout data={data}>
{sections.map((section) => (
<SectionRenderer key={section.id || section.type} section={section} ctx={ctx} />
))}
</MicrositeLayout>
);
}

7
src/pages/FormPage.tsx Normal file
View File

@ -0,0 +1,7 @@
import { useParams } from 'react-router-dom';
import { FormShell } from '../components/forms/FormShell';
export function FormPage() {
const { eventId = '', formId = '' } = useParams();
return <FormShell eventId={eventId} formId={formId} />;
}

View File

@ -0,0 +1,12 @@
import { Link } from 'react-router-dom';
export function NotFoundPage() {
return (
<div className="form-page-center">
<div style={{ textAlign: 'center' }}>
<h1 style={{ margin: '0 0 8px' }}>Page not found</h1>
<p style={{ color: '#64748b' }}>The page you are looking for does not exist.</p>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { micrositeApi } from '../api/microsite';
import { FormShell } from '../components/forms/FormShell';
export function RegisterPage() {
const { eventId = '' } = useParams();
const [formId, setFormId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!eventId) return;
setError(null);
try {
const info = await micrositeApi.getRegistration(eventId);
setFormId(info.formId);
} catch (e) {
setError(e instanceof Error ? e.message : 'Registration is not available for this event.');
}
}, [eventId]);
useEffect(() => {
void load();
}, [load]);
if (error) {
return (
<div className="form-page-center">
<p>{error}</p>
</div>
);
}
if (!formId) {
return (
<div className="form-page-center">
<p style={{ color: '#64748b' }}>Loading…</p>
</div>
);
}
return <FormShell eventId={eventId} formId={formId} titleOverride="Register" />;
}

354
src/styles/conference.css Normal file
View File

@ -0,0 +1,354 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--eb-conf-body-font, system-ui, sans-serif);
color: #0f172a;
background: #f8fafc;
}
.eb-microsite-root {
min-height: 100vh;
background: #fff;
}
.eb-conference-header {
background: var(--eb-conf-nav-bg);
padding-top: 0.5rem;
}
.eb-conference-header .navbar {
padding: 0.5rem 0;
}
.eb-conference-header .navbar-brand {
color: #fff !important;
font-family: var(--eb-conf-heading-font);
font-weight: 700;
}
.eb-conference-header .nav-link {
color: rgba(255, 255, 255, 0.9) !important;
}
.eb-header-logo {
max-height: 40px;
width: auto;
}
.eb-hero-block {
min-height: 420px;
display: flex;
align-items: center;
justify-content: center;
padding: 6rem 1rem 4rem;
background: var(--eb-conf-hero-gradient);
}
.eb-hero-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
}
.eb-hero-has-image .eb-hero-bg {
opacity: 0.35;
}
.eb-hero-mask {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.55) 100%);
}
.eb-hero-inner {
position: relative;
z-index: 1;
max-width: 820px;
}
.eb-hero-heading {
font-family: var(--eb-conf-heading-font);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 800;
line-height: 1.15;
}
.eb-hero-intro {
font-size: 1.125rem;
opacity: 0.95;
max-width: 640px;
margin-left: auto;
margin-right: auto;
}
.eb-hero-meta {
font-size: 0.95rem;
opacity: 0.9;
}
.eb-hero-cta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.eb-section-heading {
font-family: var(--eb-conf-heading-font);
font-weight: 700;
color: var(--eb-conf-primary-dark);
}
.eb-section-light {
background: #f8fafc;
}
.eb-static-stats {
background: var(--eb-conf-primary-dark);
color: #fff;
}
.eb-countdown-heading {
letter-spacing: 0.08em;
opacity: 0.85;
}
.eb-countdown-phrase {
font-family: var(--eb-conf-heading-font);
font-size: clamp(1.25rem, 3vw, 1.75rem);
font-weight: 700;
}
.eb-schedule-section {
background: #f8fafc;
}
.eb-schedule-time {
font-weight: 700;
color: var(--eb-conf-primary);
font-size: 0.95rem;
}
.eb-schedule-title {
font-family: var(--eb-conf-heading-font);
color: #0f172a;
}
.eb-schedule-item {
border-bottom: 1px solid #e2e8f0;
}
.eb-speakers-section .card {
border: none;
box-shadow: 0 2px 12px var(--eb-conf-card-glow);
}
.eb-static-footer {
background: var(--eb-static-footer-bg);
color: rgba(255, 255, 255, 0.85);
padding: 1.5rem 0;
font-size: 0.875rem;
text-align: center;
}
.btn-primary {
background: var(--eb-conf-accent) !important;
border-color: var(--eb-conf-accent) !important;
color: var(--eb-conf-accent-foreground) !important;
}
.btn-primary:hover {
background: var(--eb-conf-accent-hover) !important;
border-color: var(--eb-conf-accent-hover) !important;
}
.btn-outline-light:hover {
color: #0f172a !important;
}
.container {
width: 100%;
max-width: 1140px;
margin: 0 auto;
padding: 0 1rem;
}
.container-fluid {
width: 100%;
padding: 0 1rem;
}
@media (min-width: 992px) {
.px-lg-4 {
padding-left: 1.5rem !important;
padding-right: 1.5rem !important;
}
}
.py-5 {
padding-top: 3rem;
padding-bottom: 3rem;
}
.text-center {
text-align: center;
}
.mb-4 {
margin-bottom: 1.5rem;
}
.mb-5 {
margin-bottom: 3rem;
}
.row {
display: flex;
flex-wrap: wrap;
margin: 0 -0.75rem;
}
.col-6,
.col-md-3,
.col-md-4,
.col-md-9,
.col-md-3,
.col-lg-3 {
padding: 0 0.75rem;
}
.col-6 {
flex: 0 0 50%;
max-width: 50%;
}
@media (min-width: 768px) {
.col-md-3 {
flex: 0 0 25%;
max-width: 25%;
}
.col-md-4 {
flex: 0 0 33.333%;
max-width: 33.333%;
}
.col-md-9 {
flex: 0 0 75%;
max-width: 75%;
}
}
@media (min-width: 992px) {
.col-lg-3 {
flex: 0 0 25%;
max-width: 25%;
}
}
.card {
background: #fff;
border-radius: 0;
overflow: hidden;
}
.card-img-top {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
}
.card-body {
padding: 1rem;
}
.card-title {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
}
.card-text {
margin: 0;
}
.btn {
display: inline-block;
padding: 0.625rem 1.25rem;
border-radius: 0.375rem;
border: 1px solid transparent;
font-weight: 600;
text-decoration: none;
cursor: pointer;
font-size: 1rem;
line-height: 1.5;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1.0625rem;
}
.btn-outline-secondary {
background: transparent;
border-color: #94a3b8;
color: #475569;
}
.btn-outline-light {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.8);
color: #fff;
}
.form-label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
font-size: 0.875rem;
}
.form-control {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
font-size: 1rem;
}
textarea.form-control {
resize: vertical;
}
.g-3 > * {
margin-bottom: 1rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.small {
font-size: 0.875rem;
}
.text-muted {
color: #64748b !important;
}
.rounded-circle {
border-radius: 50%;
}
.align-items-start {
align-items: flex-start;
}
.custom-section :first-child {
margin-top: 0;
}

470
src/styles/forms.css Normal file
View File

@ -0,0 +1,470 @@
.form-page {
min-height: 100vh;
padding: 32px 16px 48px;
background: #f8fafc;
color: #0f172a;
font-family: var(--font-body, system-ui, sans-serif);
}
.form-page--embed {
min-height: 0;
padding: 16px 12px 24px;
background: #fff;
}
.form-page-center--embed {
min-height: 0;
padding: 16px 12px;
background: #fff;
}
.form-page-center {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #f8fafc;
}
.form-card {
max-width: 520px;
width: 100%;
margin: 0 auto;
}
.form-panel {
padding: 24px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.form-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-field-remarks {
margin: 6px 0 0;
font-size: 0.75rem;
line-height: 1.45;
color: #64748b;
font-weight: 400;
text-transform: none;
letter-spacing: normal;
}
.form-input {
width: 100%;
padding: 10px 12px;
font-size: 0.9rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #fff;
color: #0f172a;
box-sizing: border-box;
}
.form-textarea {
min-height: 112px;
resize: vertical;
line-height: 1.5;
}
.form-section-divider {
margin: 4px 0 0;
}
.form-section-rule {
border: none;
border-top: 1px solid #e2e8f0;
margin: 0 0 12px;
}
.form-section-title {
margin: 0;
padding: 0;
font-size: 1rem;
font-weight: 600;
color: #0f3072;
letter-spacing: -0.01em;
line-height: 1.35;
}
/* Flatpickr: hide source input; visible alt input uses .form-picker-input */
.form-picker-source {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.form-picker-input {
width: 100%;
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 40px;
}
.form-picker-input--time {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E");
}
.form-picker-input.form-input:focus {
outline: none;
border-color: #0f3072;
box-shadow: 0 0 0 3px rgba(15, 48, 114, 0.12);
}
/* Custom select — matches .form-input */
.form-select-wrap {
position: relative;
width: 100%;
}
.form-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
text-align: left;
cursor: pointer;
font-family: inherit;
}
.form-select-trigger:focus {
outline: none;
border-color: #0f3072;
box-shadow: 0 0 0 3px rgba(15, 48, 114, 0.12);
}
.form-select-value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0f172a;
}
.form-select-value--placeholder {
color: #94a3b8;
}
.form-select-chevron {
flex-shrink: 0;
color: #64748b;
display: flex;
transition: transform 0.15s ease;
}
.form-select-wrap--open .form-select-chevron {
transform: rotate(180deg);
}
.form-select-menu {
position: absolute;
z-index: 40;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 6px;
list-style: none;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
max-height: 220px;
overflow-y: auto;
}
.form-select-option {
display: block;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: #0f172a;
font-size: 0.9rem;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.form-select-option:hover {
background: #f1f5f9;
}
.form-select-option--active {
background: rgba(15, 48, 114, 0.08);
color: #0f3072;
font-weight: 600;
}
.form-select-native-required {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}
.form-country-state {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-country-state-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-country-state-label {
margin-bottom: 0;
font-size: 0.8125rem;
text-transform: none;
letter-spacing: 0;
}
/* Flatpickr calendar theme */
.flatpickr-calendar {
font-family: var(--font-body, system-ui, sans-serif);
border-radius: 10px;
border: 1px solid #e2e8f0;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.flatpickr-months .flatpickr-month {
color: #0f172a;
}
.flatpickr-current-month .flatpickr-monthDropdown-months,
.flatpickr-current-month input.cur-year {
font-weight: 600;
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange,
.flatpickr-day.selected.inRange,
.flatpickr-day.startRange.inRange,
.flatpickr-day.endRange.inRange {
background: #0f3072;
border-color: #0f3072;
}
.flatpickr-day.selected:hover,
.flatpickr-day.startRange:hover,
.flatpickr-day.endRange:hover {
background: #1e40af;
border-color: #1e40af;
}
.flatpickr-day.today {
border-color: #0f3072;
}
.flatpickr-time input:hover,
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time input:focus,
.flatpickr-time .flatpickr-am-pm:focus {
background: #f1f5f9;
}
.form-submit {
margin-top: 8px;
padding: 12px 20px;
border-radius: 8px;
border: none;
background: #0f3072;
color: #fff;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.form-submit:disabled {
opacity: 0.75;
cursor: not-allowed;
}
.form-error {
margin: 0;
font-size: 0.85rem;
color: #dc2626;
}
.form-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.3;
}
.form-event-header + .form-title {
text-align: center;
}
.form-desc {
margin: 0 0 24px;
color: #64748b;
font-size: 0.9rem;
line-height: 1.5;
}
.form-event-header {
margin-bottom: 28px;
padding-bottom: 22px;
border-bottom: 1px solid #e2e8f0;
text-align: center;
}
.form-event-header--embedded {
margin-bottom: 18px;
padding-bottom: 0;
border-bottom: none;
}
.form-event-banner {
display: flex;
justify-content: center;
margin-bottom: 16px;
border-radius: 10px;
overflow: hidden;
max-height: 120px;
}
.form-event-banner img {
height: auto;
max-height: 120px;
display: block;
}
.form-event-logo {
display: block;
max-height: 120px;
width: auto;
height: auto;
max-width: 100%;
object-fit: contain;
margin: 0 auto 16px;
}
.form-event-name {
font-size: 1.375rem;
font-weight: 600;
margin: 0 0 6px;
line-height: 1.25;
}
.form-event-subtitle {
margin: 0 0 10px;
font-size: 0.95rem;
color: #64748b;
line-height: 1.45;
}
.form-event-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px 10px;
font-size: 0.8125rem;
color: #64748b;
}
.form-event-meta-date {
color: #334155;
font-weight: 500;
}
.form-event-meta-location {
color: #475569;
}
.form-event-meta-kind {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999;
font-weight: 600;
font-size: 0.75rem;
}
.form-event-meta-kind--virtual {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.form-event-meta-kind--hybrid {
background: rgba(147, 51, 234, 0.12);
color: #7e22ce;
}
.form-event-meta-kind--physical {
background: rgba(22, 163, 74, 0.12);
color: #15803d;
}
.form-captcha-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.form-captcha-question {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 96px;
min-height: 44px;
padding: 0 14px;
border-radius: 8px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
font-weight: 700;
color: #0f172a;
}
.form-captcha-input {
max-width: 120px;
}
.form-captcha-reload {
min-width: 44px;
height: 44px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #334155;
cursor: pointer;
}
.form-captcha-reload:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@ -0,0 +1,126 @@
const DEFAULT_PRIMARY = '#5c6ac4';
const DEFAULT_ACCENT = '#00c2d1';
const DEFAULT_FONT_PAIR = 'Montserrat + Roboto';
export const FONT_SPECS: Record<string, { href: string; heading: string; body: string }> = {
'Montserrat + Roboto': {
href: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700;800&family=Roboto:wght@300;400;700&display=swap',
heading: '"Montserrat", system-ui, sans-serif',
body: '"Roboto", system-ui, sans-serif',
},
'Inter + Noto Sans': {
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Noto+Sans:wght@300;400;700&display=swap',
heading: '"Inter", system-ui, sans-serif',
body: '"Noto Sans", system-ui, sans-serif',
},
'Playfair + Source Sans': {
href: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700;800&family=Source+Sans+3:wght@300;400;700&display=swap',
heading: '"Playfair Display", Georgia, serif',
body: '"Source Sans 3", system-ui, sans-serif',
},
'Poppins + Open Sans': {
href: 'https://fonts.googleapis.com/css2?family=Poppins:wght@600;700;800&family=Open+Sans:wght@300;400;700&display=swap',
heading: '"Poppins", system-ui, sans-serif',
body: '"Open Sans", system-ui, sans-serif',
},
'Raleway + Lato': {
href: 'https://fonts.googleapis.com/css2?family=Raleway:wght@600;700;800&family=Lato:wght@300;400;700&display=swap',
heading: '"Raleway", system-ui, sans-serif',
body: '"Lato", system-ui, sans-serif',
},
};
function normalizeHex(input?: string | null): string | null {
if (input == null) return null;
let s = String(input).trim();
if (!s) return null;
if (!s.startsWith('#')) s = `#${s}`;
if (/^#([0-9a-fA-F]{3})$/.test(s)) {
const x = s.slice(1);
s = `#${x[0]}${x[0]}${x[1]}${x[1]}${x[2]}${x[2]}`;
}
if (!/^#([0-9a-fA-F]{6})$/.test(s)) return null;
return s;
}
function hexToRgb(hex: string) {
const n = parseInt(hex.slice(1), 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function rgbToHex(r: number, g: number, b: number) {
const c = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
return `#${c(r)}${c(g)}${c(b)}`;
}
function darken(hex: string, amount: number) {
const rgb = hexToRgb(hex);
const f = 1 - amount;
return rgbToHex(rgb.r * f, rgb.g * f, rgb.b * f);
}
function lighten(hex: string, amount: number) {
const rgb = hexToRgb(hex);
return rgbToHex(rgb.r + (255 - rgb.r) * amount, rgb.g + (255 - rgb.g) * amount, rgb.b + (255 - rgb.b) * amount);
}
function relativeLuminance(hex: string) {
const { r, g, b } = hexToRgb(hex);
const lin = (c: number) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function accentForeground(accentHex: string) {
return relativeLuminance(accentHex) > 0.45 ? '#0d2137' : '#ffffff';
}
function hexToRgba(hex: string, alpha: number) {
const { r, g, b } = hexToRgb(hex);
return `rgba(${r},${g},${b},${alpha})`;
}
export function fontSpec(fontPair?: string) {
const key = (fontPair && fontPair.trim()) || DEFAULT_FONT_PAIR;
return FONT_SPECS[key] || FONT_SPECS[DEFAULT_FONT_PAIR];
}
export function conferenceCssVars(website: { primaryColor?: string; accentColor?: string; fontPair?: string }) {
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 = fontSpec(website.fontPair);
return {
'--eb-conf-primary': primary,
'--eb-conf-primary-dark': primaryDark,
'--eb-conf-accent': accent,
'--eb-conf-accent-hover': accentHover,
'--eb-conf-accent-foreground': accentForeground(accent),
'--eb-conf-hero-gradient': `linear-gradient(135deg, ${gradStop1} 0%, ${gradStop2} 45%, ${primary} 100%)`,
'--eb-conf-heading-font': spec.heading,
'--eb-conf-body-font': spec.body,
'--eb-conf-nav-bg': navBg,
'--eb-conf-footer-bg': footerBg,
'--eb-static-footer-bg': darken(primary, 0.52),
'--eb-conf-card-glow': hexToRgba(primary, 0.28),
} as Record<string, string>;
}
export function loadConferenceFont(fontPair?: string) {
const spec = fontSpec(fontPair);
const id = 'eb-conf-fonts';
if (document.getElementById(id)) return;
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = spec.href;
document.head.appendChild(link);
}

96
src/types/index.ts Normal file
View File

@ -0,0 +1,96 @@
export interface FormField {
label?: string;
/** Unique key for submission data; auto-suffixed when labels duplicate. */
name?: string;
type?: string;
required?: boolean;
options?: string[];
/** Dropdown only: include an Other option; submitted value is the custom text. */
allowOther?: boolean;
otherLabel?: string;
placeholder?: string;
remarks?: string;
mapsTo?: string;
customMapsKey?: string;
isFixed?: boolean;
}
export interface PublicFormEventSummary {
name: string;
subtitle?: string;
eventType?: string;
startDate?: string | null;
endDate?: string | null;
location?: string;
locationType?: string;
logoUrl?: string | null;
imageUrl?: string | null;
}
export interface PublicFormDefinition {
_id: string;
name: string;
description?: string;
type?: string;
event?: PublicFormEventSummary | null;
fields?: FormField[];
settings?: {
thankYouMessage?: string;
showThankYou?: boolean;
collectEmail?: boolean;
allowAnonymous?: boolean;
};
}
export interface MicrositeSection {
id: string;
type: string;
order: number;
config: Record<string, unknown>;
}
export interface MicrositeSpeaker {
name: string;
designation?: string;
photoUrl?: string;
bio?: string;
}
export interface AgendaItem {
title?: string;
time?: string;
track?: string;
description?: string;
speakerName?: string;
speakerPhotoUrl?: string;
}
export interface PublicMicrositePayload {
event: PublicFormEventSummary & { _id: string };
website: {
enabled: boolean;
title: string;
description: string;
logo: string;
banners: string[];
favicon: string;
primaryColor: string;
accentColor: string;
fontPair: string;
layoutTemplate: string;
seoDescription: string;
};
title: string;
sections: MicrositeSection[];
agenda: AgendaItem[];
speakers: MicrositeSpeaker[];
registrationFormId: string | null;
registrationUrl: string;
micrositeHtml?: string;
}
export interface RegistrationInfo {
eventId: string;
formId: string;
registrationUrl: string;
}

View File

@ -0,0 +1,41 @@
import type { FormField } from '../types';
function isNonInputField(field: FormField): boolean {
return field.type === 'section';
}
/** Recompute unique `name` from each field's `label` (display text unchanged). */
export function assignUniqueFieldNames(fields: FormField[]): FormField[] {
const used = new Set<string>();
return fields.map((field) => {
if (isNonInputField(field)) return field;
const base = (field.label || '').trim();
if (!base) return { ...field, name: '' };
if (!used.has(base)) {
used.add(base);
return { ...field, name: base };
}
let n = 1;
while (used.has(`${base}-${n}`)) n += 1;
const name = `${base}-${n}`;
used.add(name);
return { ...field, name };
});
}
export function fieldDataKey(field: FormField): string {
return (field.name || field.label || '').trim();
}
export function fieldDisplayLabel(field: FormField): string {
return (field.label || field.name || '').trim();
}
export function isCountryStateField(field: FormField): boolean {
return field.type === 'country_state';
}
export function countryStateKeys(field: FormField): { country: string; state: string } {
const base = fieldDataKey(field);
return { country: `${base} - Country`, state: `${base} - State` };
}

View File

@ -0,0 +1,14 @@
import type { FormField } from '../types';
const CORE_DEFS: FormField[] = [
{ label: 'First name', name: 'First name', type: 'text', required: true, mapsTo: 'firstName', isFixed: true },
{ label: 'Last name', name: 'Last name', type: 'text', required: true, mapsTo: 'lastName', isFixed: true },
{ label: 'Email', name: 'Email', type: 'email', required: true, mapsTo: 'email', isFixed: true },
];
export function ensureRegistrationCoreFields(fields: FormField[]): FormField[] {
const list = [...fields];
const missing = CORE_DEFS.filter((def) => !list.some((f) => f.mapsTo === def.mapsTo));
if (!missing.length) return list;
return [...missing, ...list];
}

10
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_PUBLIC_WEB_BASE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

26
vite.config.ts Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://localhost:3001').replace(/\/$/, '');
return {
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5175,
proxy: {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
},
},
},
};
});