Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	src/app/components/dashboard/EventHomeModules.tsx
#	src/app/components/panels/CreateFormPanel.tsx
This commit is contained in:
Ravi Gulhane 2026-07-08 13:53:00 +05:30
commit aed52029c6
6 changed files with 187 additions and 17 deletions

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
# EventBuzz Frontend – copy to .env and adjust
# Backend API base URL (used by services/api/client.ts)
VITE_API_URL=http://localhost:3001/api
# Optional: public base URL for published custom microsites (no trailing slash). Links to {base}/{folder}/index.html
# VITE_MICROSITE_PUBLIC_BASE=https://web.eventbuzz.live
# Public web app for default microsites, forms, and registration (no trailing slash)
# VITE_PUBLIC_WEB_BASE=https://web.eventbuzz.live
# GrapesJS microsite builder (Vite app in /website_builder). Iframe src + postMessage target origin.
VITE_WEBSITE_BUILDER_URL=http://localhost:5173
# Optional: exact origin for postMessage (defaults to origin of VITE_WEBSITE_BUILDER_URL)
# VITE_WEBSITE_BUILDER_ORIGIN=http://localhost:5173
# Optional: Google Sign-In (OAuth Web client ID — must match backend GOOGLE_CLIENT_ID)
# VITE_GOOGLE_CLIENT_ID=
# Firebase Realtime Database (audience questions mirror — same project as backend FIREBASE_*)
# VITE_FIREBASE_API_KEY=
# VITE_FIREBASE_DATABASE_URL=

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
node_modules/
dist/
.vite/
.DS_Store
*.local
# Env (keep .env.example)
.env
.env.*
!.env.example

View File

@ -141,14 +141,16 @@ function moduleButtonStyle(primary = false): React.CSSProperties {
function formDisplayType(apiType: string): string {
if (apiType === "feedback") return "Feedback";
if (apiType === "registration") return "Registration";
if (apiType === "web-check-in") return "Web check-in";
if (apiType === "other") return "Other";
if (apiType === "survey" || apiType === "custom") return "Other";
return apiType.charAt(0).toUpperCase() + apiType.slice(1);
}
function formPanelMode(apiType: string): "feedback" | "registration" | "other" {
function formPanelMode(apiType: string): "feedback" | "registration" | "other" | "web-check-in" {
if (apiType === "feedback") return "feedback";
if (apiType === "registration") return "registration";
if (apiType === "web-check-in") return "web-check-in";
return "other";
}
@ -186,7 +188,7 @@ export function EventHomeModules() {
const [eventForms, setEventForms] = useState<FormWithSubmissionCount[]>([]);
const [formEditTarget, setFormEditTarget] = useState<FormWithSubmissionCount | null>(null);
const [formResponsesTarget, setFormResponsesTarget] = useState<FormWithSubmissionCount | null>(null);
const [newFormMode, setNewFormMode] = useState<"registration" | "feedback" | "other" | null>(null);
const [newFormMode, setNewFormMode] = useState<"registration" | "feedback" | "other" | "web-check-in" | null>(null);
const [copiedFormLinkId, setCopiedFormLinkId] = useState<string | null>(null);
const [pollQuizCounts, setPollQuizCounts] = useState({ polls: 0, quizzes: 0 });
const [pollQuizLoading, setPollQuizLoading] = useState(false);
@ -210,6 +212,9 @@ export function EventHomeModules() {
const [staticMicrositeLoading, setStaticMicrositeLoading] = useState(false);
const [agendaCounts, setAgendaCounts] = useState({ tracks: 0, sessions: 0 });
const [agendaLoading, setAgendaLoading] = useState(false);
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
useEffect(() => {
if (!selectedEventId) {
@ -220,6 +225,8 @@ export function EventHomeModules() {
void eventsApi.get(selectedEventId).then((res) => {
if (cancelled) return;
setEventName(res.data?.name ?? null);
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
});
return () => {
cancelled = true;
@ -492,6 +499,28 @@ export function EventHomeModules() {
: agendaLoading
? "Loading…"
: `${agendaCounts.tracks} track${agendaCounts.tracks === 1 ? "" : "s"} · ${agendaCounts.sessions} session${agendaCounts.sessions === 1 ? "" : "s"}`;
const webCheckInMeta = !selectedEventId
? "Select an event to configure web check-in"
: webCheckInLoading
? "Saving…"
: webCheckInEnabled
? "Enabled"
: "Disabled";
const handleWebCheckInToggle = async (enabled: boolean) => {
if (!selectedEventId) return;
setWebCheckInLoading(true);
try {
const nextSettings = { ...(eventSettings || {}), webCheckInEnabled: enabled };
const res = await eventsApi.update(selectedEventId, { settings: nextSettings });
if (!res.error) {
setEventSettings(nextSettings);
setWebCheckInEnabled(enabled);
}
} finally {
setWebCheckInLoading(false);
}
};
const handleSavePollQuiz = async (
kind: "poll" | "quiz",
@ -835,6 +864,40 @@ export function EventHomeModules() {
</button>
}
/>
<ModuleCard
icon={<Plus size={18} />}
title="Web check-in"
meta={webCheckInMeta}
description="Show or hide accommodation choice for participants"
actions={
<>
<button
type="button"
disabled={actionsDisabled || webCheckInLoading}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
cursor: actionsDisabled || webCheckInLoading ? "not-allowed" : "pointer",
}}
onClick={() => void handleWebCheckInToggle(true)}
>
Enable
</button>
<button
type="button"
disabled={actionsDisabled || webCheckInLoading}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
cursor: actionsDisabled || webCheckInLoading ? "not-allowed" : "pointer",
}}
onClick={() => void handleWebCheckInToggle(false)}
>
Disable
</button>
</>
}
/>
<ModuleCard
icon={<Plus size={18} />}
title="Create New Form"
@ -885,6 +948,18 @@ export function EventHomeModules() {
>
Feedback
</button>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setNewFormMode("web-check-in")}
>
Web check-in
</button>
<button
type="button"
disabled={actionsDisabled}

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
import { X, ChevronDown, ChevronUp, Check } from "lucide-react";
import { participantsApi } from "../../../services/api";
import { eventsApi, participantsApi } from "../../../services/api";
import type { Participant } from "../../../services/api/types";
const IS: React.CSSProperties = {
@ -131,6 +131,7 @@ interface ParticipantForm {
phone: string;
organization: string;
designation: string;
accommodation: "Yes" | "No";
notes: string;
}
@ -141,6 +142,7 @@ const INITIAL: ParticipantForm = {
phone: "",
organization: "",
designation: "",
accommodation: "No",
notes: "",
};
@ -182,6 +184,18 @@ export function AddParticipantPanel({
const [submitting, setSubmitting] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Partial<Record<FieldKey, string>>>({});
const [saveErr, setSaveErr] = useState<string | null>(null);
const [requiresAccommodation, setRequiresAccommodation] = useState(false);
useEffect(() => {
let cancelled = false;
void eventsApi.get(eventId).then((res) => {
if (cancelled) return;
setRequiresAccommodation(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
});
return () => {
cancelled = true;
};
}, [eventId]);
useEffect(() => {
if (!participantId) return;
@ -195,6 +209,7 @@ export function AddParticipantPanel({
phone: d.phone ?? "",
organization: d.organization ?? "",
designation: d.designation ?? "",
accommodation: d.accommodation ? "Yes" : "No",
notes: d.notes ?? "",
});
setPassedSections(new Set([1, 2, 3]));
@ -260,6 +275,7 @@ export function AddParticipantPanel({
phone: form.phone.trim() || undefined,
organization: form.organization.trim() || undefined,
designation: form.designation.trim() || undefined,
accommodation: form.accommodation === "Yes",
notes: form.notes.trim() || undefined,
};
const res = isEdit && participantId ? await participantsApi.update(participantId, payload) : await participantsApi.create(eventId, payload);
@ -412,6 +428,37 @@ export function AddParticipantPanel({
nextLabel="Save participant"
nextDisabled={submitting}
>
{requiresAccommodation ? (
<Field>
<Label text="Accommodation" />
<div style={{ display: "flex", gap: "8px" }}>
{(["Yes", "No"] as const).map((option) => {
const active = form.accommodation === option;
return (
<button
key={option}
type="button"
onClick={() => set("accommodation", option)}
style={{
flex: 1,
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: active ? "1px solid var(--primary)" : "1px solid var(--border)",
background: active ? "color-mix(in srgb, var(--primary) 12%, transparent)" : "var(--card)",
color: active ? "var(--primary)" : "var(--card-foreground)",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
{option}
</button>
);
})}
</div>
</Field>
) : null}
<Field>
<Label text="Notes" />
<textarea style={{ ...IS, resize: "vertical", minHeight: "80px" }} placeholder="Internal notes (optional)" value={form.notes} onChange={(e) => set("notes", e.target.value)} />

View File

@ -1,6 +1,6 @@
/**
* CreateFormPanel — shared by Feedback, Registration, and Other forms.
* Pass mode="feedback" | "registration" | "other", eventId, optional formId, onSuccess.
* CreateFormPanel — shared by Feedback, Registration, Other, and Web check-in forms.
* Pass mode="feedback" | "registration" | "other" | "web-check-in", eventId, optional formId, onSuccess.
*/
import React, { useState, useEffect, useCallback } from "react";
import { Plus, Trash2, GripVertical } from "lucide-react";
@ -11,7 +11,7 @@ import type { Form, FormField, ParticipantListRecord } from "../../../services/a
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
import { DropdownOtherConfigEditor } from "../forms/DropdownOtherConfigEditor";
type FormMode = "feedback" | "registration" | "other";
type FormMode = "feedback" | "registration" | "other" | "web-check-in";
function eventIdFromFormRef(ev: Form["event"] | undefined): string {
if (!ev) return "";
@ -249,6 +249,15 @@ function initialRegQuestions(): Question[] {
];
}
function initialWebCheckInQuestions(): Question[] {
return [
{ id: "wc-core-fn", label: "First name", type: "Text", required: "Yes", mapsTo: "firstName", fixed: true },
{ id: "wc-core-ln", label: "Last name", type: "Text", required: "Yes", mapsTo: "lastName", fixed: true },
{ id: "wc-core-em", label: "Email", type: "Email", required: "Yes", mapsTo: "email", fixed: true },
{ id: "wc-doc", label: "Upload document", type: "File Upload", required: "Yes", fixed: true },
];
}
function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove, dragIndex, onReorder, onLabelCommit }: {
q: Question;
types: string[];
@ -697,14 +706,16 @@ export function CreateFormPanel({
const isFeedback = mode === "feedback";
const isReg = mode === "registration";
const isOther = mode === "other";
const isWebCheckIn = mode === "web-check-in";
const TOTAL = isReg ? 5 : 3;
const qTypes = isReg ? REG_FIELD_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES;
const isFeedbackOrOther = isFeedback || isOther;
const qTypes = isReg || isWebCheckIn ? OTHER_Q_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES;
const isFeedbackOrOther = isFeedback || isOther || isWebCheckIn;
const acc = useAccordion(TOTAL);
const [form, setForm] = useState<FForm>(() => {
const base = initialForm(qTypes, isFeedbackOrOther);
if (isReg) return { ...base, questions: initialRegQuestions() };
if (isWebCheckIn) return { ...base, questions: initialWebCheckInQuestions() };
return base;
});
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
@ -819,7 +830,7 @@ export function CreateFormPanel({
const validateSection = useCallback((f: FForm, n: number): boolean => {
if (n === 1) return f.name.trim().length > 0 && selectedEventId.trim().length > 0;
if (n === 2) {
if (isReg) {
if (isReg || isWebCheckIn) {
const coreOk = f.questions.filter((q) => q.fixed).every((q) => q.label.trim().length > 0);
const extras = f.questions.filter((q) => !q.fixed);
const extrasOk = extras.every((q) => {
@ -839,7 +850,7 @@ export function CreateFormPanel({
if (n === 4 && isReg) return true;
if (n === (isReg ? 5 : 3)) return true;
return true;
}, [isReg, selectedEventId]);
}, [isReg, isWebCheckIn, selectedEventId]);
const tryAdvance = useCallback((n: number) => {
const valid = validateSection(form, n);
@ -848,7 +859,7 @@ export function CreateFormPanel({
}, [form, validateSection, acc]);
const buildPayload = useCallback(() => {
const apiType = isReg ? "registration" : isOther ? "other" : "feedback";
const apiType = isReg ? "registration" : isOther ? "other" : isWebCheckIn ? "web-check-in" : "feedback";
const saveMode: FormMode = apiType;
const uniqueQuestions = assignUniqueFieldNames(form.questions);
const fields = isReg
@ -878,7 +889,7 @@ export function CreateFormPanel({
: {}),
},
};
}, [form, isReg, isOther, isFeedbackOrOther]);
}, [form, isReg, isOther, isWebCheckIn, isFeedbackOrOther]);
const handleSave = useCallback(async () => {
if (!form.name.trim() || !selectedEventId.trim()) {
@ -913,14 +924,16 @@ export function CreateFormPanel({
handleSave();
}, [form, isReg, validateSection, handleSave]);
const breadcrumb = isFeedback ? "Forms & Workflow · Feedback" : isReg ? "Forms & Workflow · Registration" : "Forms & Workflow · Other Forms";
const breadcrumb = isFeedback ? "Forms & Workflow · Feedback" : isReg ? "Forms & Workflow · Registration" : isWebCheckIn ? "Forms & Workflow · Web check-in" : "Forms & Workflow · Other Forms";
const title = formId
? (isFeedback ? "Edit Feedback Form" : isReg ? "Edit Registration Form" : "Edit Form")
: (isFeedback ? "Create Feedback Form" : isReg ? "Create Registration Form" : "Create Other Form");
? (isFeedback ? "Edit Feedback Form" : isReg ? "Edit Registration Form" : isWebCheckIn ? "Edit Web check-in Form" : "Edit Form")
: (isFeedback ? "Create Feedback Form" : isReg ? "Create Registration Form" : isWebCheckIn ? "Create Web check-in Form" : "Create Other Form");
const panelDescription = isFeedback
? "Design a feedback form to collect responses from your attendees."
: isReg
? "Build a registration form; each submission creates or updates a participant."
: isWebCheckIn
? "Create a web check-in form with the required personal details and document upload fields."
: "Build custom forms (other / general-purpose) for your event.";
if (loading) {
@ -988,7 +1001,7 @@ export function CreateFormPanel({
<Label text="Description" />
<textarea style={{ ...IS, resize: "vertical", minHeight: "72px" }} placeholder="Briefly describe this form…" value={form.description} onChange={e => set("description", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
{isFeedbackOrOther && (
{isFeedbackOrOther && !isWebCheckIn && (
<Grid cols={2}>
<Field>
<Label text="Anonymous Responses" required />
@ -1014,6 +1027,9 @@ export function CreateFormPanel({
nextLabel={isReg ? "Participant actions" : "Settings"}
sectionError={sectionErrors[2]}
>
{isWebCheckIn ? (
<HelperText text="This form starts with First name, Last name, Email, and Upload document fields, and you can add more questions below." />
) : null}
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{isReg ? (
<>
@ -1041,7 +1057,7 @@ export function CreateFormPanel({
<Plus size={13} /> Add {isFeedbackOrOther ? "Question" : "Field"}
</button>
)}
<HelperText text={isReg ? "Core fields stay at the top. Drag extra fields by the grip icon to reorder." : "Drag the grip icon to reorder questions."} />
<HelperText text={isReg ? "Core fields stay at the top. Drag extra fields by the grip icon to reorder." : isWebCheckIn ? "The required core fields are locked in place; add more questions only if needed." : "Drag the grip icon to reorder questions."} />
</AccordionSection>
{isReg && (

View File

@ -403,6 +403,7 @@ export interface Participant {
checkedIn?: boolean;
checkedInAt?: string;
notes?: string;
accommodation?: boolean;
tags?: string[];
tenantOrganization?: string;
eventMemberships?: unknown[];