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