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