bsmnt 0.6.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/application/add-integration/index.d.ts.map +1 -1
  2. package/dist/application/add-integration/index.js +30 -4
  3. package/dist/application/add-integration/index.js.map +1 -1
  4. package/dist/application/shared/node-version.d.ts +8 -0
  5. package/dist/application/shared/node-version.d.ts.map +1 -0
  6. package/dist/application/shared/node-version.js +31 -0
  7. package/dist/application/shared/node-version.js.map +1 -0
  8. package/dist/application/shared/strip-ansi.d.ts +3 -0
  9. package/dist/application/shared/strip-ansi.d.ts.map +1 -0
  10. package/dist/application/shared/strip-ansi.js +7 -0
  11. package/dist/application/shared/strip-ansi.js.map +1 -0
  12. package/dist/core/create/execute-plan.d.ts +3 -0
  13. package/dist/core/create/execute-plan.d.ts.map +1 -1
  14. package/dist/core/create/execute-plan.js +3 -0
  15. package/dist/core/create/execute-plan.js.map +1 -1
  16. package/dist/domain/cms.d.ts +2 -0
  17. package/dist/domain/cms.d.ts.map +1 -1
  18. package/dist/domain/cms.js +1 -0
  19. package/dist/domain/cms.js.map +1 -1
  20. package/dist/domain/forms.d.ts +2 -2
  21. package/dist/domain/forms.js +1 -1
  22. package/dist/domain/forms.js.map +1 -1
  23. package/dist/domain/integration.d.ts +7 -3
  24. package/dist/domain/integration.d.ts.map +1 -1
  25. package/dist/domain/integration.js.map +1 -1
  26. package/dist/domain/skills.d.ts +26 -8
  27. package/dist/domain/skills.d.ts.map +1 -1
  28. package/dist/domain/skills.js +30 -9
  29. package/dist/domain/skills.js.map +1 -1
  30. package/dist/index.js +132 -16
  31. package/dist/index.js.map +1 -1
  32. package/dist/infrastructure/create/executor.d.ts.map +1 -1
  33. package/dist/infrastructure/create/executor.js +2 -1
  34. package/dist/infrastructure/create/executor.js.map +1 -1
  35. package/dist/infrastructure/create/init-git-repo.d.ts +11 -7
  36. package/dist/infrastructure/create/init-git-repo.d.ts.map +1 -1
  37. package/dist/infrastructure/create/init-git-repo.js +21 -18
  38. package/dist/infrastructure/create/init-git-repo.js.map +1 -1
  39. package/dist/infrastructure/create/sanity-auth.d.ts +11 -0
  40. package/dist/infrastructure/create/sanity-auth.d.ts.map +1 -0
  41. package/dist/infrastructure/create/sanity-auth.js +72 -0
  42. package/dist/infrastructure/create/sanity-auth.js.map +1 -0
  43. package/dist/infrastructure/create/setup-agent.d.ts +4 -3
  44. package/dist/infrastructure/create/setup-agent.d.ts.map +1 -1
  45. package/dist/infrastructure/create/setup-agent.js +51 -30
  46. package/dist/infrastructure/create/setup-agent.js.map +1 -1
  47. package/dist/infrastructure/create/setup-dotenvx.d.ts.map +1 -1
  48. package/dist/infrastructure/create/setup-dotenvx.js +9 -4
  49. package/dist/infrastructure/create/setup-dotenvx.js.map +1 -1
  50. package/dist/infrastructure/create/setup-remote.d.ts +14 -0
  51. package/dist/infrastructure/create/setup-remote.d.ts.map +1 -0
  52. package/dist/infrastructure/create/setup-remote.js +139 -0
  53. package/dist/infrastructure/create/setup-remote.js.map +1 -0
  54. package/dist/infrastructure/create/setup-sanity.d.ts +3 -2
  55. package/dist/infrastructure/create/setup-sanity.d.ts.map +1 -1
  56. package/dist/infrastructure/create/setup-sanity.js +34 -49
  57. package/dist/infrastructure/create/setup-sanity.js.map +1 -1
  58. package/dist/modules/features/env/dotenvx/config.d.ts.map +1 -1
  59. package/dist/modules/features/env/dotenvx/config.js +6 -0
  60. package/dist/modules/features/env/dotenvx/config.js.map +1 -1
  61. package/dist/paths.d.ts +0 -2
  62. package/dist/paths.d.ts.map +1 -1
  63. package/dist/paths.js +0 -2
  64. package/dist/paths.js.map +1 -1
  65. package/package.json +4 -3
  66. package/src/agent-skills/hubspot-pagebuilder-block/SKILL.md +160 -0
  67. package/src/agent-skills/manage-env/SKILL.md +5 -3
  68. package/src/agent-skills/rss-feed/SKILL.md +158 -0
  69. package/src/modules/features/cms/sanity/files/app/blog/[slug]/page.tsx +10 -9
  70. package/src/modules/features/cms/sanity/files/app/blog/page.tsx +5 -5
  71. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/sanity.config.ts +3 -0
  72. package/src/modules/features/cms/sanity/files/lib/scripts/copy-sanity-mcp.ts +6 -6
  73. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-tree-pane.tsx +4 -6
  74. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +3 -0
  75. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +5 -4
  76. package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +17 -26
  77. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/json-ld.tsx +1 -0
  78. package/src/modules/features/env/dotenvx/files/decrypt-env.mjs +12 -12
  79. package/src/modules/features/env/dotenvx/files/push-keys.mjs +9 -9
  80. package/src/modules/features/env/dotenvx/files/save-key.mjs +3 -3
  81. package/src/modules/features/env/dotenvx/files/setup-remote.ts +182 -0
  82. package/src/modules/features/env/dotenvx/files/write-env.mjs +6 -6
  83. package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-block.ts +60 -0
  84. package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-field.ts +30 -0
  85. package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-select-input.tsx +169 -0
  86. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/README.md +114 -0
  87. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/embed.tsx +165 -0
  88. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/form-selection.ts +40 -0
  89. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/form.tsx +29 -0
  90. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/forms.ts +142 -0
  91. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/hubspot-form.css +636 -0
  92. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/skeleton.tsx +24 -0
  93. package/src/templates/next-default/README.md +1 -0
  94. package/src/templates/next-default/biome.json +1 -1
  95. package/src/templates/next-default/components/basement.svg +1 -1
  96. package/src/templates/next-default/knip.json +9 -1
  97. package/src/templates/next-experiments/README.md +1 -0
  98. package/src/templates/next-experiments/biome.json +1 -1
  99. package/src/templates/next-experiments/components/basement.svg +1 -1
  100. package/src/templates/next-experiments/knip.json +9 -1
  101. package/src/templates/next-pagebuilder/README.md +1 -0
  102. package/src/templates/next-pagebuilder/biome.json +1 -1
  103. package/src/templates/next-pagebuilder/knip.json +7 -2
  104. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +1197 -176
  105. package/src/templates/next-webgl/README.md +1 -0
  106. package/src/templates/next-webgl/biome.json +1 -1
  107. package/src/templates/next-webgl/components/basement.svg +1 -1
  108. package/src/templates/next-webgl/knip.json +9 -1
@@ -0,0 +1,169 @@
1
+ "use client"
2
+
3
+ import {
4
+ Autocomplete,
5
+ Box,
6
+ Button,
7
+ Card,
8
+ Flex,
9
+ Spinner,
10
+ Stack,
11
+ Text,
12
+ } from "@sanity/ui"
13
+ import { useCallback, useMemo, useRef, useState } from "react"
14
+ import { PatchEvent, type StringInputProps, set, unset } from "sanity"
15
+ import {
16
+ decodeHubSpotFormSelection,
17
+ getHubSpotFormSelectionLabel,
18
+ } from "../form-selection"
19
+ import { getHubSpotFormOptions, type HubSpotFormOption } from "../forms"
20
+
21
+ type LoadStatus = "idle" | "loading" | "loaded" | "error"
22
+
23
+ function HubSpotFormOptionPreview({ option }: { option: HubSpotFormOption }) {
24
+ const form = decodeHubSpotFormSelection(option.value)
25
+
26
+ return (
27
+ <Card padding={3} style={{ cursor: "pointer" }}>
28
+ <Stack space={2}>
29
+ <Text size={1} weight="medium">
30
+ {form?.name ?? option.label}
31
+ </Text>
32
+ {form ? (
33
+ <Text muted size={1}>
34
+ {form.formId}
35
+ {option.renderRawHtml === false ? " · iframe embed" : ""}
36
+ </Text>
37
+ ) : null}
38
+ </Stack>
39
+ </Card>
40
+ )
41
+ }
42
+
43
+ /**
44
+ * Sanity Studio input that lazy-loads the connected portal's HubSpot forms and
45
+ * stores the chosen one as a single encoded string (see `hubspotFormField`).
46
+ */
47
+ export function HubSpotFormSelectInput(props: StringInputProps) {
48
+ const { value, onChange, readOnly, elementProps } = props
49
+ const { onChange: _ignoreElementOnChange, ...autocompleteElementProps } =
50
+ elementProps
51
+
52
+ const [forms, setForms] = useState<HubSpotFormOption[]>([])
53
+ const [status, setStatus] = useState<LoadStatus>("idle")
54
+ const [error, setError] = useState<string | null>(null)
55
+ const statusRef = useRef<LoadStatus>("idle")
56
+
57
+ const selectedForm = useMemo(() => decodeHubSpotFormSelection(value), [value])
58
+ const isLoading = status === "loading"
59
+
60
+ const loadForms = useCallback(async () => {
61
+ if (statusRef.current === "loading") return
62
+ statusRef.current = "loading"
63
+ setStatus("loading")
64
+ setError(null)
65
+
66
+ try {
67
+ const result = await getHubSpotFormOptions()
68
+ setForms(result.forms)
69
+ setError(result.error ?? null)
70
+ const next: LoadStatus = result.error ? "error" : "loaded"
71
+ statusRef.current = next
72
+ setStatus(next)
73
+ } catch (err) {
74
+ const message =
75
+ err instanceof Error ? err.message : "Unable to load forms."
76
+ setError(message)
77
+ statusRef.current = "error"
78
+ setStatus("error")
79
+ }
80
+ }, [])
81
+
82
+ const loadFormsOnce = useCallback(() => {
83
+ if (statusRef.current === "idle") void loadForms()
84
+ }, [loadForms])
85
+
86
+ // Keep the already-selected form visible while options are still loading.
87
+ const options =
88
+ selectedForm && !forms.some((form) => form.value === value)
89
+ ? [
90
+ {
91
+ label: getHubSpotFormSelectionLabel(selectedForm),
92
+ value: value as string,
93
+ renderRawHtml: null,
94
+ },
95
+ ...forms,
96
+ ]
97
+ : forms
98
+
99
+ return (
100
+ <Stack space={3}>
101
+ <Flex gap={2} align="center">
102
+ <Box flex={1}>
103
+ <Autocomplete
104
+ {...autocompleteElementProps}
105
+ disabled={readOnly || isLoading}
106
+ filterOption={(query, option) =>
107
+ option.label.toLowerCase().includes(query.toLowerCase())
108
+ }
109
+ loading={isLoading}
110
+ openButton
111
+ openOnFocus
112
+ options={options}
113
+ onFocus={loadFormsOnce}
114
+ onMouseDown={loadFormsOnce}
115
+ onQueryChange={(query) => {
116
+ if (query) loadFormsOnce()
117
+ }}
118
+ onSelect={(nextValue) =>
119
+ onChange(PatchEvent.from(nextValue ? set(nextValue) : unset()))
120
+ }
121
+ placeholder={
122
+ isLoading ? "Loading HubSpot forms..." : "Select a HubSpot form"
123
+ }
124
+ popover={{ portal: true, width: "auto" }}
125
+ radius={2}
126
+ renderOption={(option) => (
127
+ <HubSpotFormOptionPreview option={option} />
128
+ )}
129
+ renderValue={(_, option) => {
130
+ const form = option
131
+ ? decodeHubSpotFormSelection(option.value)
132
+ : selectedForm
133
+ return form ? getHubSpotFormSelectionLabel(form) : ""
134
+ }}
135
+ value={value ?? ""}
136
+ />
137
+ </Box>
138
+ <Button
139
+ disabled={readOnly || isLoading}
140
+ mode="ghost"
141
+ onClick={() => void loadForms()}
142
+ text="Refresh"
143
+ type="button"
144
+ />
145
+ </Flex>
146
+
147
+ {isLoading ? (
148
+ <Flex align="center" gap={2}>
149
+ <Spinner muted />
150
+ <Text muted size={1}>
151
+ Fetching forms from HubSpot
152
+ </Text>
153
+ </Flex>
154
+ ) : null}
155
+
156
+ {selectedForm ? (
157
+ <Text muted size={1}>
158
+ Selected form ID: {selectedForm.formId}
159
+ </Text>
160
+ ) : null}
161
+
162
+ {error ? (
163
+ <Text size={1} style={{ color: "var(--card-critical-fg-color)" }}>
164
+ {error}
165
+ </Text>
166
+ ) : null}
167
+ </Stack>
168
+ )
169
+ }
@@ -0,0 +1,114 @@
1
+ # HubSpot Forms integration
2
+
3
+ Agnostic, reusable HubSpot Forms embed. The core (`form.tsx`, `embed.tsx`,
4
+ `forms.ts`, `form-selection.ts`, `hubspot-form.css`) has **no CMS dependency**
5
+ and adds **no runtime packages** — it loads HubSpot's official forms script and
6
+ styles the injected DOM with self-contained, themeable CSS.
7
+
8
+ ## 1. Environment
9
+
10
+ Create a HubSpot **private app** with the `forms` and
11
+ `account-info.security.read` scopes, then add its token to `.env.local`:
12
+
13
+ ```bash
14
+ HUBSPOT_ACCESS_TOKEN=pat-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
15
+ ```
16
+
17
+ The token is only read server-side (in `forms.ts`); it is never exposed to the
18
+ client.
19
+
20
+ ## 2. Render a form
21
+
22
+ If you already know the form, pass `portalId` / `formId` / `region` directly:
23
+
24
+ ```tsx
25
+ import { HubSpotForm } from "@/lib/integrations/hubspot/form"
26
+
27
+ export function Contact() {
28
+ return (
29
+ <HubSpotForm
30
+ portalId="1234567"
31
+ formId="00000000-0000-0000-0000-000000000000"
32
+ region="na1"
33
+ />
34
+ )
35
+ }
36
+ ```
37
+
38
+ `region` is the portal's data-hosting location (`na1`, `eu1`, …).
39
+
40
+ ## 3. Theming
41
+
42
+ By default the form inherits the **basement starter** design tokens, so it
43
+ matches the surrounding site with no extra work:
44
+
45
+ | `--hs-*` token | Maps to (fallback) |
46
+ | --- | --- |
47
+ | `--hs-surface` | `--color-primary` (`#ffffff`) |
48
+ | `--hs-text` / `--hs-text-strong` | `--color-secondary` (`#18181b`) |
49
+ | `--hs-accent` | `--color-contrast` (`#ff4d00`) |
50
+ | `--hs-muted` | `--color-gray-400` (`#858585`) |
51
+ | `--hs-font-sans` | `--font-sans` → `--font-mono` → system |
52
+
53
+ Light/dark follows the host's `[data-theme]` automatically (via
54
+ `--color-primary` / `--color-secondary`). Outside the starter, the fallbacks
55
+ render a clean light theme.
56
+
57
+ Override any token from a parent scope to customize:
58
+
59
+ ```css
60
+ .my-section {
61
+ --hs-accent: #487cff;
62
+ --hs-surface: #0a0a0a;
63
+ --hs-text: #ededed;
64
+ --hs-radius: 8px;
65
+ }
66
+ ```
67
+
68
+ ## 4. Listing forms programmatically
69
+
70
+ `getHubSpotFormOptions()` (from `forms.ts`) returns every published form in the
71
+ portal as `{ label, value, renderRawHtml }`. Each `value` is a JSON-encoded
72
+ selection — decode it with `decodeHubSpotFormSelection` and spread it into
73
+ `<HubSpotForm />`.
74
+
75
+ ## 5. Optional: Sanity Studio form picker
76
+
77
+ When this integration is added to a project that already has Sanity, a
78
+ `sanity/` folder ships alongside the core with a Studio picker:
79
+
80
+ - `sanity/form-field.ts` — a ready-to-use `hubspotFormField` (`defineField`)
81
+ - `sanity/form-select-input.tsx` — the `<Autocomplete>` input it uses
82
+ - `sanity/form-block.ts` — a sample `hubspotFormBlock` document type
83
+
84
+ Wire the field into any schema:
85
+
86
+ ```ts
87
+ import { defineType } from "sanity"
88
+ import { hubspotFormField } from "@/lib/integrations/hubspot/sanity/form-field"
89
+
90
+ export const contactBlock = defineType({
91
+ name: "contactBlock",
92
+ type: "object",
93
+ fields: [hubspotFormField],
94
+ })
95
+ ```
96
+
97
+ Or register the sample block by adding `hubspotFormBlock` to your schema's
98
+ document list, then render it (see the example in `sanity/form-block.ts`).
99
+
100
+ ## 6. Use as a page-builder block
101
+
102
+ In a basement **Sanity page-builder** project, `hubspotFormBlock` can be a
103
+ selectable block. Adding this integration installs the
104
+ **`hubspot-pagebuilder-block`** agent skill — ask your coding agent to "wire the
105
+ HubSpot form into the page builder" and it makes the edits and verifies the
106
+ typecheck. To do it by hand:
107
+
108
+ 1. `schemas/index.ts` — import `hubspotFormBlock`, add it to `schema.types`
109
+ 2. `page-builder-config.ts` — add `"hubspotFormBlock"` to
110
+ `PageBuilderDocumentType` and a `hubspotFormBlockReference` member
111
+ 3. `structure.ts` — add `S.documentTypeListItem("hubspotFormBlock")`
112
+ 4. `components/page-builder/renderer.tsx` — add a `case "hubspotFormBlock"` that
113
+ decodes `block.form` and renders `<HubSpotForm />`
114
+ 5. `bun run sanity:typegen && bun typecheck`
@@ -0,0 +1,165 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState } from "react"
4
+ import { HubSpotFormSkeleton } from "./skeleton"
5
+ import "./hubspot-form.css"
6
+
7
+ const HUBSPOT_FORMS_SCRIPT_SRC = "https://js.hsforms.net/forms/embed/v2.js"
8
+ // A form that hasn't rendered within this window is treated as failed.
9
+ const FORM_READY_TIMEOUT_MS = 12_000
10
+
11
+ let hubspotFormsScriptPromise: Promise<void> | null = null
12
+
13
+ type HubSpotFormCreateOptions = {
14
+ portalId: string
15
+ formId: string
16
+ region: string
17
+ target: string
18
+ cssRequired?: string
19
+ css?: string
20
+ onFormReady?: (form: HTMLFormElement) => void
21
+ }
22
+
23
+ declare global {
24
+ interface Window {
25
+ hbspt?: {
26
+ forms?: {
27
+ create: (options: HubSpotFormCreateOptions) => void
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ const loadHubSpotFormsScript = () => {
34
+ if (typeof window === "undefined") return Promise.resolve()
35
+ if (window.hbspt?.forms) return Promise.resolve()
36
+ if (hubspotFormsScriptPromise) return hubspotFormsScriptPromise
37
+
38
+ hubspotFormsScriptPromise = new Promise((resolve, reject) => {
39
+ // Drop the dead tag on error so a later mount refetches instead of awaiting
40
+ // a `load` that already fired.
41
+ const fail = (script: HTMLScriptElement) => {
42
+ hubspotFormsScriptPromise = null
43
+ script.remove()
44
+ reject(new Error("Failed to load the HubSpot forms script."))
45
+ }
46
+
47
+ // An existing tag means a load is in flight (a finished one set window.hbspt,
48
+ // caught above) — attach to its events.
49
+ const existing = document.querySelector<HTMLScriptElement>(
50
+ `script[src="${HUBSPOT_FORMS_SCRIPT_SRC}"]`
51
+ )
52
+ if (existing) {
53
+ existing.addEventListener("load", () => resolve(), { once: true })
54
+ existing.addEventListener("error", () => fail(existing), { once: true })
55
+ return
56
+ }
57
+
58
+ const script = document.createElement("script")
59
+ script.src = HUBSPOT_FORMS_SCRIPT_SRC
60
+ script.async = true
61
+ script.charset = "utf-8"
62
+ script.onload = () => resolve()
63
+ script.onerror = () => fail(script)
64
+ document.head.appendChild(script)
65
+ })
66
+
67
+ return hubspotFormsScriptPromise
68
+ }
69
+
70
+ type HubSpotFormEmbedProps = {
71
+ portalId: string
72
+ formId: string
73
+ region: string
74
+ }
75
+
76
+ export function HubSpotFormEmbed({
77
+ portalId,
78
+ formId,
79
+ region,
80
+ }: HubSpotFormEmbedProps) {
81
+ // Sanitize useId() (it can contain `:`) for use as a DOM id / selector.
82
+ const containerId = `hubspot-form-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`
83
+ const containerRef = useRef<HTMLDivElement | null>(null)
84
+ const readyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
85
+ const unmountedRef = useRef(false)
86
+ const [hasError, setHasError] = useState(false)
87
+ const [isReady, setIsReady] = useState(false)
88
+
89
+ const createForm = useCallback(() => {
90
+ const target = containerRef.current
91
+ const formsApi = window.hbspt?.forms
92
+ if (!(target && formsApi)) {
93
+ setHasError(true)
94
+ return
95
+ }
96
+
97
+ setIsReady(false)
98
+ target.innerHTML = ""
99
+
100
+ // create() is fire-and-forget: a bad portal/region or archived form never
101
+ // calls onFormReady, so fall back to the error state on timeout.
102
+ if (readyTimeoutRef.current) clearTimeout(readyTimeoutRef.current)
103
+ readyTimeoutRef.current = setTimeout(() => {
104
+ if (unmountedRef.current) return
105
+ target.innerHTML = ""
106
+ setHasError(true)
107
+ }, FORM_READY_TIMEOUT_MS)
108
+
109
+ // cssRequired/css are emptied so HubSpot doesn't inject its own stylesheet —
110
+ // the form is styled by hubspot-form.css instead. Everything else (submit,
111
+ // validation, the post-submit thank-you message) is HubSpot's default.
112
+ formsApi.create({
113
+ portalId,
114
+ formId,
115
+ region,
116
+ target: `#${containerId}`,
117
+ cssRequired: "",
118
+ css: "",
119
+ onFormReady: () => {
120
+ if (unmountedRef.current) return
121
+ if (readyTimeoutRef.current) clearTimeout(readyTimeoutRef.current)
122
+ setIsReady(true)
123
+ setHasError(false)
124
+ },
125
+ })
126
+ }, [containerId, formId, portalId, region])
127
+
128
+ useEffect(() => {
129
+ unmountedRef.current = false
130
+ setHasError(false)
131
+
132
+ void loadHubSpotFormsScript()
133
+ .then(() => {
134
+ if (unmountedRef.current) return
135
+ createForm()
136
+ })
137
+ .catch((error: unknown) => {
138
+ if (unmountedRef.current) return
139
+ console.error("Failed to mount HubSpot form", error)
140
+ setHasError(true)
141
+ })
142
+
143
+ return () => {
144
+ unmountedRef.current = true
145
+ if (readyTimeoutRef.current) clearTimeout(readyTimeoutRef.current)
146
+ if (containerRef.current) containerRef.current.innerHTML = ""
147
+ }
148
+ }, [createForm])
149
+
150
+ const showSkeleton = !(isReady || hasError)
151
+
152
+ return (
153
+ <div className="hubspot-form-wrapper">
154
+ <div className="hubspot-form-embed" id={containerId} ref={containerRef} />
155
+
156
+ {showSkeleton ? <HubSpotFormSkeleton /> : null}
157
+
158
+ {hasError ? (
159
+ <p className="hubspot-form-error">
160
+ We couldn&apos;t load this form right now.
161
+ </p>
162
+ ) : null}
163
+ </div>
164
+ )
165
+ }
@@ -0,0 +1,40 @@
1
+ export type HubSpotFormSelection = {
2
+ name: string
3
+ formId: string
4
+ portalId: string
5
+ region: string
6
+ }
7
+
8
+ export function decodeHubSpotFormSelection(
9
+ value?: string | null
10
+ ): HubSpotFormSelection | null {
11
+ if (!value) return null
12
+
13
+ try {
14
+ const parsed: unknown = JSON.parse(value)
15
+
16
+ if (typeof parsed !== "object" || parsed === null) return null
17
+
18
+ const { name, formId, portalId, region } = parsed as Record<string, unknown>
19
+
20
+ if (
21
+ typeof name !== "string" ||
22
+ typeof formId !== "string" ||
23
+ typeof portalId !== "string" ||
24
+ typeof region !== "string"
25
+ ) {
26
+ return null
27
+ }
28
+
29
+ // Fields that drive hbspt.forms.create must be non-empty (name is cosmetic).
30
+ if (!(formId.trim() && portalId.trim() && region.trim())) return null
31
+
32
+ return { name, formId, portalId, region }
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ export function getHubSpotFormSelectionLabel(selection: HubSpotFormSelection) {
39
+ return `${selection.name} (${selection.formId})`
40
+ }
@@ -0,0 +1,29 @@
1
+ import { HubSpotFormEmbed } from "./embed"
2
+ import "./hubspot-form.css"
3
+
4
+ type HubSpotFormProps = {
5
+ portalId: string
6
+ formId: string
7
+ region: string
8
+ className?: string | undefined
9
+ }
10
+
11
+ /**
12
+ * Renders a HubSpot form, fully styled and decoupled from any CMS. Pass the
13
+ * portal/form/region directly, or decode them from a stored selection with
14
+ * `decodeHubSpotFormSelection`.
15
+ */
16
+ export function HubSpotForm({
17
+ portalId,
18
+ formId,
19
+ region,
20
+ className,
21
+ }: HubSpotFormProps) {
22
+ const classes = ["hubspot-form-card", className].filter(Boolean).join(" ")
23
+
24
+ return (
25
+ <div className={classes}>
26
+ <HubSpotFormEmbed formId={formId} portalId={portalId} region={region} />
27
+ </div>
28
+ )
29
+ }
@@ -0,0 +1,142 @@
1
+ "use server"
2
+
3
+ import {
4
+ getHubSpotFormSelectionLabel,
5
+ type HubSpotFormSelection,
6
+ } from "./form-selection"
7
+
8
+ type HubSpotAccountDetailsResponse = {
9
+ portalId?: number | string
10
+ dataHostingLocation?: string
11
+ }
12
+
13
+ type HubSpotApiForm = {
14
+ id?: string
15
+ name?: string
16
+ archived?: boolean
17
+ displayOptions?: {
18
+ renderRawHtml?: boolean
19
+ }
20
+ }
21
+
22
+ type HubSpotFormsResponse = {
23
+ results?: HubSpotApiForm[]
24
+ paging?: { next?: { after?: string } }
25
+ }
26
+
27
+ export type HubSpotFormOption = {
28
+ label: string
29
+ value: string
30
+ /** False when HubSpot will iframe-embed (Free portals); used only as a hint. */
31
+ renderRawHtml: boolean | null
32
+ }
33
+
34
+ const HUBSPOT_API_BASE_URL = "https://api.hubapi.com"
35
+
36
+ async function fetchHubSpotJson<T>(path: string, accessToken: string) {
37
+ const response = await fetch(`${HUBSPOT_API_BASE_URL}${path}`, {
38
+ headers: {
39
+ Authorization: `Bearer ${accessToken}`,
40
+ Accept: "application/json",
41
+ },
42
+ cache: "no-store",
43
+ })
44
+
45
+ if (!response.ok) {
46
+ // Keep the full body for the server log, but throw a clean status so the
47
+ // caller-facing message never leaks HubSpot's raw response.
48
+ const body = await response.text()
49
+ console.error(
50
+ `HubSpot request failed (${response.status}) ${path}: ${body}`
51
+ )
52
+ throw new Error(`HubSpot request failed (${response.status}).`)
53
+ }
54
+
55
+ return (await response.json()) as T
56
+ }
57
+
58
+ async function fetchHubSpotForms(accessToken: string) {
59
+ const forms: HubSpotApiForm[] = []
60
+ let after: string | undefined
61
+
62
+ do {
63
+ const searchParams = new URLSearchParams({
64
+ formTypes: "hubspot",
65
+ limit: "100",
66
+ })
67
+ if (after) searchParams.set("after", after)
68
+
69
+ const response = await fetchHubSpotJson<HubSpotFormsResponse>(
70
+ `/marketing/v3/forms?${searchParams.toString()}`,
71
+ accessToken
72
+ )
73
+
74
+ forms.push(...(response.results ?? []))
75
+ after = response.paging?.next?.after
76
+ } while (after)
77
+
78
+ return forms
79
+ }
80
+
81
+ /**
82
+ * Lists every published HubSpot form in the connected portal as a flat list of
83
+ * options. Each option's `value` is the JSON-encoded selection consumed by
84
+ * `decodeHubSpotFormSelection` and ultimately by `<HubSpotForm />`.
85
+ *
86
+ * Requires a private-app token in `HUBSPOT_ACCESS_TOKEN` with the `forms` and
87
+ * `account-info.security.read` scopes.
88
+ */
89
+ export async function getHubSpotFormOptions(): Promise<{
90
+ forms: HubSpotFormOption[]
91
+ error: string | null
92
+ }> {
93
+ const accessToken = process.env.HUBSPOT_ACCESS_TOKEN
94
+
95
+ if (!accessToken) {
96
+ return { forms: [], error: "Missing HUBSPOT_ACCESS_TOKEN." }
97
+ }
98
+
99
+ try {
100
+ const [accountDetails, forms] = await Promise.all([
101
+ fetchHubSpotJson<HubSpotAccountDetailsResponse>(
102
+ "/account-info/v3/details",
103
+ accessToken
104
+ ),
105
+ fetchHubSpotForms(accessToken),
106
+ ])
107
+
108
+ const portalId = accountDetails.portalId
109
+ if (!portalId) {
110
+ return {
111
+ forms: [],
112
+ error: "HubSpot account details did not include a portal ID.",
113
+ }
114
+ }
115
+
116
+ const region = accountDetails.dataHostingLocation || "na1"
117
+
118
+ const options = forms
119
+ .filter((form) => form.id && form.name && !form.archived)
120
+ .map<HubSpotFormOption>((form) => {
121
+ const selection: HubSpotFormSelection = {
122
+ name: form.name as string,
123
+ formId: form.id as string,
124
+ portalId: String(portalId),
125
+ region,
126
+ }
127
+ return {
128
+ label: getHubSpotFormSelectionLabel(selection),
129
+ value: JSON.stringify(selection),
130
+ renderRawHtml: form.displayOptions?.renderRawHtml ?? null,
131
+ }
132
+ })
133
+ .sort((a, b) => a.label.localeCompare(b.label))
134
+
135
+ return { forms: options, error: null }
136
+ } catch (error) {
137
+ return {
138
+ forms: [],
139
+ error: error instanceof Error ? error.message : "Unable to load forms.",
140
+ }
141
+ }
142
+ }