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} -
-
- )} -
+
)