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/app/stripe/events/checkout/route.ts b/cms/src/app/stripe/events/checkout/route.ts new file mode 100644 index 0000000..716e340 --- /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/${eventId}/signup/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${webUrl}/events/${eventId}/signup?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..15fb52e 100644 --- a/cms/src/collections/Events.ts +++ b/cms/src/collections/Events.ts @@ -2,6 +2,9 @@ import type { CollectionConfig } from 'payload' export const Events: CollectionConfig = { slug: 'events', + access: { + read: () => true, + }, admin: { useAsTitle: 'title', }, @@ -11,6 +14,11 @@ export const Events: CollectionConfig = { type: 'text', required: true, }, + { + name: 'eventCost', + type: 'number', + required: false, + }, { name: 'date', type: 'date', @@ -25,6 +33,14 @@ export const Events: CollectionConfig = { type: 'upload', relationTo: 'media', }, + { + name: 'signupForm', + type: 'relationship', + relationTo: 'forms', + admin: { + position: 'sidebar', + }, + }, { name: 'isUpcoming', type: 'checkbox', @@ -41,8 +57,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..3def67d 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 @@ -197,9 +201,11 @@ export interface Media { export interface Event { id: number title: string + eventCost?: number | null date: string description?: string | null coverImage?: (number | null) | Media + signupForm?: (number | null) | Form isUpcoming?: boolean | null images?: | { @@ -207,6 +213,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 +460,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 +525,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: | { @@ -425,9 +631,11 @@ export interface MediaSelect { */ export interface EventsSelect { title?: T + eventCost?: T date?: T description?: T coverImage?: T + signupForm?: T isUpcoming?: T images?: | T @@ -435,6 +643,7 @@ export interface EventsSelect { image?: T id?: T } + stripePriceId?: T updatedAt?: T createdAt?: T } @@ -502,6 +711,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 025400e..9badb00 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) @@ -102,6 +105,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) @@ -1447,6 +1453,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} @@ -1481,6 +1494,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==} @@ -5933,6 +5952,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) @@ -6026,6 +6059,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 8c0a943..df7b256 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/api/events/checkout/route.ts b/web/src/app/api/events/checkout/route.ts new file mode 100644 index 0000000..f2cc935 --- /dev/null +++ b/web/src/app/api/events/checkout/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from 'next/server' + +export const POST = async (request: NextRequest) => { + const cmsUrl = process.env.CMS_URL + + if (!cmsUrl) { + return Response.json({ error: 'CMS_URL not configured' }, { status: 500 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid request body' }, { status: 400 }) + } + + let cmsResponse: Response + try { + cmsResponse = await fetch(`${cmsUrl}/stripe/events/checkout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } 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 { + console.error('[api/checkout] CMS returned non-JSON body:', cmsBody) + data = { error: 'Checkout service error. Please try again.' } + } + } + + return Response.json(data, { status: cmsResponse.status }) +} 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/events/[id]/signup/components/EventPaymentStep.tsx b/web/src/app/events/[id]/signup/components/EventPaymentStep.tsx new file mode 100644 index 0000000..85c0aef --- /dev/null +++ b/web/src/app/events/[id]/signup/components/EventPaymentStep.tsx @@ -0,0 +1,33 @@ +import CardSection from '@/components/CardSection' + +interface PaymentStepProps { + onPay: () => void + eventCost: number | undefined + 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/events/[id]/signup/components/EventSignUpForm.tsx b/web/src/app/events/[id]/signup/components/EventSignUpForm.tsx new file mode 100644 index 0000000..7b3d3d5 --- /dev/null +++ b/web/src/app/events/[id]/signup/components/EventSignUpForm.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useState } from 'react' +import { useSearchParams } from 'next/navigation' +import ProgressBar from '@/components/ProgressBar' +import EventPaymentStep from './EventPaymentStep' +import PayloadFormStep from './PayloadFormStep' +import type { PayloadForm } from '@/lib/payload-form' +import { PayloadEvent } from '@/lib/events' + +type EventSignupFormProps = { + form: PayloadForm | null + event: PayloadEvent | null +} + +type SubmissionDataEntry = { + field: string + value: unknown +} + +const EventSignupForm = ({ form, event }: EventSignupFormProps) => { + const searchParams = useSearchParams() + const hasSession = Boolean(searchParams.get('session_id')) + + const [step, setStep] = useState(hasSession ? 1 : 0) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + function handlePendingSubmission(submissionData: SubmissionDataEntry[]) { + if (!form) { + setError('The signup form is not configured in Payload CMS yet.') + return + } + + window.sessionStorage.setItem( + `event-signup-submission:${form.id}`, + JSON.stringify(submissionData), + ) + setStep(1) + } + + const handlePay = async () => { + setError(null) + + setIsLoading(true) + try { + const response = await fetch(`/api/events/checkout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + eventId: event?.id, + customerId: 'cus_UYuqhW1J65hvuZ', + priceId: event?.stripePriceId, + }), + }) + + 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 ( +
+
+
+ {error && ( +
+ {error} +
+ )} + + + {step === 0 && form ? ( + + ) : ( +
+ The signup form is not configured in Payload CMS yet. +
+ )} + + {step === 1 && ( + + )} +
+
+
+ ) +} + +export default EventSignupForm diff --git a/web/src/app/events/[id]/signup/components/FinalizeSubmission.tsx b/web/src/app/events/[id]/signup/components/FinalizeSubmission.tsx new file mode 100644 index 0000000..ed2fa99 --- /dev/null +++ b/web/src/app/events/[id]/signup/components/FinalizeSubmission.tsx @@ -0,0 +1,88 @@ +'use client' + +import { useEffect, useState } from 'react' + +type SubmissionDataEntry = { + field: string + value: unknown +} + +type FinalizeSubmissionProps = { + formId: string | number +} + +type SubmissionStatus = 'loading' | 'success' | 'error' + +export default function FinalizeSubmission({ + formId, +}: FinalizeSubmissionProps) { + const [status, setStatus] = useState('loading') + const [message, setMessage] = useState('Finalizing your signup...') + + useEffect(() => { + const run = async () => { + const storageKey = `event-signup-submission:${formId}` + const storedSubmission = window.sessionStorage.getItem(storageKey) + + if (!storedSubmission) { + setStatus('error') + setMessage( + 'We could not find your saved signup details. Please contact support if your payment went through.', + ) + return + } + + let submissionData: SubmissionDataEntry[] + try { + submissionData = JSON.parse(storedSubmission) as SubmissionDataEntry[] + } catch { + setStatus('error') + setMessage( + 'Your saved signup details were unreadable. Please try again.', + ) + return + } + + try { + const response = await fetch(`/api/forms/${formId}/submit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ submissionData }), + }) + + const result = await response.json().catch(() => null) + + if (!response.ok) { + throw new Error( + result?.error ?? 'Something went wrong. Please try again.', + ) + } + + window.sessionStorage.removeItem(storageKey) + setStatus('success') + setMessage('Your response has been submitted.') + } catch (err) { + setStatus('error') + setMessage( + err instanceof Error + ? err.message + : 'Network error. Please check your connection and try again.', + ) + } + } + + void run() + }, [formId]) + + return ( +
+ {message} +
+ ) +} diff --git a/web/src/app/events/[id]/signup/components/PayloadFormStep.tsx b/web/src/app/events/[id]/signup/components/PayloadFormStep.tsx new file mode 100644 index 0000000..b3f0502 --- /dev/null +++ b/web/src/app/events/[id]/signup/components/PayloadFormStep.tsx @@ -0,0 +1,294 @@ +'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 SubmissionDataEntry = { + field: string + value: unknown +} + +type PayloadFormStepProps = { + form: PayloadForm + onSubmitPending: (submissionData: SubmissionDataEntry[]) => void +} + +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, + onSubmitPending, +}: 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 [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) + + 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 }> + + onSubmitPending(submissionData) + } catch { + setError('Something went wrong. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + 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 ( +
+ +