first commit
This commit is contained in:
commit
4bfc64f56d
|
|
@ -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."
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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`);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ?? '');
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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] ?? [];
|
||||
}
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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` };
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Loading…
Reference in New Issue