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.
- package/dist/application/add-integration/index.d.ts.map +1 -1
- package/dist/application/add-integration/index.js +30 -4
- package/dist/application/add-integration/index.js.map +1 -1
- package/dist/application/shared/node-version.d.ts +8 -0
- package/dist/application/shared/node-version.d.ts.map +1 -0
- package/dist/application/shared/node-version.js +31 -0
- package/dist/application/shared/node-version.js.map +1 -0
- package/dist/application/shared/strip-ansi.d.ts +3 -0
- package/dist/application/shared/strip-ansi.d.ts.map +1 -0
- package/dist/application/shared/strip-ansi.js +7 -0
- package/dist/application/shared/strip-ansi.js.map +1 -0
- package/dist/core/create/execute-plan.d.ts +3 -0
- package/dist/core/create/execute-plan.d.ts.map +1 -1
- package/dist/core/create/execute-plan.js +3 -0
- package/dist/core/create/execute-plan.js.map +1 -1
- package/dist/domain/cms.d.ts +2 -0
- package/dist/domain/cms.d.ts.map +1 -1
- package/dist/domain/cms.js +1 -0
- package/dist/domain/cms.js.map +1 -1
- package/dist/domain/forms.d.ts +2 -2
- package/dist/domain/forms.js +1 -1
- package/dist/domain/forms.js.map +1 -1
- package/dist/domain/integration.d.ts +7 -3
- package/dist/domain/integration.d.ts.map +1 -1
- package/dist/domain/integration.js.map +1 -1
- package/dist/domain/skills.d.ts +26 -8
- package/dist/domain/skills.d.ts.map +1 -1
- package/dist/domain/skills.js +30 -9
- package/dist/domain/skills.js.map +1 -1
- package/dist/index.js +132 -16
- package/dist/index.js.map +1 -1
- package/dist/infrastructure/create/executor.d.ts.map +1 -1
- package/dist/infrastructure/create/executor.js +2 -1
- package/dist/infrastructure/create/executor.js.map +1 -1
- package/dist/infrastructure/create/init-git-repo.d.ts +11 -7
- package/dist/infrastructure/create/init-git-repo.d.ts.map +1 -1
- package/dist/infrastructure/create/init-git-repo.js +21 -18
- package/dist/infrastructure/create/init-git-repo.js.map +1 -1
- package/dist/infrastructure/create/sanity-auth.d.ts +11 -0
- package/dist/infrastructure/create/sanity-auth.d.ts.map +1 -0
- package/dist/infrastructure/create/sanity-auth.js +72 -0
- package/dist/infrastructure/create/sanity-auth.js.map +1 -0
- package/dist/infrastructure/create/setup-agent.d.ts +4 -3
- package/dist/infrastructure/create/setup-agent.d.ts.map +1 -1
- package/dist/infrastructure/create/setup-agent.js +51 -30
- package/dist/infrastructure/create/setup-agent.js.map +1 -1
- package/dist/infrastructure/create/setup-dotenvx.d.ts.map +1 -1
- package/dist/infrastructure/create/setup-dotenvx.js +9 -4
- package/dist/infrastructure/create/setup-dotenvx.js.map +1 -1
- package/dist/infrastructure/create/setup-remote.d.ts +14 -0
- package/dist/infrastructure/create/setup-remote.d.ts.map +1 -0
- package/dist/infrastructure/create/setup-remote.js +139 -0
- package/dist/infrastructure/create/setup-remote.js.map +1 -0
- package/dist/infrastructure/create/setup-sanity.d.ts +3 -2
- package/dist/infrastructure/create/setup-sanity.d.ts.map +1 -1
- package/dist/infrastructure/create/setup-sanity.js +34 -49
- package/dist/infrastructure/create/setup-sanity.js.map +1 -1
- package/dist/modules/features/env/dotenvx/config.d.ts.map +1 -1
- package/dist/modules/features/env/dotenvx/config.js +6 -0
- package/dist/modules/features/env/dotenvx/config.js.map +1 -1
- package/dist/paths.d.ts +0 -2
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +0 -2
- package/dist/paths.js.map +1 -1
- package/package.json +4 -3
- package/src/agent-skills/hubspot-pagebuilder-block/SKILL.md +160 -0
- package/src/agent-skills/manage-env/SKILL.md +5 -3
- package/src/agent-skills/rss-feed/SKILL.md +158 -0
- package/src/modules/features/cms/sanity/files/app/blog/[slug]/page.tsx +10 -9
- package/src/modules/features/cms/sanity/files/app/blog/page.tsx +5 -5
- package/src/modules/features/cms/sanity/files/lib/integrations/sanity/sanity.config.ts +3 -0
- package/src/modules/features/cms/sanity/files/lib/scripts/copy-sanity-mcp.ts +6 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-tree-pane.tsx +4 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +3 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +5 -4
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +17 -26
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/json-ld.tsx +1 -0
- package/src/modules/features/env/dotenvx/files/decrypt-env.mjs +12 -12
- package/src/modules/features/env/dotenvx/files/push-keys.mjs +9 -9
- package/src/modules/features/env/dotenvx/files/save-key.mjs +3 -3
- package/src/modules/features/env/dotenvx/files/setup-remote.ts +182 -0
- package/src/modules/features/env/dotenvx/files/write-env.mjs +6 -6
- package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-block.ts +60 -0
- package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-field.ts +30 -0
- package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-select-input.tsx +169 -0
- package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/README.md +114 -0
- package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/embed.tsx +165 -0
- package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/form-selection.ts +40 -0
- package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/form.tsx +29 -0
- package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/forms.ts +142 -0
- package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/hubspot-form.css +636 -0
- package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/skeleton.tsx +24 -0
- package/src/templates/next-default/README.md +1 -0
- package/src/templates/next-default/biome.json +1 -1
- package/src/templates/next-default/components/basement.svg +1 -1
- package/src/templates/next-default/knip.json +9 -1
- package/src/templates/next-experiments/README.md +1 -0
- package/src/templates/next-experiments/biome.json +1 -1
- package/src/templates/next-experiments/components/basement.svg +1 -1
- package/src/templates/next-experiments/knip.json +9 -1
- package/src/templates/next-pagebuilder/README.md +1 -0
- package/src/templates/next-pagebuilder/biome.json +1 -1
- package/src/templates/next-pagebuilder/knip.json +7 -2
- package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +1197 -176
- package/src/templates/next-webgl/README.md +1 -0
- package/src/templates/next-webgl/biome.json +1 -1
- package/src/templates/next-webgl/components/basement.svg +1 -1
- 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'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
|
+
}
|