HubSpotFormEmbed.tsx164 lines · main
1'use client'
2
3import { useEffect, useId, useRef, useState } from 'react'
4
5interface HubSpotFormCreateConfig {
6 portalId: string
7 formId: string
8 region?: string
9 target: string
10 cssClass?: string
11}
12
13declare global {
14 interface Window {
15 hbspt?: {
16 forms: {
17 create: (config: HubSpotFormCreateConfig) => void
18 }
19 }
20 }
21}
22
23const HUBSPOT_SCRIPT_SRC = 'https://js.hsforms.net/forms/embed/v2.js'
24
25let scriptPromise: Promise<void> | null = null
26
27function loadHubSpotScript(): Promise<void> {
28 if (typeof window === 'undefined') {
29 return Promise.reject(new Error('HubSpot script can only load in the browser'))
30 }
31 if (window.hbspt) return Promise.resolve()
32 if (scriptPromise) return scriptPromise
33
34 scriptPromise = new Promise<void>((resolve, reject) => {
35 const existing = document.querySelector<HTMLScriptElement>(
36 `script[src="${HUBSPOT_SCRIPT_SRC}"]`
37 )
38 if (existing) {
39 existing.addEventListener('load', () => resolve(), { once: true })
40 existing.addEventListener('error', () => reject(new Error('HubSpot script failed to load')), {
41 once: true,
42 })
43 return
44 }
45
46 const script = document.createElement('script')
47 script.src = HUBSPOT_SCRIPT_SRC
48 script.async = true
49 script.defer = true
50 script.onload = () => resolve()
51 script.onerror = () => reject(new Error('HubSpot script failed to load'))
52 document.body.appendChild(script)
53 })
54
55 // Reset the cached promise on failure so a retry can re-attempt the load.
56 scriptPromise.catch(() => {
57 scriptPromise = null
58 })
59
60 return scriptPromise
61}
62
63export interface HubSpotFormEmbedProps {
64 /** HubSpot portal (hub) ID. */
65 portalId: string
66 /** HubSpot form GUID. */
67 formId: string
68 /**
69 * HubSpot region — required for EU-hosted portals (e.g. `'eu1'`). Omit for
70 * the default North America region.
71 */
72 region?: string
73 /** Class name applied to the wrapper element. */
74 className?: string
75}
76
77type LoadState = 'loading' | 'ready' | 'error'
78
79/**
80 * Embeds a HubSpot-hosted form by loading their `forms/embed/v2.js` script
81 * and mounting the form into a container managed by this component. Use this
82 * when the form is managed in HubSpot (e.g. with conditional fields, GDPR
83 * notices, or workflow integrations) and you don't want to re-implement that
84 * logic natively.
85 *
86 * Note: HubSpot renders the form inside a same-origin iframe styled by their
87 * own stylesheet. The iframe's appearance is driven by the form's settings in
88 * HubSpot — adjust visual styling there.
89 *
90 * For native-rendered forms with multi-channel fan-out (HubSpot + Customer.io
91 * + Notion), use `MarketingForm` instead.
92 */
93export default function HubSpotFormEmbed({
94 portalId,
95 formId,
96 region,
97 className,
98}: HubSpotFormEmbedProps) {
99 const targetId = `hubspot-form-${useId().replace(/:/g, '-')}`
100 const containerRef = useRef<HTMLDivElement | null>(null)
101 const [state, setState] = useState<LoadState>('loading')
102
103 useEffect(() => {
104 let cancelled = false
105 setState('loading')
106
107 loadHubSpotScript()
108 .then(() => {
109 if (cancelled) return
110 if (!window.hbspt || !containerRef.current) {
111 setState('error')
112 return
113 }
114
115 // Clear any previously rendered form (e.g. on prop change or remount).
116 while (containerRef.current.firstChild) {
117 containerRef.current.removeChild(containerRef.current.firstChild)
118 }
119
120 window.hbspt.forms.create({
121 portalId,
122 formId,
123 ...(region ? { region } : {}),
124 target: `#${targetId}`,
125 })
126 setState('ready')
127 })
128 .catch((error) => {
129 if (cancelled) return
130 console.error('[marketing/hubspot] Failed to initialize HubSpot form embed', error)
131 setState('error')
132 })
133
134 return () => {
135 cancelled = true
136 }
137 }, [portalId, formId, region, targetId])
138
139 return (
140 <div className={className}>
141 <div id={targetId} ref={containerRef} aria-busy={state === 'loading'} />
142
143 {state === 'loading' && (
144 <div
145 className="flex flex-col gap-3 animate-pulse p-6 sm:p-8"
146 role="status"
147 aria-label="Loading form"
148 >
149 <div className="h-10 rounded-md bg-surface-200" />
150 <div className="h-10 rounded-md bg-surface-200" />
151 <div className="h-10 rounded-md bg-surface-200" />
152 <div className="h-24 rounded-md bg-surface-200" />
153 <div className="h-10 rounded-md bg-surface-200 w-1/3" />
154 </div>
155 )}
156
157 {state === 'error' && (
158 <p className="text-sm text-foreground-light p-6 sm:p-8" role="alert">
159 We couldn&apos;t load the form. Please refresh the page or try again later.
160 </p>
161 )}
162 </div>
163 )
164}