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.
This commit is contained in:
Alan Daniel
2026-05-05 12:25:36 -04:00
parent 2c892acec4
commit 54b358e684
6 changed files with 357 additions and 326 deletions

View File

@@ -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: (
<div className="mx-auto w-full max-w-2xl border border-muted rounded-2xl p-6 sm:p-8">
<HubSpotFormEmbed portalId="19953346" formId="db2718f8-1f23-4fe1-aaab-b4924dc4ca54" />
<div className="mx-auto w-full max-w-2xl">
<MarketingForm
fields={[
{
type: 'text',
name: 'firstName',
label: 'First name',
placeholder: 'First name',
required: true,
half: true,
},
{
type: 'text',
name: 'lastName',
label: 'Last name',
placeholder: 'Last name',
required: true,
half: true,
},
{
type: 'email',
name: 'email',
label: 'Work email',
placeholder: 'you@company.com',
required: true,
},
{
type: 'text',
name: 'company',
label: 'Company',
placeholder: 'Company name',
required: true,
},
{
type: 'text',
name: 'awsAccountId',
label: 'AWS account ID',
placeholder: '12-digit AWS account ID',
required: true,
},
]}
submitLabel="Apply for credits"
successMessage="Thanks! We've received your application and will review it within 5 business days."
disclaimer="By submitting this form, I confirm that I have read and understood the [Privacy Policy](https://supabase.com/privacy)."
crm={{
hubspot: {
formGuid: 'db2718f8-1f23-4fe1-aaab-b4924dc4ca54',
fieldMap: {
firstName: 'firstname',
lastName: 'lastname',
awsAccountId: 'aws_account_id',
},
},
}}
/>
</div>
),
},

View File

@@ -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<void> {
return new Promise((resolve, reject) => {
if (window.hbspt) {
resolve()
return
}
const existing = document.querySelector<HTMLScriptElement>(
`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 <div id={targetId} />
}

View File

@@ -1 +1,2 @@
export * from './src/go'
export * from './src/forms'

View File

@@ -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<typeof formFieldSchema>
export type MarketingFormCrmConfig = z.input<typeof formCrmConfigSchema>
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 (
<Input_Shadcn_
type={field.type}
placeholder={field.placeholder}
required={field.required}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
case 'textarea':
return (
<TextArea_Shadcn_
placeholder={field.placeholder}
required={field.required}
rows={field.rows}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
case 'select':
return (
<Select_Shadcn_ value={value} onValueChange={onChange} required={field.required}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder={field.placeholder} />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{field.options.map((opt) => (
<SelectItem_Shadcn_ key={opt.value} value={opt.value}>
{opt.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
)
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<Record<string, string>>(() =>
Object.fromEntries(fields.map((f) => [f.name, '']))
)
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [errorMessages, setErrorMessages] = useState<string[]>([])
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 (
<div className={className}>
<div
className={
card
? 'border border-muted rounded-2xl p-6 sm:p-8 flex flex-col items-center gap-4 text-center'
: 'flex flex-col items-center gap-4 text-center'
}
>
<p className="text-lg font-medium">Thank you!</p>
<p className="text-foreground-light">
{successMessage ?? "We've received your submission and will be in touch soon."}
</p>
</div>
</div>
)
}
return (
<div className={className}>
{(title || description) && (
<div className="flex flex-col items-center gap-4 text-center text-balance mb-10">
{title && (
<h2 className="text-2xl md:text-3xl lg:text-4xl leading-tight tracking-tight">
{title}
</h2>
)}
{description && <p className="text-foreground-light text-lg max-w-xl">{description}</p>}
</div>
)}
<form
onSubmit={handleSubmit}
className={
card
? 'border border-muted rounded-2xl p-6 sm:p-8 flex flex-col gap-6'
: 'flex flex-col gap-6'
}
>
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className={row.length > 1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined}
>
{row.map((field) => (
<div key={field.name} className="flex flex-col gap-2">
<label className="text-sm text-foreground font-medium">{field.label}</label>
<FormField
field={field}
value={values[field.name] ?? ''}
onChange={(v) => handleChange(field.name, v)}
/>
</div>
))}
</div>
))}
{submitState === 'error' && errorMessages.length > 0 && (
<div className="flex flex-col gap-1">
{errorMessages.map((msg, i) => (
<p key={i} className="text-sm text-destructive">
{msg}
</p>
))}
</div>
)}
<hr className="border-muted" />
<Button
htmlType="submit"
type="primary"
size="large"
block
loading={submitState === 'loading'}
>
{submitLabel}
</Button>
{disclaimer && (
<div className="text-xs text-foreground-lighter leading-relaxed [&_a]:text-brand-link [&_a]:decoration-brand-link">
<ReactMarkdown
components={{
p: ({ children }) => <p>{children}</p>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{disclaimer}
</ReactMarkdown>
</div>
)}
</form>
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { default as MarketingForm } from './MarketingForm'
export type {
MarketingFormCrmConfig,
MarketingFormField,
MarketingFormProps,
} from './MarketingForm'

View File

@@ -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 (
<Input_Shadcn_
type={field.type}
placeholder={field.placeholder}
required={field.required}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
case 'textarea':
return (
<TextArea_Shadcn_
placeholder={field.placeholder}
required={field.required}
rows={field.rows}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
case 'select':
return (
<Select_Shadcn_ value={value} onValueChange={onChange} required={field.required}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder={field.placeholder} />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{field.options.map((opt) => (
<SelectItem_Shadcn_ key={opt.value} value={opt.value}>
{opt.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
)
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<Record<string, string>>(() =>
Object.fromEntries(section.fields.map((f) => [f.name, '']))
)
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [errorMessages, setErrorMessages] = useState<string[]>([])
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 (
<section>
<div className="max-w-2xl mx-auto px-8">
<div className="border border-muted rounded-2xl p-6 sm:p-8 flex flex-col items-center gap-4 text-center">
<p className="text-lg font-medium">Thank you!</p>
<p className="text-foreground-light">
{section.successMessage ??
"We've received your submission and will be in touch soon."}
</p>
</div>
</div>
</section>
)
}
return (
<section>
<div className="max-w-2xl mx-auto px-8">
{(section.title || section.description) && (
<div className="flex flex-col items-center gap-4 text-center text-balance mb-10">
{section.title && (
<h2 className="text-2xl md:text-3xl lg:text-4xl leading-tight tracking-tight">
{section.title}
</h2>
)}
{section.description && (
<p className="text-foreground-light text-lg max-w-xl">{section.description}</p>
)}
</div>
)}
<form
onSubmit={handleSubmit}
className="border border-muted rounded-2xl p-6 sm:p-8 flex flex-col gap-6"
>
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className={row.length > 1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined}
>
{row.map((field) => (
<div key={field.name} className="flex flex-col gap-2">
<label className="text-sm text-foreground font-medium">{field.label}</label>
<FormField
field={field}
value={values[field.name] ?? ''}
onChange={(v) => handleChange(field.name, v)}
/>
</div>
))}
</div>
))}
{submitState === 'error' && errorMessages.length > 0 && (
<div className="flex flex-col gap-1">
{errorMessages.map((msg, i) => (
<p key={i} className="text-sm text-destructive">
{msg}
</p>
))}
</div>
)}
<hr className="border-muted" />
<Button
htmlType="submit"
type="primary"
size="large"
block
loading={submitState === 'loading'}
>
{section.submitLabel}
</Button>
{section.disclaimer && (
<div className="text-xs text-foreground-lighter leading-relaxed [&_a]:text-brand-link [&_a]:decoration-brand-link">
<ReactMarkdown
components={{
p: ({ children }) => <p>{children}</p>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{section.disclaimer}
</ReactMarkdown>
</div>
)}
</form>
<MarketingForm
fields={section.fields}
submitLabel={section.submitLabel}
title={section.title}
description={section.description}
disclaimer={section.disclaimer}
successMessage={section.successMessage}
successRedirect={section.successRedirect}
crm={section.crm}
/>
</div>
</section>
)