From 54b358e6843f97e65fb6f98f64e2dc200048528d Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Tue, 5 May 2026 12:25:36 -0400 Subject: [PATCH] feat(marketing): add reusable MarketingForm with multi-channel fan-out Extract form rendering from FormSection into a standalone MarketingForm component exported from the marketing package. The form fans out submissions to HubSpot, Customer.io, and Notion in parallel via the existing submitFormAction. Replace the legacy HubSpotFormEmbed iframe on the AWS Activate page with the new form, and delete the old embed. --- apps/www/_go/lead-gen/aws-activate-offer.tsx | 60 +++- .../lead-gen/components/HubSpotFormEmbed.tsx | 92 ------ packages/marketing/index.ts | 1 + .../marketing/src/forms/MarketingForm.tsx | 282 ++++++++++++++++++ packages/marketing/src/forms/index.ts | 6 + .../marketing/src/go/sections/FormSection.tsx | 242 +-------------- 6 files changed, 357 insertions(+), 326 deletions(-) delete mode 100644 apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx create mode 100644 packages/marketing/src/forms/MarketingForm.tsx create mode 100644 packages/marketing/src/forms/index.ts diff --git a/apps/www/_go/lead-gen/aws-activate-offer.tsx b/apps/www/_go/lead-gen/aws-activate-offer.tsx index e00782d749..2c693a6653 100644 --- a/apps/www/_go/lead-gen/aws-activate-offer.tsx +++ b/apps/www/_go/lead-gen/aws-activate-offer.tsx @@ -1,7 +1,6 @@ +import { MarketingForm } from 'marketing' import type { GoPageInput } from 'marketing' -import HubSpotFormEmbed from './components/HubSpotFormEmbed' - const page: GoPageInput = { template: 'lead-gen', slug: 'aws-activate-offer', @@ -110,8 +109,61 @@ const page: GoPageInput = { id: 'form', title: 'Apply for your credits', children: ( -
- +
+
), }, diff --git a/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx b/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx deleted file mode 100644 index 159cb31ebc..0000000000 --- a/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client' - -import { useEffect, useId } from 'react' - -declare global { - interface Window { - hbspt?: { - forms: { - create: (config: { portalId: string; formId: string; target: string }) => void - } - } - } -} - -const HUBSPOT_SCRIPT_SRC = 'https://js.hsforms.net/forms/embed/v2.js' - -function loadHubSpotScript(): Promise { - return new Promise((resolve, reject) => { - if (window.hbspt) { - resolve() - return - } - - const existing = document.querySelector( - `script[src="${HUBSPOT_SCRIPT_SRC}"]` - ) - if (existing) { - existing.addEventListener('load', () => resolve(), { once: true }) - existing.addEventListener( - 'error', - () => reject(new Error('Failed to load HubSpot form script')), - { - once: true, - } - ) - return - } - - const script = document.createElement('script') - script.src = HUBSPOT_SCRIPT_SRC - script.async = true - script.defer = true - script.onload = () => resolve() - script.onerror = () => reject(new Error('Failed to load HubSpot form script')) - document.body.appendChild(script) - }) -} - -export default function HubSpotFormEmbed({ - portalId, - formId, -}: { - portalId: string - formId: string -}) { - const targetId = `hubspot-form-${useId().replace(/:/g, '-')}` - - useEffect(() => { - let cancelled = false - - const mountForm = async () => { - try { - await loadHubSpotScript() - if (cancelled || !window.hbspt) return - - const target = document.getElementById(targetId) - if (!target) return - - // Reset target to avoid duplicate forms on remounts. - while (target.firstChild) { - target.removeChild(target.firstChild) - } - - window.hbspt.forms.create({ - portalId, - formId, - target: `#${targetId}`, - }) - } catch (error) { - console.error('[go/hubspot] Failed to initialize HubSpot form embed', error) - } - } - - mountForm() - - return () => { - cancelled = true - } - }, [formId, portalId, targetId]) - - return
-} diff --git a/packages/marketing/index.ts b/packages/marketing/index.ts index 8f286a4718..7d1cd84017 100644 --- a/packages/marketing/index.ts +++ b/packages/marketing/index.ts @@ -1 +1,2 @@ export * from './src/go' +export * from './src/forms' diff --git a/packages/marketing/src/forms/MarketingForm.tsx b/packages/marketing/src/forms/MarketingForm.tsx new file mode 100644 index 0000000000..3e7a931936 --- /dev/null +++ b/packages/marketing/src/forms/MarketingForm.tsx @@ -0,0 +1,282 @@ +'use client' + +import { useState } from 'react' +import ReactMarkdown from 'react-markdown' +import { + Button, + Input_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + TextArea_Shadcn_, +} from 'ui' +import type { z } from 'zod' + +import { submitFormAction } from '../go/actions/submitForm' +import { formCrmConfigSchema, formFieldSchema } from '../go/schemas' + +/** Input-shape field type — fields with Zod defaults (`half`, `required`) are optional here. */ +export type MarketingFormField = z.input +export type MarketingFormCrmConfig = z.input + +export interface MarketingFormProps { + /** Form fields. The submit handler builds the payload from these by `name`. */ + fields: MarketingFormField[] + /** Submit button label. */ + submitLabel: string + /** Optional title shown above the form. */ + title?: string + /** Optional description shown above the form. */ + description?: string + /** Optional markdown disclaimer shown beneath the submit button. */ + disclaimer?: string + /** Message shown after a successful submission. Ignored when `successRedirect` is set. */ + successMessage?: string + /** URL to redirect the user to after a successful submission. Overrides `successMessage`. */ + successRedirect?: string + /** CRM fan-out config — submits to HubSpot, Customer.io, and/or Notion in parallel. */ + crm?: MarketingFormCrmConfig + /** Wraps the form in a styled card (border + padding). Defaults to `true`. */ + card?: boolean + /** Extra class names applied to the outer wrapper. */ + className?: string +} + +type SubmitState = 'idle' | 'loading' | 'success' | 'error' + +function FormField({ + field, + value, + onChange, +}: { + field: MarketingFormField + value: string + onChange: (value: string) => void +}) { + switch (field.type) { + case 'text': + case 'email': + return ( + onChange(e.target.value)} + /> + ) + case 'textarea': + return ( + onChange(e.target.value)} + /> + ) + case 'select': + return ( + + + + + + {field.options.map((opt) => ( + + {opt.label} + + ))} + + + ) + default: { + const _exhaustive: never = field + return null + } + } +} + +export default function MarketingForm({ + fields, + submitLabel, + title, + description, + disclaimer, + successMessage, + successRedirect, + crm, + card = true, + className, +}: MarketingFormProps) { + const [values, setValues] = useState>(() => + Object.fromEntries(fields.map((f) => [f.name, ''])) + ) + const [submitState, setSubmitState] = useState('idle') + const [errorMessages, setErrorMessages] = useState([]) + + const handleChange = (name: string, value: string) => { + setValues((prev) => ({ ...prev, [name]: value })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!crm) { + if (process.env.NODE_ENV === 'development') { + console.log('[marketing/form] No CRM configured — form values:', values) + } + return + } + + setSubmitState('loading') + setErrorMessages([]) + + const pageUri = typeof window !== 'undefined' ? window.location.href : undefined + const pageName = typeof document !== 'undefined' ? document.title : undefined + + try { + const result = await submitFormAction(crm, values, { pageUri, pageName }) + + if (result.success) { + if (successRedirect) { + window.location.href = successRedirect + } else { + setSubmitState('success') + } + } else { + setSubmitState('error') + setErrorMessages(result.errors) + } + } catch (err: any) { + console.error('[marketing/form] Form submission failed:', err) + setSubmitState('error') + setErrorMessages(['Something went wrong. Please try again.']) + } + } + + // Group fields into rows: half-width fields pair up, full-width fields get their own row + const rows: MarketingFormField[][] = [] + let pendingHalf: MarketingFormField | null = null + + for (const field of fields) { + if (field.half) { + if (pendingHalf) { + rows.push([pendingHalf, field]) + pendingHalf = null + } else { + pendingHalf = field + } + } else { + if (pendingHalf) { + rows.push([pendingHalf]) + pendingHalf = null + } + rows.push([field]) + } + } + if (pendingHalf) { + rows.push([pendingHalf]) + } + + if (submitState === 'success') { + return ( +
+
+

Thank you!

+

+ {successMessage ?? "We've received your submission and will be in touch soon."} +

+
+
+ ) + } + + return ( +
+ {(title || description) && ( +
+ {title && ( +

+ {title} +

+ )} + {description &&

{description}

} +
+ )} +
+ {rows.map((row, rowIndex) => ( +
1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined} + > + {row.map((field) => ( +
+ + handleChange(field.name, v)} + /> +
+ ))} +
+ ))} + + {submitState === 'error' && errorMessages.length > 0 && ( +
+ {errorMessages.map((msg, i) => ( +

+ {msg} +

+ ))} +
+ )} + +
+ + + + {disclaimer && ( +
+

{children}

, + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {disclaimer} +
+
+ )} +
+
+ ) +} diff --git a/packages/marketing/src/forms/index.ts b/packages/marketing/src/forms/index.ts new file mode 100644 index 0000000000..1a358e8719 --- /dev/null +++ b/packages/marketing/src/forms/index.ts @@ -0,0 +1,6 @@ +export { default as MarketingForm } from './MarketingForm' +export type { + MarketingFormCrmConfig, + MarketingFormField, + MarketingFormProps, +} from './MarketingForm' diff --git a/packages/marketing/src/go/sections/FormSection.tsx b/packages/marketing/src/go/sections/FormSection.tsx index 0939d15e86..c4147eb125 100644 --- a/packages/marketing/src/go/sections/FormSection.tsx +++ b/packages/marketing/src/go/sections/FormSection.tsx @@ -1,240 +1,22 @@ 'use client' -import { useState } from 'react' -import ReactMarkdown from 'react-markdown' -import { - Button, - Input_Shadcn_, - Select_Shadcn_, - SelectContent_Shadcn_, - SelectItem_Shadcn_, - SelectTrigger_Shadcn_, - SelectValue_Shadcn_, - TextArea_Shadcn_, -} from 'ui' - -import { submitFormAction } from '../actions/submitForm' -import type { GoFormField, GoFormSection } from '../schemas' - -function FormField({ - field, - value, - onChange, -}: { - field: GoFormField - value: string - onChange: (value: string) => void -}) { - switch (field.type) { - case 'text': - case 'email': - return ( - onChange(e.target.value)} - /> - ) - case 'textarea': - return ( - onChange(e.target.value)} - /> - ) - case 'select': - return ( - - - - - - {field.options.map((opt) => ( - - {opt.label} - - ))} - - - ) - default: { - const _exhaustive: never = field - return null - } - } -} - -type SubmitState = 'idle' | 'loading' | 'success' | 'error' +import MarketingForm from '../../forms/MarketingForm' +import type { GoFormSection } from '../schemas' export default function FormSection({ section }: { section: GoFormSection }) { - const [values, setValues] = useState>(() => - Object.fromEntries(section.fields.map((f) => [f.name, ''])) - ) - const [submitState, setSubmitState] = useState('idle') - const [errorMessages, setErrorMessages] = useState([]) - - const handleChange = (name: string, value: string) => { - setValues((prev) => ({ ...prev, [name]: value })) - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!section.crm) { - if (process.env.NODE_ENV === 'development') { - console.log('[go/form] No CRM configured — form values:', values) - } - return - } - - setSubmitState('loading') - setErrorMessages([]) - - const pageUri = typeof window !== 'undefined' ? window.location.href : undefined - const pageName = typeof document !== 'undefined' ? document.title : undefined - - try { - const result = await submitFormAction(section.crm, values, { pageUri, pageName }) - - if (result.success) { - if (section.successRedirect) { - window.location.href = section.successRedirect - } else { - setSubmitState('success') - } - } else { - setSubmitState('error') - setErrorMessages(result.errors) - } - } catch (err: any) { - // Unexpected client-side error (network failure, server action crash, etc.) - console.error('[go/form] Form submission failed:', err) - setSubmitState('error') - setErrorMessages(['Something went wrong. Please try again.']) - } - } - - // Group fields into rows: half-width fields pair up, full-width fields get their own row - const rows: GoFormField[][] = [] - let pendingHalf: GoFormField | null = null - - for (const field of section.fields) { - if (field.half) { - if (pendingHalf) { - rows.push([pendingHalf, field]) - pendingHalf = null - } else { - pendingHalf = field - } - } else { - if (pendingHalf) { - rows.push([pendingHalf]) - pendingHalf = null - } - rows.push([field]) - } - } - if (pendingHalf) { - rows.push([pendingHalf]) - } - - if (submitState === 'success') { - return ( -
-
-
-

Thank you!

-

- {section.successMessage ?? - "We've received your submission and will be in touch soon."} -

-
-
-
- ) - } - return (
- {(section.title || section.description) && ( -
- {section.title && ( -

- {section.title} -

- )} - {section.description && ( -

{section.description}

- )} -
- )} -
- {rows.map((row, rowIndex) => ( -
1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined} - > - {row.map((field) => ( -
- - handleChange(field.name, v)} - /> -
- ))} -
- ))} - - {submitState === 'error' && errorMessages.length > 0 && ( -
- {errorMessages.map((msg, i) => ( -

- {msg} -

- ))} -
- )} - -
- - - - {section.disclaimer && ( -
-

{children}

, - a: ({ href, children }) => ( - - {children} - - ), - }} - > - {section.disclaimer} -
-
- )} -
+
)