bsmnt 0.6.3 → 0.7.0
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.js +1 -1
- 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 +3 -3
- package/src/agent-skills/hubspot-pagebuilder-block/SKILL.md +160 -0
- package/src/agent-skills/rss-feed/SKILL.md +158 -0
- package/src/modules/features/cms/sanity/files/lib/integrations/sanity/sanity.config.ts +3 -0
- 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/scripts/sanity-typegen.ts +16 -25
- package/src/modules/features/env/dotenvx/files/push-keys.mjs +7 -7
- package/src/modules/features/env/dotenvx/files/save-key.mjs +27 -12
- package/src/modules/features/env/dotenvx/files/setup-remote.ts +182 -0
- 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 +634 -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/knip.json +9 -1
- package/src/templates/next-experiments/README.md +1 -0
- package/src/templates/next-experiments/knip.json +9 -1
- package/src/templates/next-pagebuilder/README.md +1 -0
- 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/knip.json +9 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create the GitHub repo, link a Vercel project to it, and push the dotenvx key
|
|
3
|
+
* to Vercel env — before the git connection, so the first push-to-deploy can
|
|
4
|
+
* decrypt the committed `.env`. No GitHub secret: CI reads the key from
|
|
5
|
+
* 1Password via OP_SERVICE_ACCOUNT_TOKEN.
|
|
6
|
+
*
|
|
7
|
+
* bun setup-remote # private repo under your account (default)
|
|
8
|
+
* bun setup-remote --public # public repo
|
|
9
|
+
* bun setup-remote --org my-org # create under an org
|
|
10
|
+
*
|
|
11
|
+
* The CLI runs the same sequence inline during `bsmnt create`
|
|
12
|
+
* (src/infrastructure/create/setup-remote.ts); keep the two in sync.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const GH_HINT =
|
|
19
|
+
"gh CLI not found — install it (https://cli.github.com) and run `gh auth login`.";
|
|
20
|
+
const VERCEL_HINT =
|
|
21
|
+
"vercel CLI not found — install it (`bun add -g vercel`) and try again.";
|
|
22
|
+
|
|
23
|
+
/** Run an interactive command, letting the user answer its prompts directly. */
|
|
24
|
+
function run(cmd: string, args: string[], notFoundHint: string): boolean {
|
|
25
|
+
const res = spawnSync(cmd, args, { stdio: "inherit" });
|
|
26
|
+
if ((res.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
27
|
+
console.error(`\n${notFoundHint}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
return res.status === 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Push the private keys to Vercel env via the tested push-keys path, rather
|
|
35
|
+
* than re-implementing the per-key (stdin-piped) upload here.
|
|
36
|
+
*/
|
|
37
|
+
function pushVercelKeys(): boolean {
|
|
38
|
+
return (
|
|
39
|
+
spawnSync("bun", ["run", "scripts/push-keys.mjs", "vercel"], {
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
}).status === 0
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasOriginRemote(): boolean {
|
|
46
|
+
return (
|
|
47
|
+
spawnSync("git", ["remote", "get-url", "origin"], { stdio: "ignore" })
|
|
48
|
+
.status === 0
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ghReady(): boolean {
|
|
53
|
+
const res = spawnSync("gh", ["auth", "status"], { stdio: "ignore" });
|
|
54
|
+
return (
|
|
55
|
+
(res.error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT" &&
|
|
56
|
+
res.status === 0
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when `.env.keys` holds at least one non-empty DOTENV_PRIVATE_KEY*. */
|
|
61
|
+
function hasPrivateKey(): boolean {
|
|
62
|
+
if (!existsSync(".env.keys")) return false;
|
|
63
|
+
for (const line of readFileSync(".env.keys", "utf8").split("\n")) {
|
|
64
|
+
const value = line
|
|
65
|
+
.match(/^\s*DOTENV_PRIVATE_KEY[A-Z_]*\s*=\s*(.+?)\s*$/)?.[1]
|
|
66
|
+
?.replace(/^["']|["']$/g, "");
|
|
67
|
+
if (value) return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function main(): void {
|
|
73
|
+
// No key means a deploy can't decrypt `.env` — nothing to wire up yet.
|
|
74
|
+
if (!hasPrivateKey()) {
|
|
75
|
+
console.error(
|
|
76
|
+
"No DOTENV_PRIVATE_KEY* in .env.keys — run `bun run env:setup` first so there's a key to push.",
|
|
77
|
+
);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// GitHub: ensure the repo exists so Vercel can git-connect to it.
|
|
82
|
+
let connected = hasOriginRemote();
|
|
83
|
+
if (!connected) {
|
|
84
|
+
if (ghReady()) {
|
|
85
|
+
// `--source` forces non-interactive mode, which requires a visibility flag.
|
|
86
|
+
const visibility = process.argv.includes("--public")
|
|
87
|
+
? "--public"
|
|
88
|
+
: "--private";
|
|
89
|
+
const orgIndex = process.argv.indexOf("--org");
|
|
90
|
+
const owner = orgIndex !== -1 ? process.argv[orgIndex + 1] : undefined;
|
|
91
|
+
const nameArg = owner ? [`${owner}/${path.basename(process.cwd())}`] : [];
|
|
92
|
+
console.info(`→ gh repo create (${visibility})`);
|
|
93
|
+
// No `--push` here: the push must come after `vercel git connect`, or
|
|
94
|
+
// the git integration never sees it and skips the first deployment.
|
|
95
|
+
if (
|
|
96
|
+
run(
|
|
97
|
+
"gh",
|
|
98
|
+
["repo", "create", ...nameArg, "--source=.", "--remote=origin", visibility],
|
|
99
|
+
GH_HINT,
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
connected = hasOriginRemote();
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
console.info(
|
|
106
|
+
"\nNo `origin` remote and the gh CLI isn't ready — skipping GitHub setup.\n" +
|
|
107
|
+
" Install gh (https://cli.github.com), run `gh auth login`, then re-run\n" +
|
|
108
|
+
" (or create the repo manually and re-run).",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Vercel: link the project and push the keys as env vars.
|
|
114
|
+
if (spawnSync("vercel", ["whoami"], { stdio: "ignore" }).status !== 0) {
|
|
115
|
+
console.info("→ vercel login");
|
|
116
|
+
if (!run("vercel", ["login"], VERCEL_HINT)) process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
// Pick the scope via Vercel's team picker, then link with `--yes` so the
|
|
119
|
+
// validated lowercase dir name is used (the `Name?` prompt rejects uppercase).
|
|
120
|
+
console.info("→ vercel switch + link");
|
|
121
|
+
run("vercel", ["switch"], VERCEL_HINT);
|
|
122
|
+
if (!run("vercel", ["link", "--yes"], VERCEL_HINT)) {
|
|
123
|
+
console.error("\nLink cancelled — re-run `bun setup-remote` when ready.");
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
// Pin the Next.js framework so Vercel resolves the .next output (it sometimes
|
|
127
|
+
// creates the project as "Other"); commit it so the push below ships it.
|
|
128
|
+
if (!existsSync("vercel.json")) {
|
|
129
|
+
writeFileSync(
|
|
130
|
+
"vercel.json",
|
|
131
|
+
'{\n "$schema": "https://openapi.vercel.sh/vercel.json",\n "framework": "nextjs"\n}\n',
|
|
132
|
+
);
|
|
133
|
+
spawnSync("git", ["add", "vercel.json"], { stdio: "ignore" });
|
|
134
|
+
spawnSync(
|
|
135
|
+
"git",
|
|
136
|
+
[
|
|
137
|
+
"commit",
|
|
138
|
+
"-m",
|
|
139
|
+
"chore: pin Next.js framework for Vercel",
|
|
140
|
+
"--no-verify",
|
|
141
|
+
"--",
|
|
142
|
+
"vercel.json",
|
|
143
|
+
],
|
|
144
|
+
{ stdio: "ignore" },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
console.info("→ uploading private keys to Vercel env");
|
|
148
|
+
if (!pushVercelKeys()) process.exit(1);
|
|
149
|
+
|
|
150
|
+
// Connect the two so pushes build from a clone (keys already in place).
|
|
151
|
+
if (!connected) {
|
|
152
|
+
console.info(
|
|
153
|
+
"\n✓ Vercel linked and keys uploaded. No GitHub repo connected yet — once\n" +
|
|
154
|
+
" one exists, run `vercel git connect` for push-to-deploy.",
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
console.info("→ vercel git connect");
|
|
159
|
+
if (!run("vercel", ["git", "connect"], VERCEL_HINT)) {
|
|
160
|
+
console.error(
|
|
161
|
+
"\nKeys uploaded, but `vercel git connect` failed. If the Git integration\n" +
|
|
162
|
+
"isn't installed, add it once from the Vercel dashboard (Account/Team →\n" +
|
|
163
|
+
"Integrations → GitHub), then re-run `vercel git connect`.",
|
|
164
|
+
);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Push after connecting so the git integration builds the first deployment.
|
|
169
|
+
console.info("→ git push");
|
|
170
|
+
if (!run("git", ["push", "-u", "origin", "HEAD"], "git not found.")) {
|
|
171
|
+
console.error(
|
|
172
|
+
"\nConnected, but the push failed — run `git push` to trigger the first deploy.",
|
|
173
|
+
);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.info(
|
|
178
|
+
"\n✓ GitHub + Vercel ready. Pushed — Vercel is building the first deployment.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
main();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { EnvelopeIcon } from "@sanity/icons"
|
|
2
|
+
import { defineField, defineType } from "sanity"
|
|
3
|
+
import {
|
|
4
|
+
decodeHubSpotFormSelection,
|
|
5
|
+
getHubSpotFormSelectionLabel,
|
|
6
|
+
} from "../form-selection"
|
|
7
|
+
import { hubspotFormField } from "./form-field"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sample reusable HubSpot form block. Register it in your schema's document
|
|
11
|
+
* list (and page-builder config, if you use one), then render its `form` with
|
|
12
|
+
* <HubSpotForm /> from the integration core:
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { HubSpotForm } from "@/lib/integrations/hubspot/form"
|
|
16
|
+
* import { decodeHubSpotFormSelection } from "@/lib/integrations/hubspot/form-selection"
|
|
17
|
+
*
|
|
18
|
+
* const selection = decodeHubSpotFormSelection(block.form)
|
|
19
|
+
* if (!selection) return null
|
|
20
|
+
* return <HubSpotForm {...selection} />
|
|
21
|
+
*/
|
|
22
|
+
export const hubspotFormBlock = defineType({
|
|
23
|
+
name: "hubspotFormBlock",
|
|
24
|
+
title: "HubSpot Form Block",
|
|
25
|
+
type: "document",
|
|
26
|
+
icon: EnvelopeIcon,
|
|
27
|
+
fields: [
|
|
28
|
+
defineField({
|
|
29
|
+
name: "name",
|
|
30
|
+
title: "Name",
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "Used to identify this block in the Studio.",
|
|
33
|
+
validation: (Rule) => Rule.required(),
|
|
34
|
+
}),
|
|
35
|
+
hubspotFormField,
|
|
36
|
+
],
|
|
37
|
+
preview: {
|
|
38
|
+
select: {
|
|
39
|
+
name: "name",
|
|
40
|
+
form: "form",
|
|
41
|
+
},
|
|
42
|
+
prepare({ name, form }) {
|
|
43
|
+
const selectedForm = decodeHubSpotFormSelection(form)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
title: name || "Untitled",
|
|
47
|
+
...(selectedForm
|
|
48
|
+
? { subtitle: getHubSpotFormSelectionLabel(selectedForm) }
|
|
49
|
+
: {}),
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const hubspotFormBlockReference = defineType({
|
|
56
|
+
name: "hubspotFormBlockReference",
|
|
57
|
+
title: "HubSpot Form Block",
|
|
58
|
+
type: "reference",
|
|
59
|
+
to: [{ type: "hubspotFormBlock" }],
|
|
60
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineField } from "sanity"
|
|
2
|
+
import { decodeHubSpotFormSelection } from "../form-selection"
|
|
3
|
+
import { HubSpotFormSelectInput } from "./form-select-input"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared HubSpot form picker field. Stores the selected form as one encoded
|
|
7
|
+
* string and validates it decodes to a real selection. Reuse it in any block
|
|
8
|
+
* or document that embeds a HubSpot form.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* defineType({
|
|
12
|
+
* name: "contactBlock",
|
|
13
|
+
* type: "object",
|
|
14
|
+
* fields: [hubspotFormField],
|
|
15
|
+
* })
|
|
16
|
+
*/
|
|
17
|
+
export const hubspotFormField = defineField({
|
|
18
|
+
name: "form",
|
|
19
|
+
title: "HubSpot Form",
|
|
20
|
+
type: "string",
|
|
21
|
+
components: {
|
|
22
|
+
input: HubSpotFormSelectInput,
|
|
23
|
+
},
|
|
24
|
+
description:
|
|
25
|
+
"Fetches HubSpot forms when the dropdown is opened. The selected form is stored as one string.",
|
|
26
|
+
validation: (Rule) =>
|
|
27
|
+
Rule.required().custom((value) =>
|
|
28
|
+
decodeHubSpotFormSelection(value) ? true : "Select a valid HubSpot form."
|
|
29
|
+
),
|
|
30
|
+
})
|
|
@@ -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`
|