From f156492f5d27d322d86b1ce4ad14d60e16a00746 Mon Sep 17 00:00:00 2001 From: Henry Ly Date: Sat, 16 May 2026 02:43:51 +1200 Subject: [PATCH 1/7] Setup a quick form page --- pnpm-lock.yaml | 14 ++++++ web/package.json | 1 + .../app/forms/components/EventPaymentStep.tsx | 33 ++++++++++++++ .../app/forms/components/EventSignUpForm.tsx | 43 +++++++++++++++++++ .../app/forms/components/GoogleFormStep.tsx | 19 ++++++++ web/src/app/forms/page.tsx | 24 +++++++++++ 6 files changed, 134 insertions(+) create mode 100644 web/src/app/forms/components/EventPaymentStep.tsx create mode 100644 web/src/app/forms/components/EventSignUpForm.tsx create mode 100644 web/src/app/forms/components/GoogleFormStep.tsx create mode 100644 web/src/app/forms/page.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af3f683..94747aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: web: dependencies: + '@react-oauth/google': + specifier: ^0.13.5 + version: 0.13.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.77.4) @@ -1478,6 +1481,12 @@ packages: '@preact/signals-core@1.14.1': resolution: {integrity: sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==} + '@react-oauth/google@0.13.5': + resolution: {integrity: sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@rolldown/pluginutils@1.0.0-beta.11': resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==} @@ -6018,6 +6027,11 @@ snapshots: '@preact/signals-core@1.14.1': {} + '@react-oauth/google@0.13.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@rolldown/pluginutils@1.0.0-beta.11': {} '@rollup/rollup-android-arm-eabi@4.60.3': diff --git a/web/package.json b/web/package.json index d851fe6..022eff7 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "format:check": "prettier --check ." }, "dependencies": { + "@react-oauth/google": "^0.13.5", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" diff --git a/web/src/app/forms/components/EventPaymentStep.tsx b/web/src/app/forms/components/EventPaymentStep.tsx new file mode 100644 index 0000000..59f6ae8 --- /dev/null +++ b/web/src/app/forms/components/EventPaymentStep.tsx @@ -0,0 +1,33 @@ +import CardSection from '@/components/CardSection' + +interface PaymentStepProps { + onPay: () => void + eventCost: number + isLoading: boolean +} + +const PaymentStep = ({ onPay, eventCost, isLoading }: PaymentStepProps) => { + return ( + +
+

+ A ticket to this event costs ${eventCost.toFixed(2)} +

+
+ +

+ Powered by Stripe +

+
+
+
+ ) +} + +export default PaymentStep diff --git a/web/src/app/forms/components/EventSignUpForm.tsx b/web/src/app/forms/components/EventSignUpForm.tsx new file mode 100644 index 0000000..285f1c6 --- /dev/null +++ b/web/src/app/forms/components/EventSignUpForm.tsx @@ -0,0 +1,43 @@ +'use client' +import { useState } from 'react' +import ProgressBar from '@/components/ProgressBar' +// import GoogleFormStep from './GoogleFormStep' +import EventPaymentStep from './EventPaymentStep' + +export default function SignupForm() { + const step = 0 + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + function handlePay() { + setIsLoading(true) + setError(null) + } + + return ( +
+
+
+ {error && ( +
+ {error} +
+ )} + + + + {step === 0 && ( + + )} + {/* {step === 1 && ( + + )} */} +
+
+
+ ) +} diff --git a/web/src/app/forms/components/GoogleFormStep.tsx b/web/src/app/forms/components/GoogleFormStep.tsx new file mode 100644 index 0000000..a31fb04 --- /dev/null +++ b/web/src/app/forms/components/GoogleFormStep.tsx @@ -0,0 +1,19 @@ +import CardSection from '@/components/CardSection' + +const GoogleFormStep = () => { + return ( + +
+ +
+
+ ) +} + +export default GoogleFormStep diff --git a/web/src/app/forms/page.tsx b/web/src/app/forms/page.tsx new file mode 100644 index 0000000..1637633 --- /dev/null +++ b/web/src/app/forms/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from 'react' +import Hero from '@/components/Hero' +import EventSignupForm from './components/EventSignUpForm' + +export default function SignupPage() { + return ( +
+ + + Loading... + + } + > + + +
+ ) +} From 303ecddcfb7b21d62340c368d5234a14aabfd31a Mon Sep 17 00:00:00 2001 From: Henry Ly Date: Sun, 17 May 2026 01:35:38 +1200 Subject: [PATCH 2/7] Form now moves from payment to google form --- .../app/forms/components/EventSignUpForm.tsx | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/web/src/app/forms/components/EventSignUpForm.tsx b/web/src/app/forms/components/EventSignUpForm.tsx index 285f1c6..9ac3002 100644 --- a/web/src/app/forms/components/EventSignUpForm.tsx +++ b/web/src/app/forms/components/EventSignUpForm.tsx @@ -1,17 +1,55 @@ 'use client' + import { useState } from 'react' +import { useSearchParams } from 'next/navigation' import ProgressBar from '@/components/ProgressBar' -// import GoogleFormStep from './GoogleFormStep' +import GoogleFormStep from './GoogleFormStep' import EventPaymentStep from './EventPaymentStep' -export default function SignupForm() { - const step = 0 +const EventSignupForm = () => { + const searchParams = useSearchParams() + const hasSession = Boolean(searchParams.get('session_id')) + + const step = hasSession ? 1 : 0 const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) - function handlePay() { - setIsLoading(true) + const handlePay = async () => { setError(null) + + setIsLoading(true) + try { + const response = await fetch('/api/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'First Last', + email: 'example@email.com', + password: '123456', + phone: '1234567890', + upi: 'upi@example.com', + studentId: 'STUDENT123', + areaOfStudy: 'Computer Science', + yearOfUniversity: '4', + gender: 'male', + ethnicity: 'chinese', + returningMember: true, + }), + }) + + const result = await response.json() + + if (!response.ok || !result.checkoutUrl) { + setError(result.error ?? 'Something went wrong. Please try again.') + return + } + + window.location.href = result.checkoutUrl + } catch { + setError('Network error. Please check your connection and try again.') + } finally { + setIsLoading(false) + } } return ( @@ -33,11 +71,11 @@ export default function SignupForm() { isLoading={isLoading} /> )} - {/* {step === 1 && ( - - )} */} + {step === 1 && } ) } + +export default EventSignupForm From 99b967bbb6543963ebba751eaba95230be171682 Mon Sep 17 00:00:00 2001 From: Henry Ly <160989916+henryhlly@users.noreply.github.com> Date: Sat, 23 May 2026 15:05:36 +1200 Subject: [PATCH 3/7] Added payload form builder plugin --- cms/package.json | 1 + cms/src/app/(payload)/admin/importMap.js | 48 +++- cms/src/payload-types.ts | 352 +++++++++++++++++++++++ cms/src/payload.config.ts | 10 +- pnpm-lock.yaml | 24 ++ 5 files changed, 433 insertions(+), 2 deletions(-) diff --git a/cms/package.json b/cms/package.json index c579831..ed40135 100644 --- a/cms/package.json +++ b/cms/package.json @@ -20,6 +20,7 @@ "dependencies": { "@payloadcms/db-postgres": "3.79.0", "@payloadcms/next": "3.79.0", + "@payloadcms/plugin-form-builder": "3.79.0", "@payloadcms/richtext-lexical": "3.79.0", "@payloadcms/ui": "3.79.0", "cross-env": "^7.0.3", diff --git a/cms/src/app/(payload)/admin/importMap.js b/cms/src/app/(payload)/admin/importMap.js index f410558..d275fa9 100644 --- a/cms/src/app/(payload)/admin/importMap.js +++ b/cms/src/app/(payload)/admin/importMap.js @@ -1,5 +1,51 @@ +import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { - '@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, + "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/cms/src/payload-types.ts b/cms/src/payload-types.ts index c4e3671..17f8176 100644 --- a/cms/src/payload-types.ts +++ b/cms/src/payload-types.ts @@ -74,6 +74,8 @@ export interface Config { sponsors: Sponsor; execs: Exec; members: Member; + forms: Form; + 'form-submissions': FormSubmission; 'payload-kv': PayloadKv; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -87,6 +89,8 @@ export interface Config { sponsors: SponsorsSelect | SponsorsSelect; execs: ExecsSelect | ExecsSelect; members: MembersSelect | MembersSelect; + forms: FormsSelect | FormsSelect; + 'form-submissions': FormSubmissionsSelect | FormSubmissionsSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -275,6 +279,197 @@ export interface Member { password?: string | null; collection: 'members'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "forms". + */ +export interface Form { + id: number; + title: string; + fields?: + | ( + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + defaultValue?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'checkbox'; + } + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'country'; + } + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'email'; + } + | { + message?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + id?: string | null; + blockName?: string | null; + blockType: 'message'; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'number'; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + placeholder?: string | null; + options?: + | { + label: string; + value: string; + id?: string | null; + }[] + | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'select'; + } + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'state'; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'text'; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'textarea'; + } + )[] + | null; + submitButtonLabel?: string | null; + /** + * Choose whether to display an on-page message or redirect to a different page after they submit the form. + */ + confirmationType?: ('message' | 'redirect') | null; + confirmationMessage?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + redirect?: { + url: string; + }; + /** + * Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email. + */ + emails?: + | { + emailTo?: string | null; + cc?: string | null; + bcc?: string | null; + replyTo?: string | null; + emailFrom?: string | null; + subject: string; + /** + * Enter the message that should be sent in this email. + */ + message?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "form-submissions". + */ +export interface FormSubmission { + id: number; + form: number | Form; + submissionData?: + | { + field: string; + value: string; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv". @@ -322,6 +517,14 @@ export interface PayloadLockedDocument { | ({ relationTo: 'members'; value: number | Member; + } | null) + | ({ + relationTo: 'forms'; + value: number | Form; + } | null) + | ({ + relationTo: 'form-submissions'; + value: number | FormSubmission; } | null); globalSlug?: string | null; user: @@ -496,6 +699,155 @@ export interface MembersSelect { expiresAt?: T; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "forms_select". + */ +export interface FormsSelect { + title?: T; + fields?: + | T + | { + checkbox?: + | T + | { + name?: T; + label?: T; + width?: T; + required?: T; + defaultValue?: T; + id?: T; + blockName?: T; + }; + country?: + | T + | { + name?: T; + label?: T; + width?: T; + required?: T; + id?: T; + blockName?: T; + }; + email?: + | T + | { + name?: T; + label?: T; + width?: T; + required?: T; + id?: T; + blockName?: T; + }; + message?: + | T + | { + message?: T; + id?: T; + blockName?: T; + }; + number?: + | T + | { + name?: T; + label?: T; + width?: T; + defaultValue?: T; + required?: T; + id?: T; + blockName?: T; + }; + select?: + | T + | { + name?: T; + label?: T; + width?: T; + defaultValue?: T; + placeholder?: T; + options?: + | T + | { + label?: T; + value?: T; + id?: T; + }; + required?: T; + id?: T; + blockName?: T; + }; + state?: + | T + | { + name?: T; + label?: T; + width?: T; + required?: T; + id?: T; + blockName?: T; + }; + text?: + | T + | { + name?: T; + label?: T; + width?: T; + defaultValue?: T; + required?: T; + id?: T; + blockName?: T; + }; + textarea?: + | T + | { + name?: T; + label?: T; + width?: T; + defaultValue?: T; + required?: T; + id?: T; + blockName?: T; + }; + }; + submitButtonLabel?: T; + confirmationType?: T; + confirmationMessage?: T; + redirect?: + | T + | { + url?: T; + }; + emails?: + | T + | { + emailTo?: T; + cc?: T; + bcc?: T; + replyTo?: T; + emailFrom?: T; + subject?: T; + message?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "form-submissions_select". + */ +export interface FormSubmissionsSelect { + form?: T; + submissionData?: + | T + | { + field?: T; + value?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv_select". diff --git a/cms/src/payload.config.ts b/cms/src/payload.config.ts index 1294e8f..3892a4b 100644 --- a/cms/src/payload.config.ts +++ b/cms/src/payload.config.ts @@ -3,6 +3,7 @@ import { lexicalEditor } from '@payloadcms/richtext-lexical' import path from 'path' import { buildConfig } from 'payload' import { fileURLToPath } from 'url' +import { formBuilderPlugin } from '@payloadcms/plugin-form-builder' import sharp from 'sharp' import { Users } from './collections/Users' @@ -34,5 +35,12 @@ export default buildConfig({ }, }), sharp, - plugins: [], + plugins: [ + formBuilderPlugin({ + fields: { + payment: false, + upload: false, + }, + }), + ], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94747aa..43e3abe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@payloadcms/next': specifier: 3.79.0 version: 3.79.0(@types/react@19.2.9)(graphql@16.14.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) + '@payloadcms/plugin-form-builder': + specifier: 3.79.0 + version: 3.79.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) '@payloadcms/richtext-lexical': specifier: 3.79.0 version: 3.79.0(@faceless-ui/modal@3.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@payloadcms/next@3.79.0(@types/react@19.2.9)(graphql@16.14.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)(yjs@13.6.30) @@ -1447,6 +1450,13 @@ packages: next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.0-canary.10 <17.0.0' payload: 3.79.0 + '@payloadcms/plugin-form-builder@3.79.0': + resolution: {integrity: sha512-epu7DQKHRlxCRvWYktEpQwD9NTqolEZKhXN4OwmVIQbqJbdJ6G0h+uYsYliIwQcoUQ6fuEBhKV6FsvfeWltjvw==} + peerDependencies: + payload: 3.79.0 + react: ^19.0.1 || ^19.1.2 || ^19.2.1 + react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1 + '@payloadcms/richtext-lexical@3.79.0': resolution: {integrity: sha512-SPoF44Lf3SwuQtWn51VsqHHSteIoymeANF+T+iGk6g9ji2QoVMWaYu8g6lp8TjNtGd0oLvYhAuTBnKXMIpCavA==} engines: {node: ^18.20.2 || >=20.9.0} @@ -5934,6 +5944,20 @@ snapshots: - supports-color - typescript + '@payloadcms/plugin-form-builder@3.79.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': + dependencies: + '@payloadcms/ui': 3.79.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) + escape-html: 1.0.3 + payload: 3.79.0(graphql@16.14.0)(typescript@5.7.3) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + transitivePeerDependencies: + - '@types/react' + - monaco-editor + - next + - supports-color + - typescript + '@payloadcms/richtext-lexical@3.79.0(@faceless-ui/modal@3.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@payloadcms/next@3.79.0(@types/react@19.2.9)(graphql@16.14.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)(yjs@13.6.30)': dependencies: '@faceless-ui/modal': 3.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) From e5562dc2195faf4da3cd926c1196d131816cd29c Mon Sep 17 00:00:00 2001 From: Henry Ly Date: Sat, 23 May 2026 18:43:40 +1200 Subject: [PATCH 4/7] Creating an events signup page that uses event id --- cms/src/app/stripe/events/checkout/route.ts | 61 +++ cms/src/collections/Events.ts | 20 +- cms/src/payload-types.ts | 356 ++++++++++++++++++ web/package-lock.json | 120 ++++++ .../app/api/forms/[formId]/submit/route.ts | 63 ++++ .../[id]}/components/EventPaymentStep.tsx | 0 .../[id]}/components/EventSignUpForm.tsx | 16 +- .../[id]/components/PayloadFormStep.tsx | 315 ++++++++++++++++ .../{forms => events/signup/[id]}/page.tsx | 22 +- .../app/events/signup/[id]/success/page.tsx | 38 ++ .../app/forms/components/GoogleFormStep.tsx | 19 - web/src/lib/payload-form.ts | 98 +++++ 12 files changed, 1092 insertions(+), 36 deletions(-) create mode 100644 cms/src/app/stripe/events/checkout/route.ts create mode 100644 web/src/app/api/forms/[formId]/submit/route.ts rename web/src/app/{forms => events/signup/[id]}/components/EventPaymentStep.tsx (100%) rename web/src/app/{forms => events/signup/[id]}/components/EventSignUpForm.tsx (80%) create mode 100644 web/src/app/events/signup/[id]/components/PayloadFormStep.tsx rename web/src/app/{forms => events/signup/[id]}/page.tsx (50%) create mode 100644 web/src/app/events/signup/[id]/success/page.tsx delete mode 100644 web/src/app/forms/components/GoogleFormStep.tsx create mode 100644 web/src/lib/payload-form.ts diff --git a/cms/src/app/stripe/events/checkout/route.ts b/cms/src/app/stripe/events/checkout/route.ts new file mode 100644 index 0000000..ab15680 --- /dev/null +++ b/cms/src/app/stripe/events/checkout/route.ts @@ -0,0 +1,61 @@ +import { NextRequest } from 'next/server' +// import configPromise from '@payload-config' +// import { getPayload } from 'payload' +import Stripe from 'stripe' + +// Generic route for all event payments, takes in stripe customer ID and event price ID +export const POST = async (request: NextRequest) => { + let body: { + eventId?: string + customerId?: string + priceId?: string + } + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { customerId, priceId, eventId } = body + if (!customerId || !priceId) { + return Response.json( + { + error: 'Missing required fields: customerId, priceId', + }, + { status: 400 }, + ) + } + + const stripeSecretKey = process.env.STRIPE_SECRET_KEY + const webUrl = process.env.WEB_URL || 'http://localhost:3000' + + if (!stripeSecretKey || !priceId) { + return Response.json({ error: 'Stripe not configured' }, { status: 500 }) + } + + // const payload = await getPayload({ config: configPromise }) + const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) + + try { + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + customer: customerId, + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${webUrl}/events/signup/${eventId}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${webUrl}/events//signup/${eventId}?cancelled=true`, + metadata: { customerId: String(customerId) }, + }) + + if (!session.url) { + return Response.json( + { error: 'Stripe did not provide a checkout URL for the created session' }, + { status: 502 }, + ) + } + + return Response.json({ checkoutUrl: session.url }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create Stripe checkout session' + return Response.json({ error: message }, { status: 502 }) + } +} diff --git a/cms/src/collections/Events.ts b/cms/src/collections/Events.ts index 332de04..d18e794 100644 --- a/cms/src/collections/Events.ts +++ b/cms/src/collections/Events.ts @@ -25,6 +25,14 @@ export const Events: CollectionConfig = { type: 'upload', relationTo: 'media', }, + { + name: 'signupForm', + type: 'relationship', + relationTo: 'forms', + admin: { + position: 'sidebar', + }, + }, { name: 'isUpcoming', type: 'checkbox', @@ -41,8 +49,14 @@ export const Events: CollectionConfig = { type: 'upload', relationTo: 'media', required: true, - } + }, ], - } - ] + }, + { + name: 'stripePriceId', + type: 'text', + defaultValue: '', + required: true, + }, + ], } diff --git a/cms/src/payload-types.ts b/cms/src/payload-types.ts index 0b6d415..045bb8a 100644 --- a/cms/src/payload-types.ts +++ b/cms/src/payload-types.ts @@ -74,6 +74,8 @@ export interface Config { sponsors: Sponsor execs: Exec members: Member + forms: Form + 'form-submissions': FormSubmission 'payload-kv': PayloadKv 'payload-locked-documents': PayloadLockedDocument 'payload-preferences': PayloadPreference @@ -87,6 +89,8 @@ export interface Config { sponsors: SponsorsSelect | SponsorsSelect execs: ExecsSelect | ExecsSelect members: MembersSelect | MembersSelect + forms: FormsSelect | FormsSelect + 'form-submissions': FormSubmissionsSelect | FormSubmissionsSelect 'payload-kv': PayloadKvSelect | PayloadKvSelect 'payload-locked-documents': | PayloadLockedDocumentsSelect @@ -200,6 +204,7 @@ export interface Event { date: string description?: string | null coverImage?: (number | null) | Media + signupForm?: (number | null) | Form isUpcoming?: boolean | null images?: | { @@ -207,6 +212,181 @@ export interface Event { id?: string | null }[] | null + stripePriceId: string + updatedAt: string + createdAt: string +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "forms". + */ +export interface Form { + id: number + title: string + fields?: + | ( + | { + name: string + label?: string | null + width?: number | null + required?: boolean | null + defaultValue?: boolean | null + id?: string | null + blockName?: string | null + blockType: 'checkbox' + } + | { + name: string + label?: string | null + width?: number | null + required?: boolean | null + id?: string | null + blockName?: string | null + blockType: 'country' + } + | { + name: string + label?: string | null + width?: number | null + required?: boolean | null + id?: string | null + blockName?: string | null + blockType: 'email' + } + | { + message?: { + root: { + type: string + children: { + type: any + version: number + [k: string]: unknown + }[] + direction: ('ltr' | 'rtl') | null + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '' + indent: number + version: number + } + [k: string]: unknown + } | null + id?: string | null + blockName?: string | null + blockType: 'message' + } + | { + name: string + label?: string | null + width?: number | null + defaultValue?: number | null + required?: boolean | null + id?: string | null + blockName?: string | null + blockType: 'number' + } + | { + name: string + label?: string | null + width?: number | null + defaultValue?: string | null + placeholder?: string | null + options?: + | { + label: string + value: string + id?: string | null + }[] + | null + required?: boolean | null + id?: string | null + blockName?: string | null + blockType: 'select' + } + | { + name: string + label?: string | null + width?: number | null + required?: boolean | null + id?: string | null + blockName?: string | null + blockType: 'state' + } + | { + name: string + label?: string | null + width?: number | null + defaultValue?: string | null + required?: boolean | null + id?: string | null + blockName?: string | null + blockType: 'text' + } + | { + name: string + label?: string | null + width?: number | null + defaultValue?: string | null + required?: boolean | null + id?: string | null + blockName?: string | null + blockType: 'textarea' + } + )[] + | null + submitButtonLabel?: string | null + /** + * Choose whether to display an on-page message or redirect to a different page after they submit the form. + */ + confirmationType?: ('message' | 'redirect') | null + confirmationMessage?: { + root: { + type: string + children: { + type: any + version: number + [k: string]: unknown + }[] + direction: ('ltr' | 'rtl') | null + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '' + indent: number + version: number + } + [k: string]: unknown + } | null + redirect?: { + url: string + } + /** + * Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email. + */ + emails?: + | { + emailTo?: string | null + cc?: string | null + bcc?: string | null + replyTo?: string | null + emailFrom?: string | null + subject: string + /** + * Enter the message that should be sent in this email. + */ + message?: { + root: { + type: string + children: { + type: any + version: number + [k: string]: unknown + }[] + direction: ('ltr' | 'rtl') | null + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '' + indent: number + version: number + } + [k: string]: unknown + } | null + id?: string | null + }[] + | null updatedAt: string createdAt: string } @@ -279,6 +459,23 @@ export interface Member { password?: string | null collection: 'members' } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "form-submissions". + */ +export interface FormSubmission { + id: number + form: number | Form + submissionData?: + | { + field: string + value: string + id?: string | null + }[] + | null + updatedAt: string + createdAt: string +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv". @@ -327,6 +524,14 @@ export interface PayloadLockedDocument { relationTo: 'members' value: number | Member } | null) + | ({ + relationTo: 'forms' + value: number | Form + } | null) + | ({ + relationTo: 'form-submissions' + value: number | FormSubmission + } | null) globalSlug?: string | null user: | { @@ -428,6 +633,7 @@ export interface EventsSelect { date?: T description?: T coverImage?: T + signupForm?: T isUpcoming?: T images?: | T @@ -435,6 +641,7 @@ export interface EventsSelect { image?: T id?: T } + stripePriceId?: T updatedAt?: T createdAt?: T } @@ -502,6 +709,155 @@ export interface MembersSelect { expiresAt?: T } } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "forms_select". + */ +export interface FormsSelect { + title?: T + fields?: + | T + | { + checkbox?: + | T + | { + name?: T + label?: T + width?: T + required?: T + defaultValue?: T + id?: T + blockName?: T + } + country?: + | T + | { + name?: T + label?: T + width?: T + required?: T + id?: T + blockName?: T + } + email?: + | T + | { + name?: T + label?: T + width?: T + required?: T + id?: T + blockName?: T + } + message?: + | T + | { + message?: T + id?: T + blockName?: T + } + number?: + | T + | { + name?: T + label?: T + width?: T + defaultValue?: T + required?: T + id?: T + blockName?: T + } + select?: + | T + | { + name?: T + label?: T + width?: T + defaultValue?: T + placeholder?: T + options?: + | T + | { + label?: T + value?: T + id?: T + } + required?: T + id?: T + blockName?: T + } + state?: + | T + | { + name?: T + label?: T + width?: T + required?: T + id?: T + blockName?: T + } + text?: + | T + | { + name?: T + label?: T + width?: T + defaultValue?: T + required?: T + id?: T + blockName?: T + } + textarea?: + | T + | { + name?: T + label?: T + width?: T + defaultValue?: T + required?: T + id?: T + blockName?: T + } + } + submitButtonLabel?: T + confirmationType?: T + confirmationMessage?: T + redirect?: + | T + | { + url?: T + } + emails?: + | T + | { + emailTo?: T + cc?: T + bcc?: T + replyTo?: T + emailFrom?: T + subject?: T + message?: T + id?: T + } + updatedAt?: T + createdAt?: T +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "form-submissions_select". + */ +export interface FormSubmissionsSelect { + form?: T + submissionData?: + | T + | { + field?: T + value?: T + id?: T + } + updatedAt?: T + createdAt?: T +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv_select". diff --git a/web/package-lock.json b/web/package-lock.json index 36af562..cf24158 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -654,6 +654,126 @@ "node_modules/typescript": { "resolved": "../node_modules/.pnpm/typescript@5.7.3/node_modules/typescript", "link": true + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/web/src/app/api/forms/[formId]/submit/route.ts b/web/src/app/api/forms/[formId]/submit/route.ts new file mode 100644 index 0000000..c52f522 --- /dev/null +++ b/web/src/app/api/forms/[formId]/submit/route.ts @@ -0,0 +1,63 @@ +import { NextRequest } from 'next/server' + +type SubmissionDataEntry = { + field: string + value: unknown +} + +export const POST = async ( + request: NextRequest, + { params }: { params: Promise<{ formId: string }> }, +) => { + const cmsUrl = process.env.CMS_URL + + if (!cmsUrl) { + return Response.json({ error: 'CMS_URL not configured' }, { status: 500 }) + } + + const { formId } = await params + + let body: { submissionData?: SubmissionDataEntry[] } + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid request body' }, { status: 400 }) + } + + if (!Array.isArray(body.submissionData)) { + return Response.json( + { error: 'submissionData is required' }, + { status: 400 }, + ) + } + + let cmsResponse: Response + try { + cmsResponse = await fetch(new URL('/api/form-submissions', cmsUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + form: formId, + submissionData: body.submissionData, + }), + }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to reach CMS' + return Response.json({ error: message }, { status: 502 }) + } + + const cmsBody = await cmsResponse.text() + let data: unknown + + if (!cmsBody) { + data = { error: 'Empty CMS response' } + } else { + try { + data = JSON.parse(cmsBody) + } catch { + data = { error: 'Form submission service error. Please try again.' } + } + } + + return Response.json(data, { status: cmsResponse.status }) +} diff --git a/web/src/app/forms/components/EventPaymentStep.tsx b/web/src/app/events/signup/[id]/components/EventPaymentStep.tsx similarity index 100% rename from web/src/app/forms/components/EventPaymentStep.tsx rename to web/src/app/events/signup/[id]/components/EventPaymentStep.tsx diff --git a/web/src/app/forms/components/EventSignUpForm.tsx b/web/src/app/events/signup/[id]/components/EventSignUpForm.tsx similarity index 80% rename from web/src/app/forms/components/EventSignUpForm.tsx rename to web/src/app/events/signup/[id]/components/EventSignUpForm.tsx index 9ac3002..67fc71e 100644 --- a/web/src/app/forms/components/EventSignUpForm.tsx +++ b/web/src/app/events/signup/[id]/components/EventSignUpForm.tsx @@ -3,10 +3,15 @@ import { useState } from 'react' import { useSearchParams } from 'next/navigation' import ProgressBar from '@/components/ProgressBar' -import GoogleFormStep from './GoogleFormStep' import EventPaymentStep from './EventPaymentStep' +import PayloadFormStep from './PayloadFormStep' +import type { PayloadForm } from '@/lib/payload-form' -const EventSignupForm = () => { +type EventSignupFormProps = { + form: PayloadForm | null +} + +const EventSignupForm = ({ form }: EventSignupFormProps) => { const searchParams = useSearchParams() const hasSession = Boolean(searchParams.get('session_id')) @@ -71,7 +76,12 @@ const EventSignupForm = () => { isLoading={isLoading} /> )} - {step === 1 && } + {step === 1 && form && } + {step === 1 && !form && ( +
+ The signup form is not configured in Payload CMS yet. +
+ )} diff --git a/web/src/app/events/signup/[id]/components/PayloadFormStep.tsx b/web/src/app/events/signup/[id]/components/PayloadFormStep.tsx new file mode 100644 index 0000000..2ecca0d --- /dev/null +++ b/web/src/app/events/signup/[id]/components/PayloadFormStep.tsx @@ -0,0 +1,315 @@ +'use client' + +import { useMemo, useState } from 'react' +import CardSection from '@/components/CardSection' +import InputField from '@/components/InputField' +import SelectField from '@/components/SelectField' +import type { PayloadForm, PayloadFormField } from '@/lib/payload-form' + +type FormValues = Record + +type PayloadFormStepProps = { + form: PayloadForm +} + +function getFieldKey(field: PayloadFormField, index: number) { + return field.name || field.blockName || `field-${index}` +} + +function getInitialValue(field: PayloadFormField) { + if (field.blockType === 'checkbox') { + return Boolean(field.defaultValue) + } + + if (typeof field.defaultValue === 'string') { + return field.defaultValue + } + + return '' +} + +function toLabel(field: PayloadFormField, index: number) { + return field.label || field.blockName || `Field ${index + 1}` +} + +function renderMessage(message: unknown) { + if (typeof message === 'string') { + return

{message}

+ } + + return null +} + +export default function PayloadFormStep({ form }: PayloadFormStepProps) { + const initialValues = useMemo(() => { + return form.fields.reduce((accumulator, field, index) => { + if (field.blockType === 'message') { + return accumulator + } + + accumulator[getFieldKey(field, index)] = getInitialValue(field) + return accumulator + }, {}) + }, [form.fields]) + + const [values, setValues] = useState(initialValues) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [fieldErrors, setFieldErrors] = useState>({}) + + const buttonLabel = form.submitButtonLabel || 'Submit' + + function updateValue(key: string, value: string | boolean) { + setValues((current) => ({ ...current, [key]: value })) + setFieldErrors((current) => { + if (!current[key]) { + return current + } + + const next = { ...current } + delete next[key] + return next + }) + } + + function validate() { + const nextErrors: Record = {} + + form.fields.forEach((field, index) => { + if (field.blockType === 'message') { + return + } + + const key = getFieldKey(field, index) + const value = values[key] + + if (field.required) { + const hasValue = + typeof value === 'boolean' + ? value + : String(value ?? '').trim().length > 0 + + if (!hasValue) { + nextErrors[key] = 'This field is required' + } + } + }) + + return nextErrors + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + setError(null) + setSuccess(false) + + const nextErrors = validate() + if (Object.keys(nextErrors).length > 0) { + setFieldErrors(nextErrors) + return + } + + setIsSubmitting(true) + try { + const submissionData = form.fields + .map((field, index) => { + if (field.blockType === 'message') { + return null + } + + const key = getFieldKey(field, index) + return { + field: key, + value: values[key], + } + }) + .filter(Boolean) as Array<{ field: string; value: unknown }> + + const response = await fetch(`/api/forms/${form.id}/submit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ submissionData }), + }) + + const result = await response.json().catch(() => null) + + if (!response.ok) { + setError(result?.error ?? 'Something went wrong. Please try again.') + return + } + + if (form.confirmationType === 'redirect' && form.redirect?.url) { + window.location.assign(form.redirect.url) + return + } + + setSuccess(true) + } catch { + setError('Network error. Please check your connection and try again.') + } finally { + setIsSubmitting(false) + } + } + + if (success) { + return ( + +
+ Your response has been submitted. +
+
+ ) + } + + return ( + +
+ {error && ( +
+ {error} +
+ )} + + {form.fields.map((field, index) => { + if (field.blockType === 'message') { + return ( +
+ {renderMessage(field.message)} +
+ ) + } + + const key = getFieldKey(field, index) + const label = toLabel(field, index) + const value = values[key] + const errorMessage = fieldErrors[key] + + if (field.blockType === 'select') { + return ( + updateValue(key, nextValue)} + options={field.options || []} + error={errorMessage} + /> + ) + } + + if (field.blockType === 'radio') { + return ( +
+ + {label} + {field.required && ( + * + )} + +
+ {(field.options || []).map((option) => ( + + ))} +
+ {errorMessage && ( +

{errorMessage}

+ )} +
+ ) + } + + if (field.blockType === 'checkbox') { + return ( +
+ + {errorMessage && ( +

{errorMessage}

+ )} +
+ ) + } + + if (field.blockType === 'textarea') { + return ( +
+ +