feat(website): implement sales contact form submission via HubSpot#11710
feat(website): implement sales contact form submission via HubSpot#11710
Conversation
The /contact form's handleSubmit was a stub, silently dropping enterprise leads. Submit form data to HubSpot's Forms Submissions API v3 (unauthenticated, CORS-enabled) so leads land in the configured HubSpot contact-sales form. Form structure now matches the HubSpot form definition exactly: - Drop the 'Company' field (not present in HubSpot definition) - Add a required 'Work Email' field (HubSpot rejects without it) - Add a required 'Who primarily builds workflows?' multi-checkbox group - Mark the 'What are you looking for?' textarea as required - Map package values to HubSpot's enumeration (Individual=No, Teams=Teams, Enterprise=Yes) and submit them under the form's actual internal property name - Submit each field with objectTypeId='0-1' per HubSpot's schema Submission utility: - Add submitHubspotForm with fetch DI, abort/timeout, and a typed HubspotSubmissionError that carries HubSpot's per-field error array - Read the visitor's hubspotutk tracking cookie so submissions tie back to HubSpot's session tracking - Surface HubSpot's per-field validation messages to the user instead of a generic 'something went wrong' Tests: - Vitest coverage for the utility (15 cases): payload shape including objectTypeId, region switching, empty-value pruning, context handling, success body parse, HubspotSubmissionError on 400, unparseable error bodies, unconfigured guard, timeout/abort, and the hubspotutk cookie reader - Component tests (3 cases) using @testing-library/vue + user-event: payload shape end-to-end, success state + form reset, and HubSpot error surfacing Configuration: - Default Portal ID and Form GUID baked into the component (and documented in .env_example) — these are public IDs that appear in HubSpot's own embed code, not secrets - PUBLIC_HUBSPOT_PORTAL_ID, PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES, and PUBLIC_HUBSPOT_REGION env vars override the defaults per environment - Wire @vitejs/plugin-vue into vitest.config so .vue components can be tested with happy-dom
🌐 Website E2ECaution Some tests failed.
🔗 Website PreviewWebsite Preview: https://comfy-website-preview-pr-11710.vercel.app This commit: https://website-frontend-kztcotpgp-comfyui.vercel.app Last updated: 2026-04-28T02:32:09Z for |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a HubSpot Forms v3 client and integrates HubSpot-backed submission into the website contact form with region-aware endpoints, client-side validation, tracking cookie capture, timeoutable requests, updated translations and .env_example, tests for utilities and the form, and Vue transforms for Vitest. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Form as FormSection.vue
participant Utils as submitHubspotForm.ts
participant HubSpot as HubSpot API
User->>Form: Fill form & submit
Form->>Form: Trim inputs & validate required fields (email, lookingFor, buildsWorkflows)
Form->>Form: Set status = "submitting"
Form->>Utils: submitHubspotForm({ config, context, fields })
Utils->>Utils: resolve region, build endpoint, filter fields, add submittedAt
Utils->>HubSpot: POST JSON payload (fields, submittedAt, context) with timeout
alt success (2xx)
HubSpot->>Utils: Return JSON { inlineMessage?, redirectUri? }
Utils->>Form: Resolve with response
Form->>Form: Set status = "success" and reset form
else error (4xx/5xx)
HubSpot->>Utils: Return error body (maybe JSON)
Utils->>Form: Throw HubspotSubmissionError(status, errors)
Form->>Form: Set status = "error" and render errorDetail
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
🎨 Storybook: ✅ Built — View Storybook |
🎭 Playwright: ✅ 1359 passed, 0 failed · 3 flaky📊 Browser Reports
|
|
Saw the "review in progress" notification — standing by for the actual review. Will address whatever findings come back. |
📦 Bundle: 5.23 MB gzip 🔴 +67 BDetailsSummary
Category Glance App Entry Points — 22.5 kB (baseline 22.5 kB) • ⚪ 0 BMain entry bundles and manifests
Status: 1 added / 1 removed Graph Workspace — 1.23 MB (baseline 1.23 MB) • ⚪ 0 BGraph editor runtime, canvas, workflow orchestration
Status: 1 added / 1 removed Views & Navigation — 77.7 kB (baseline 77.7 kB) • ⚪ 0 BTop-level views, pages, and routed surfaces
Status: 9 added / 9 removed / 2 unchanged Panels & Settings — 484 kB (baseline 484 kB) • ⚪ 0 BConfiguration panels, inspectors, and settings screens
Status: 10 added / 10 removed / 11 unchanged User & Accounts — 17.4 kB (baseline 17.4 kB) • ⚪ 0 BAuthentication, profile, and account management bundles
Status: 5 added / 5 removed / 2 unchanged Editors & Dialogs — 113 kB (baseline 113 kB) • ⚪ 0 BModals, dialogs, drawers, and in-app editors
Status: 4 added / 4 removed UI Components — 61 kB (baseline 61 kB) • ⚪ 0 BReusable component library chunks
Status: 5 added / 5 removed / 8 unchanged Data & Services — 3.04 MB (baseline 3.04 MB) • ⚪ 0 BStores, services, APIs, and repositories
Status: 13 added / 13 removed / 4 unchanged Utilities & Hooks — 363 kB (baseline 363 kB) • ⚪ 0 BHelpers, composables, and utility bundles
Status: 13 added / 13 removed / 18 unchanged Vendor & Third-Party — 9.88 MB (baseline 9.88 MB) • ⚪ 0 BExternal libraries and shared vendor chunks Status: 16 unchanged Other — 8.83 MB (baseline 8.83 MB) • ⚪ 0 BBundles that do not match a named category
Status: 57 added / 57 removed / 78 unchanged ⚡ Performance Report
All metrics
Historical variance (last 15 runs)
Trend (last 15 commits on main)
Raw data{
"timestamp": "2026-04-28T02:49:45.241Z",
"gitSha": "bc1f773f47da8517d5ab1ff8e105bd6cd07b15cc",
"branch": "glary/implement-sales-form-submission",
"measurements": [
{
"name": "canvas-idle",
"durationMs": 2005.0170000000094,
"styleRecalcs": 7,
"styleRecalcDurationMs": 7.142000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 341.41900000000004,
"heapDeltaBytes": 20966628,
"heapUsedBytes": 64700524,
"domNodes": 14,
"jsHeapTotalBytes": 22806528,
"scriptDurationMs": 16.91399999999999,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-idle",
"durationMs": 2026.1429999999905,
"styleRecalcs": 10,
"styleRecalcDurationMs": 9.905000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 436.809,
"heapDeltaBytes": 20770356,
"heapUsedBytes": 64753328,
"domNodes": 19,
"jsHeapTotalBytes": 23330816,
"scriptDurationMs": 24.427000000000003,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.670000000000012,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-idle",
"durationMs": 2012.540999999942,
"styleRecalcs": 10,
"styleRecalcDurationMs": 9.046,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 389.97499999999997,
"heapDeltaBytes": 21133588,
"heapUsedBytes": 66321764,
"domNodes": 20,
"jsHeapTotalBytes": 22806528,
"scriptDurationMs": 21.926999999999996,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-mouse-sweep",
"durationMs": 2090.822000000003,
"styleRecalcs": 82,
"styleRecalcDurationMs": 49.317,
"layouts": 12,
"layoutDurationMs": 4.023,
"taskDurationMs": 1107.086,
"heapDeltaBytes": 16499360,
"heapUsedBytes": 60448224,
"domNodes": 63,
"jsHeapTotalBytes": 23592960,
"scriptDurationMs": 142.443,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-mouse-sweep",
"durationMs": 2066.11300000003,
"styleRecalcs": 81,
"styleRecalcDurationMs": 44.16100000000001,
"layouts": 12,
"layoutDurationMs": 3.5620000000000003,
"taskDurationMs": 1021.55,
"heapDeltaBytes": 15812544,
"heapUsedBytes": 59687128,
"domNodes": 65,
"jsHeapTotalBytes": 24117248,
"scriptDurationMs": 138.139,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "canvas-mouse-sweep",
"durationMs": 2023.0639999999767,
"styleRecalcs": 84,
"styleRecalcDurationMs": 45.028,
"layouts": 12,
"layoutDurationMs": 3.5329999999999995,
"taskDurationMs": 962.3739999999999,
"heapDeltaBytes": 16493484,
"heapUsedBytes": 60542220,
"domNodes": 66,
"jsHeapTotalBytes": 23068672,
"scriptDurationMs": 128.502,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1748.5699999999724,
"styleRecalcs": 31,
"styleRecalcDurationMs": 16.351,
"layouts": 6,
"layoutDurationMs": 0.68,
"taskDurationMs": 315.15000000000003,
"heapDeltaBytes": 25045680,
"heapUsedBytes": 68802980,
"domNodes": 77,
"jsHeapTotalBytes": 21495808,
"scriptDurationMs": 27.959000000000003,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1732.904000000019,
"styleRecalcs": 32,
"styleRecalcDurationMs": 15.479,
"layouts": 6,
"layoutDurationMs": 0.565,
"taskDurationMs": 279.40999999999997,
"heapDeltaBytes": 24743228,
"heapUsedBytes": 68780336,
"domNodes": 77,
"jsHeapTotalBytes": 21233664,
"scriptDurationMs": 17.776999999999997,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1708.3089999999856,
"styleRecalcs": 32,
"styleRecalcDurationMs": 17.615999999999996,
"layouts": 6,
"layoutDurationMs": 0.667,
"taskDurationMs": 307.32899999999995,
"heapDeltaBytes": 24443004,
"heapUsedBytes": 67872432,
"domNodes": 77,
"jsHeapTotalBytes": 20709376,
"scriptDurationMs": 22.14,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "dom-widget-clipping",
"durationMs": 551.1359999999854,
"styleRecalcs": 12,
"styleRecalcDurationMs": 7.8069999999999995,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 339.94599999999997,
"heapDeltaBytes": 7059572,
"heapUsedBytes": 50825028,
"domNodes": 20,
"jsHeapTotalBytes": 12320768,
"scriptDurationMs": 63.809000000000005,
"eventListeners": 2,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "dom-widget-clipping",
"durationMs": 529.5559999999568,
"styleRecalcs": 13,
"styleRecalcDurationMs": 9.046000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 340.73199999999997,
"heapDeltaBytes": 7035068,
"heapUsedBytes": 50762364,
"domNodes": 21,
"jsHeapTotalBytes": 12582912,
"scriptDurationMs": 65.072,
"eventListeners": 2,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "dom-widget-clipping",
"durationMs": 583.8109999999688,
"styleRecalcs": 11,
"styleRecalcDurationMs": 8.333999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 332.19200000000006,
"heapDeltaBytes": 6721740,
"heapUsedBytes": 50574940,
"domNodes": 18,
"jsHeapTotalBytes": 12058624,
"scriptDurationMs": 56.565000000000005,
"eventListeners": 2,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.663333333333338,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-idle",
"durationMs": 2075.8770000000195,
"styleRecalcs": 10,
"styleRecalcDurationMs": 9.934999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 587.2310000000001,
"heapDeltaBytes": 4843644,
"heapUsedBytes": 57428476,
"domNodes": -259,
"jsHeapTotalBytes": 16158720,
"scriptDurationMs": 107.19700000000002,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-idle",
"durationMs": 2010.6979999999908,
"styleRecalcs": 10,
"styleRecalcDurationMs": 9.677999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 593.163,
"heapDeltaBytes": 4455820,
"heapUsedBytes": 57107628,
"domNodes": -257,
"jsHeapTotalBytes": 15896576,
"scriptDurationMs": 105.77999999999999,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-idle",
"durationMs": 2024.4089999999915,
"styleRecalcs": 10,
"styleRecalcDurationMs": 10.834000000000003,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 601.992,
"heapDeltaBytes": 5185352,
"heapUsedBytes": 57524692,
"domNodes": -258,
"jsHeapTotalBytes": 16158720,
"scriptDurationMs": 109.75100000000002,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-pan",
"durationMs": 2128.8979999999924,
"styleRecalcs": 68,
"styleRecalcDurationMs": 18.017,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1130.708,
"heapDeltaBytes": 18969716,
"heapUsedBytes": 73944760,
"domNodes": -262,
"jsHeapTotalBytes": 17674240,
"scriptDurationMs": 406.286,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-pan",
"durationMs": 2115.844999999979,
"styleRecalcs": 68,
"styleRecalcDurationMs": 16.337,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1064.776,
"heapDeltaBytes": -16649808,
"heapUsedBytes": 48095460,
"domNodes": -263,
"jsHeapTotalBytes": 16011264,
"scriptDurationMs": 383.773,
"eventListeners": -129,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-pan",
"durationMs": 2103.1939999999167,
"styleRecalcs": 68,
"styleRecalcDurationMs": 17.169999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1122.326,
"heapDeltaBytes": 16383304,
"heapUsedBytes": 71660164,
"domNodes": -264,
"jsHeapTotalBytes": 18722816,
"scriptDurationMs": 407.48400000000004,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-zoom",
"durationMs": 3162.8729999999905,
"styleRecalcs": 65,
"styleRecalcDurationMs": 18.269000000000002,
"layouts": 60,
"layoutDurationMs": 7.263,
"taskDurationMs": 1351.189,
"heapDeltaBytes": 6061332,
"heapUsedBytes": 62503420,
"domNodes": -269,
"jsHeapTotalBytes": 17469440,
"scriptDurationMs": 490.763,
"eventListeners": -123,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-zoom",
"durationMs": 3114.435999999955,
"styleRecalcs": 64,
"styleRecalcDurationMs": 17.094999999999995,
"layouts": 60,
"layoutDurationMs": 7.196000000000001,
"taskDurationMs": 1329.9509999999998,
"heapDeltaBytes": 7687248,
"heapUsedBytes": 64533524,
"domNodes": -267,
"jsHeapTotalBytes": 16158720,
"scriptDurationMs": 490.36199999999997,
"eventListeners": -123,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.670000000000012,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-zoom",
"durationMs": 3179.8949999999877,
"styleRecalcs": 66,
"styleRecalcDurationMs": 18.429000000000006,
"layouts": 60,
"layoutDurationMs": 7.429,
"taskDurationMs": 1387.1789999999999,
"heapDeltaBytes": 9172504,
"heapUsedBytes": 65747912,
"domNodes": -264,
"jsHeapTotalBytes": 16945152,
"scriptDurationMs": 531.555,
"eventListeners": -123,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "minimap-idle",
"durationMs": 2050.0129999999785,
"styleRecalcs": 7,
"styleRecalcDurationMs": 7.626000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 542.793,
"heapDeltaBytes": 3069492,
"heapUsedBytes": 59030788,
"domNodes": -264,
"jsHeapTotalBytes": 16158720,
"scriptDurationMs": 95.498,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "minimap-idle",
"durationMs": 2050.3759999999716,
"styleRecalcs": 9,
"styleRecalcDurationMs": 8.167999999999997,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 518.852,
"heapDeltaBytes": 2708720,
"heapUsedBytes": 58692688,
"domNodes": -264,
"jsHeapTotalBytes": 16420864,
"scriptDurationMs": 89.029,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "minimap-idle",
"durationMs": 2069.19999999991,
"styleRecalcs": 8,
"styleRecalcDurationMs": 7.873999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 581.395,
"heapDeltaBytes": 3189852,
"heapUsedBytes": 59005860,
"domNodes": -262,
"jsHeapTotalBytes": 15634432,
"scriptDurationMs": 106.74899999999998,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 575.7020000000352,
"styleRecalcs": 46,
"styleRecalcDurationMs": 11.332,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 375.1960000000001,
"heapDeltaBytes": 6926820,
"heapUsedBytes": 51268788,
"domNodes": 18,
"jsHeapTotalBytes": 13631488,
"scriptDurationMs": 128.65099999999998,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.663333333333338,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 564.8780000000215,
"styleRecalcs": 48,
"styleRecalcDurationMs": 12.029,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 356.4510000000001,
"heapDeltaBytes": 7031868,
"heapUsedBytes": 51350440,
"domNodes": 22,
"jsHeapTotalBytes": 13369344,
"scriptDurationMs": 122.64,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 550.5010000000539,
"styleRecalcs": 48,
"styleRecalcDurationMs": 12.200999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 352.31300000000005,
"heapDeltaBytes": 6871688,
"heapUsedBytes": 51139964,
"domNodes": 22,
"jsHeapTotalBytes": 12320768,
"scriptDurationMs": 119.75999999999999,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-idle",
"durationMs": 2013.625999999988,
"styleRecalcs": 10,
"styleRecalcDurationMs": 10.261999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 363.125,
"heapDeltaBytes": 19923684,
"heapUsedBytes": 63776756,
"domNodes": 20,
"jsHeapTotalBytes": 23068672,
"scriptDurationMs": 16.188999999999997,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-idle",
"durationMs": 2014.3080000000282,
"styleRecalcs": 11,
"styleRecalcDurationMs": 10.203,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 356.321,
"heapDeltaBytes": 20061236,
"heapUsedBytes": 64294008,
"domNodes": 21,
"jsHeapTotalBytes": 22806528,
"scriptDurationMs": 17.349000000000004,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-idle",
"durationMs": 1997.7039999999988,
"styleRecalcs": 9,
"styleRecalcDurationMs": 8.778,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 371.86499999999995,
"heapDeltaBytes": -4816100,
"heapUsedBytes": 45719152,
"domNodes": 18,
"jsHeapTotalBytes": 24903680,
"scriptDurationMs": 16.767999999999997,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1997.5049999999896,
"styleRecalcs": 87,
"styleRecalcDurationMs": 46.759,
"layouts": 16,
"layoutDurationMs": 4.243,
"taskDurationMs": 939.91,
"heapDeltaBytes": 11828900,
"heapUsedBytes": 55908072,
"domNodes": 71,
"jsHeapTotalBytes": 23330816,
"scriptDurationMs": 103.58800000000001,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1990.30300000004,
"styleRecalcs": 84,
"styleRecalcDurationMs": 48.05200000000001,
"layouts": 16,
"layoutDurationMs": 4.648000000000001,
"taskDurationMs": 931.7169999999999,
"heapDeltaBytes": 11907516,
"heapUsedBytes": 55886224,
"domNodes": 72,
"jsHeapTotalBytes": 23330816,
"scriptDurationMs": 105.21699999999998,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1974.0070000000287,
"styleRecalcs": 84,
"styleRecalcDurationMs": 46.726,
"layouts": 16,
"layoutDurationMs": 4.485,
"taskDurationMs": 904.9209999999999,
"heapDeltaBytes": 11917432,
"heapUsedBytes": 56061724,
"domNodes": 72,
"jsHeapTotalBytes": 22806528,
"scriptDurationMs": 103.90599999999999,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "viewport-pan-sweep",
"durationMs": 8184.618,
"styleRecalcs": 250,
"styleRecalcDurationMs": 52.687,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3811.3230000000003,
"heapDeltaBytes": 24260408,
"heapUsedBytes": 76686256,
"domNodes": -260,
"jsHeapTotalBytes": 19771392,
"scriptDurationMs": 1255.209,
"eventListeners": -111,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "viewport-pan-sweep",
"durationMs": 8184.405000000026,
"styleRecalcs": 250,
"styleRecalcDurationMs": 51.19799999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3851.95,
"heapDeltaBytes": -595988,
"heapUsedBytes": 51877140,
"domNodes": -260,
"jsHeapTotalBytes": 19828736,
"scriptDurationMs": 1288.889,
"eventListeners": -111,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 8163.714000000027,
"styleRecalcs": 250,
"styleRecalcDurationMs": 51.084,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3714.3360000000002,
"heapDeltaBytes": -514928,
"heapUsedBytes": 51955688,
"domNodes": -261,
"jsHeapTotalBytes": 19304448,
"scriptDurationMs": 1261.444,
"eventListeners": -111,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 12400.514000000043,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 12390.255,
"heapDeltaBytes": -29177812,
"heapUsedBytes": 187340420,
"domNodes": -9850,
"jsHeapTotalBytes": 21557248,
"scriptDurationMs": 591.4229999999999,
"eventListeners": -23959,
"totalBlockingTimeMs": 0,
"frameDurationMs": 18.333333333333332,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-idle",
"durationMs": 11984.163000000024,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 11974.139000000001,
"heapDeltaBytes": -28516436,
"heapUsedBytes": 169281136,
"domNodes": -9848,
"jsHeapTotalBytes": 24178688,
"scriptDurationMs": 594.5930000000001,
"eventListeners": -23963,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.223333333333358,
"p95FrameDurationMs": 16.80000000000291
},
{
"name": "vue-large-graph-idle",
"durationMs": 12051.299000000086,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 12040.896999999999,
"heapDeltaBytes": -46361344,
"heapUsedBytes": 168927816,
"domNodes": -9850,
"jsHeapTotalBytes": 25489408,
"scriptDurationMs": 598.722,
"eventListeners": -23959,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.219999999999953,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-pan",
"durationMs": 14601.693000000012,
"styleRecalcs": 67,
"styleRecalcDurationMs": 17.596,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 14567.047,
"heapDeltaBytes": -50297036,
"heapUsedBytes": 162553416,
"domNodes": -9848,
"jsHeapTotalBytes": -13918208,
"scriptDurationMs": 926.4080000000001,
"eventListeners": -23955,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.776666666666642,
"p95FrameDurationMs": 33.29999999999927
},
{
"name": "vue-large-graph-pan",
"durationMs": 14112.535999999978,
"styleRecalcs": 65,
"styleRecalcDurationMs": 15.887999999999957,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 14091.271,
"heapDeltaBytes": -39194688,
"heapUsedBytes": 174104556,
"domNodes": -9852,
"jsHeapTotalBytes": -25190400,
"scriptDurationMs": 840.327,
"eventListeners": -23953,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.776666666666642,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-pan",
"durationMs": 14321.086000000036,
"styleRecalcs": 66,
"styleRecalcDurationMs": 16.735,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 14299.152000000002,
"heapDeltaBytes": -41238060,
"heapUsedBytes": 171938180,
"domNodes": -9848,
"jsHeapTotalBytes": -10248192,
"scriptDurationMs": 891.2789999999999,
"eventListeners": -23983,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.220000000000073,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "workflow-execution",
"durationMs": 467.1369999999797,
"styleRecalcs": 19,
"styleRecalcDurationMs": 29.034000000000006,
"layouts": 6,
"layoutDurationMs": 1.825,
"taskDurationMs": 153.279,
"heapDeltaBytes": 5394616,
"heapUsedBytes": 56742736,
"domNodes": 167,
"jsHeapTotalBytes": 524288,
"scriptDurationMs": 33.437999999999995,
"eventListeners": 71,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999727
},
{
"name": "workflow-execution",
"durationMs": 123.81199999992987,
"styleRecalcs": 10,
"styleRecalcDurationMs": 20.866,
"layouts": 5,
"layoutDurationMs": 1.712,
"taskDurationMs": 91.054,
"heapDeltaBytes": 3334068,
"heapUsedBytes": 48987688,
"domNodes": 143,
"jsHeapTotalBytes": 262144,
"scriptDurationMs": 19.717000000000002,
"eventListeners": 37,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999727
},
{
"name": "workflow-execution",
"durationMs": 457.1819999999889,
"styleRecalcs": 16,
"styleRecalcDurationMs": 21.124000000000002,
"layouts": 5,
"layoutDurationMs": 1.268,
"taskDurationMs": 110.038,
"heapDeltaBytes": 4968796,
"heapUsedBytes": 56440064,
"domNodes": 154,
"jsHeapTotalBytes": 524288,
"scriptDurationMs": 22.034,
"eventListeners": 71,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999727
}
]
} |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/website/src/components/contact/FormSection.test.ts (1)
33-50: Prefer label/role queries in the form helper.This helper is currently coupled to placeholder and copy text, so minor copy changes will break several tests at once. The form already exposes labels and control roles, so
getByLabelText/getByRolewould keep the assertions anchored to accessible behavior instead of marketing copy.Based on learnings: "In test files, prefer selecting or asserting on accessible properties (text content, aria-label, role, accessible name) over data-testid attributes."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/website/src/components/contact/FormSection.test.ts` around lines 33 - 50, The helper fillRequiredFields is using brittle selectors like screen.getByPlaceholderText and screen.getByText; switch these to accessibility-first queries: replace screen.getByPlaceholderText calls with screen.getByLabelText using the form field labels (and keep user.type), and replace screen.getByText button/radio clicks with screen.getByRole('button' or 'radio', { name: /accessible label/i }) so the test targets labels/roles/accessible names rather than marketing copy; update the user.click and user.type calls accordingly and keep the function signature and return value (fillRequiredFields returns user) intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/website/src/components/contact/FormSection.vue`:
- Around line 135-159: handleSubmit currently doesn't validate the checkbox
group and will submit when buildsWorkflows is empty; add a client-side check in
the handleSubmit function to ensure buildsWorkflows.value has at least one
selection, and if empty set status.value = 'error', populate errorDetail.value
with a user-facing message and return early (so the
HUBSPOT_FIELD_NAMES.buildsWorkflows field isn't posted empty). Apply the same
validation to the other identical submit handler referenced (the duplicate
handleSubmit block around the other occurrence) so both places enforce the
required buildsWorkflows checkbox group before calling submitHubspotForm.
In `@apps/website/src/utils/submitHubspotForm.ts`:
- Around line 139-142: The cookie parsing currently splits on '; ' which fails
when cookies have no space or include extra spaces; in the submitHubspotForm.ts
logic that computes match from cookieString, change the approach to split on ';'
(just semicolon), trim each resulting entry, find the one that startsWith
'hubspotutk=', and then return only the value after the '=' (trimmed) rather
than the whole entry; update the code paths that reference the match variable so
they extract and use the hubspotutk value correctly.
---
Nitpick comments:
In `@apps/website/src/components/contact/FormSection.test.ts`:
- Around line 33-50: The helper fillRequiredFields is using brittle selectors
like screen.getByPlaceholderText and screen.getByText; switch these to
accessibility-first queries: replace screen.getByPlaceholderText calls with
screen.getByLabelText using the form field labels (and keep user.type), and
replace screen.getByText button/radio clicks with screen.getByRole('button' or
'radio', { name: /accessible label/i }) so the test targets
labels/roles/accessible names rather than marketing copy; update the user.click
and user.type calls accordingly and keep the function signature and return value
(fillRequiredFields returns user) intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 88cf3333-e471-49c4-99a8-87ca567cc5f0
📒 Files selected for processing (7)
.env_exampleapps/website/src/components/contact/FormSection.test.tsapps/website/src/components/contact/FormSection.vueapps/website/src/i18n/translations.tsapps/website/src/utils/submitHubspotForm.test.tsapps/website/src/utils/submitHubspotForm.tsapps/website/vitest.config.ts
Codecov Report✅ All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## main #11710 +/- ##
===========================================
- Coverage 69.01% 51.47% -17.54%
===========================================
Files 1481 1374 -107
Lines 83864 70339 -13525
Branches 23173 18654 -4519
===========================================
- Hits 57877 36209 -21668
- Misses 25048 33531 +8483
+ Partials 939 599 -340
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
- Validate the workflow-builder checkbox group client-side: handleSubmit now blocks empty submissions with a localized error and never posts an empty value to HubSpot's required field. - Mark the workflow-builder section as required in the UI (asterisk). - Fix readHubspotTrackingCookie to handle cookie strings without spaces after semicolons (split on ';' + trim). - Wrap form labels around their inputs so the form is reachable via getByLabelText / accessible-name queries; switch component-test helpers from placeholder/text queries to label/role queries; add coverage for the new empty-builds validation and the no-space cookie case.
|
All three pieces of feedback addressed in
Quality gates: 41 tests pass (was 38; +1 component test for empty-builds, +2 cookie cases), |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/website/src/components/contact/FormSection.test.ts (1)
56-98: Add one multi-select serialization case forbuildsWorkflows.This only covers the single-checkbox path right now, so a regression in the
';'join logic would still keep the suite green. One test with two selections would lock down the HubSpot-specific wire format this PR is adding.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/website/src/components/contact/FormSection.test.ts` around lines 56 - 98, Update the test in FormSection.test.ts (the "submits the HubSpot payload..." spec that uses render(FormSection), fillRequiredFields, and submitMock) to cover multi-select serialization for the buildsWorkflows field: after calling fillRequiredFields, simulate selecting two options for the who_primarily_builds_workflows input (so the form's multi-select contains two choices), submit, then assert that the mapped field in args.fields for name 'who_primarily_builds_workflows' has value equal to the two choices joined with ';' (verifying the HubSpot ';' join logic). Keep using submitMock and the existing args/fieldByName mapping to locate and assert the value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/website/src/components/contact/FormSection.vue`:
- Around line 233-267: Trim the free-text v-models before running validation or
sending to HubSpot: in the FormSection.vue submit handler (the method that reads
v-models firstName, lastName, email, phone, and the message/textarea model) call
.trim() on firstName, lastName, email and the message field (or use trimmed
local copies) and use those trimmed values for validation and submission so
whitespace-only input is rejected by the required checks; update the bound
variables or ensure the validation uses the trimmed values.
- Around line 26-32: The hubspotConfig currently casts
import.meta.env.PUBLIC_HUBSPOT_REGION to HubspotRegion without validating the
runtime string; change the hubspotConfig creation (the hubspotConfig constant
and its region field) to perform a runtime check against the allowed
HubspotRegion values (e.g., a small set/array of valid region strings) and only
use the env value if it matches exactly, otherwise fall back to 'na1' (and
optionally emit a console.warn or logger message); remove the raw type assertion
and ensure the runtime-validated value is assigned to region so typos like "eu"
or "EU1" don't silently produce the wrong host.
---
Nitpick comments:
In `@apps/website/src/components/contact/FormSection.test.ts`:
- Around line 56-98: Update the test in FormSection.test.ts (the "submits the
HubSpot payload..." spec that uses render(FormSection), fillRequiredFields, and
submitMock) to cover multi-select serialization for the buildsWorkflows field:
after calling fillRequiredFields, simulate selecting two options for the
who_primarily_builds_workflows input (so the form's multi-select contains two
choices), submit, then assert that the mapped field in args.fields for name
'who_primarily_builds_workflows' has value equal to the two choices joined with
';' (verifying the HubSpot ';' join logic). Keep using submitMock and the
existing args/fieldByName mapping to locate and assert the value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f27aaf5d-850b-4ec2-84d6-3ec7c9ac8c0c
📒 Files selected for processing (5)
apps/website/src/components/contact/FormSection.test.tsapps/website/src/components/contact/FormSection.vueapps/website/src/i18n/translations.tsapps/website/src/utils/submitHubspotForm.test.tsapps/website/src/utils/submitHubspotForm.ts
- Trim free-text v-models with the .trim modifier and reject whitespace-only required fields explicitly in handleSubmit so whitespace-only submissions cannot pass validation. - Validate PUBLIC_HUBSPOT_REGION at runtime via resolveHubspotRegion: unknown values now log a console.warn and fall back to 'na1' instead of silently passing through a typo to the wrong host. - Add tests: resolveHubspotRegion (3 cases), multi-select join for who_primarily_builds_workflows, and whitespace-only rejection of required fields.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
apps/website/src/components/contact/FormSection.test.ts (1)
65-69: Use the realsubmitHubspotForminput type here.This inline payload shape will drift if the utility contract changes. Prefer
Parameters<typeof SubmitModule.submitHubspotForm>[0](or an exported request type) so the test stays locked to the production signature.Based on learnings, “In TypeScript test files (e.g., any test under src), avoid duplicating interface/type definitions. Import real type definitions from the component modules under test and reference them directly, so there is a single source of truth and to prevent type drift.”
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/website/src/components/contact/FormSection.test.ts` around lines 65 - 69, Replace the inline payload shape with the real input type for the HubSpot submit function so the test stays in sync with production: import the module that exports submitHubspotForm (e.g., SubmitModule or the function export) and type the captured arg as Parameters<typeof SubmitModule.submitHubspotForm>[0] (or use the exported request type if available) instead of the hardcoded object literal; update the declaration that currently asserts submitMock.mock.calls[0][0] to use that imported type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/website/src/components/contact/FormSection.vue`:
- Around line 165-185: This change sends PII (names, email, phone, free-text
notes, page metadata and hutk via readHubspotTrackingCookie) to HubSpot from
submitHubspotForm; update the form UX and documentation to obtain and record
user consent before sending: add a mandatory consent checkbox (tied to the form
state used by FormSection.vue and validated before calling submitHubspotForm),
ensure the consent value and a timestamp are included in the submission payload
(alongside fields/context), and update the public privacy policy and any in-form
disclosure text to mention HubSpot as a processor and what data (hutk, pageUri,
pageName, contact fields) is shared.
- Around line 26-35: The hubspotConfig currently falls back to production
defaults which causes non-prod runs to submit to the live form; update the logic
in hubspotConfig and isFormConfigured so it does NOT default to the production
portalId/formGuid. Specifically, remove or replace the hard-coded production
fallbacks in the hubspotConfig object (the portalId and formGuid values) and
make isFormConfigured require explicit environment values (e.g., consider
treating empty/undefined or a special placeholder as not configured) so that
local/preview builds will fail-safe unless a non-prod sandbox or explicit config
is provided; adjust uses of hubspotConfig and isFormConfigured accordingly to
prevent accidental submissions to the live sales form.
In `@apps/website/src/utils/submitHubspotForm.ts`:
- Line 140: The current guard uses isHubspotSuccessBody(parsed) which treats any
non-null object (including arrays) as valid; update isHubspotSuccessBody to
ensure parsed is a plain object and contains the expected
HubspotSubmissionResult fields (e.g., check typeof parsed === "object" &&
!Array.isArray(parsed) and validate required keys like "inlineMessage" or
"portalId" as used elsewhere), then return parsed only if that stricter check
passes; also apply the same stricter validation to the other return site that
uses isHubspotSuccessBody in this module so malformed payloads (like []) are
rejected and an empty object is returned instead.
---
Nitpick comments:
In `@apps/website/src/components/contact/FormSection.test.ts`:
- Around line 65-69: Replace the inline payload shape with the real input type
for the HubSpot submit function so the test stays in sync with production:
import the module that exports submitHubspotForm (e.g., SubmitModule or the
function export) and type the captured arg as Parameters<typeof
SubmitModule.submitHubspotForm>[0] (or use the exported request type if
available) instead of the hardcoded object literal; update the declaration that
currently asserts submitMock.mock.calls[0][0] to use that imported type.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 0e586f4e-0359-412c-9bd4-0b6d3dc962aa
📒 Files selected for processing (5)
apps/website/src/components/contact/FormSection.test.tsapps/website/src/components/contact/FormSection.vueapps/website/src/i18n/translations.tsapps/website/src/utils/submitHubspotForm.test.tsapps/website/src/utils/submitHubspotForm.ts
| await submitHubspotForm({ | ||
| config: hubspotConfig, | ||
| fields: [ | ||
| field(HUBSPOT_FIELD_NAMES.firstName, trimmedFirstName), | ||
| field(HUBSPOT_FIELD_NAMES.lastName, trimmedLastName), | ||
| field(HUBSPOT_FIELD_NAMES.email, trimmedEmail), | ||
| field(HUBSPOT_FIELD_NAMES.phone, trimmedPhone), | ||
| field(HUBSPOT_FIELD_NAMES.package, selectedPackage.value), | ||
| field(HUBSPOT_FIELD_NAMES.comfyUsage, comfyUsage.value), | ||
| field( | ||
| HUBSPOT_FIELD_NAMES.buildsWorkflows, | ||
| buildsWorkflows.value.join(';') | ||
| ), | ||
| field(HUBSPOT_FIELD_NAMES.lookingFor, trimmedLookingFor) | ||
| ], | ||
| context: { | ||
| hutk: readHubspotTrackingCookie(), | ||
| pageUri: | ||
| typeof window === 'undefined' ? undefined : window.location.href, | ||
| pageName: typeof document === 'undefined' ? undefined : document.title | ||
| } |
There was a problem hiding this comment.
Ship the HubSpot disclosure/policy update with this PII handoff.
This submission now sends name, work email, phone, free-text notes, page metadata, and hutk to HubSpot. I don’t see a matching disclosure/consent update in the form UX or an accompanying privacy-policy update in the changed surface, so the site would start sharing contact-form PII with a new processor without telling users.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/website/src/components/contact/FormSection.vue` around lines 165 - 185,
This change sends PII (names, email, phone, free-text notes, page metadata and
hutk via readHubspotTrackingCookie) to HubSpot from submitHubspotForm; update
the form UX and documentation to obtain and record user consent before sending:
add a mandatory consent checkbox (tied to the form state used by FormSection.vue
and validated before calling submitHubspotForm), ensure the consent value and a
timestamp are included in the submission payload (alongside fields/context), and
update the public privacy policy and any in-form disclosure text to mention
HubSpot as a processor and what data (hutk, pageUri, pageName, contact fields)
is shared.
There was a problem hiding this comment.
Partially addressed in 56e4e79ac, partially pushing back.
What I changed: added a privacy disclosure paragraph immediately under the submit button, in en + zh-CN, that explicitly names HubSpot as the CRM/processor and links to /privacy-policy. So the data sharing is now disclosed at point of collection rather than only in the policy footer.
What I'm pushing back on:
-
Mandatory consent checkbox tied to the form. The HubSpot form definition (managed in the HubSpot UI, not in this repo) is the source of truth for required fields. I confirmed the live form's required-field list earlier in this PR; consent isn't one of them. Adding a
consent: truefield to the submission payload would either be silently ignored by HubSpot or fail withFIELD_NOT_IN_FORM_DEFINITION. The right place to add a GDPR/CCPA consent capture is on the HubSpot form itself (HubSpot has a built-in "Legitimate Interest" / "Process Data" / "Communications" consent module), and once it's added there the field internal name comes back through the same render-definition endpoint and we can wire it up. That's a separate change owned by sales/marketing ops. -
Privacy-policy text update. The
/privacy-policypage is data-driven fromi18n/translations.ts(theprivacy.*keys). The existing intro already covers third-party processors generically (block 2: "In the event our site contains links to third-party sites and services, please be aware that those sites and services have their own privacy policies"). Whether HubSpot needs to be named explicitly there — and how — is a legal/ops call, not a frontend code change. Happy to add a sub-task or open a separate PR if the team wants the disclosure expanded there too, but I don't want to author that text unilaterally on this PR. -
Consent timestamp in the payload. Same reason as (1): the HubSpot form schema doesn't have a slot for it, and HubSpot already records the submission timestamp server-side via
submittedAt.
So this PR closes the in-form disclosure gap; the HubSpot-side consent module + privacy-policy text expansion belong in a follow-up that loops in legal/ops.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
| ) | ||
| } | ||
|
|
||
| return isHubspotSuccessBody(parsed) ? parsed : {} |
There was a problem hiding this comment.
Tighten the 2xx response guard.
isHubspotSuccessBody() currently accepts any non-null object, so a malformed successful payload like [] can be returned from Line 140 as a HubspotSubmissionResult. Restrict this to plain objects, or validate the fields you actually consume.
♻️ Proposed fix
function isHubspotSuccessBody(
value: unknown
): value is HubspotSubmissionResult {
- return typeof value === 'object' && value !== null
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ !Array.isArray(value)
+ )
}Also applies to: 179-183
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/website/src/utils/submitHubspotForm.ts` at line 140, The current guard
uses isHubspotSuccessBody(parsed) which treats any non-null object (including
arrays) as valid; update isHubspotSuccessBody to ensure parsed is a plain object
and contains the expected HubspotSubmissionResult fields (e.g., check typeof
parsed === "object" && !Array.isArray(parsed) and validate required keys like
"inlineMessage" or "portalId" as used elsewhere), then return parsed only if
that stricter check passes; also apply the same stricter validation to the other
return site that uses isHubspotSuccessBody in this module so malformed payloads
(like []) are rejected and an empty object is returned instead.
There was a problem hiding this comment.
Fixed in 56e4e79ac. Applied your suggested diff verbatim — isHubspotSuccessBody now also checks !Array.isArray(value) so a malformed [] from the success branch is rejected and we fall through to the empty-object return on line 140.
Added a regression test that fulfills the mock with [1, 2, 3] as the JSON body and asserts submitHubspotForm resolves with {} instead of leaking the array.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
…esponse parser - Production HubSpot Portal ID and Form GUID are now used only when import.meta.env.PROD is true. Local dev and Vercel preview deploys fail-safe with the submit button disabled unless the env vars are explicitly set, so non-prod runs cannot pollute the live CRM. - Add a privacy disclosure under the submit button (en + zh-CN) noting that submissions are sent to HubSpot, with a link to /privacy-policy. - Tighten isHubspotSuccessBody to reject arrays so a malformed 200 response cannot be returned as a HubspotSubmissionResult. - Replace inline payload type in the component test with the real Parameters<typeof submitHubspotForm>[0] so the test stays in sync with the production signature. - Add tests for the prod-gating fail-safe and the array-response guard.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
apps/website/src/utils/submitHubspotForm.test.ts (2)
278-282: Restoreconsole.warnafter the suite to avoid global mock leakage.Line 278 installs a spy, but Line 281 only clears call history. Add restore teardown so the global
console.warnimplementation is always put back.♻️ Suggested fix
-import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' ... describe('resolveHubspotRegion', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) afterEach(() => { warnSpy.mockClear() }) + + afterAll(() => { + warnSpy.mockRestore() + })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/website/src/utils/submitHubspotForm.test.ts` around lines 278 - 282, The test installs a spy via warnSpy = vi.spyOn(console, 'warn') but only calls warnSpy.mockClear() in afterEach, which leaves the global console.warn mocked; update the teardown to restore the original implementation by calling warnSpy.mockRestore() (or warnSpy.mockReset() followed by mockRestore) — either in afterEach alongside mockClear or in afterAll — so that console.warn is returned to its original implementation after the suite.
245-261: Use a table-driven test for cookie separator variants.These three cases assert the same behavior with different inputs; collapsing them into
it.eachkeeps intent and reduces repetition.As per coding guidelines: "Do not write redundant tests; follow composable testing patterns".♻️ Suggested refactor
- it('reads the hubspotutk cookie value with spaces after semicolons', () => { - expect( - readHubspotTrackingCookie('foo=bar; hubspotutk=abc123; baz=qux') - ).toBe('abc123') - }) - - it('reads the hubspotutk cookie value when separators have no spaces', () => { - expect(readHubspotTrackingCookie('foo=bar;hubspotutk=abc123;baz=qux')).toBe( - 'abc123' - ) - }) - - it('reads the hubspotutk cookie value when separators are mixed', () => { - expect( - readHubspotTrackingCookie('foo=bar; hubspotutk=abc123;baz=qux') - ).toBe('abc123') - }) + it.each([ + 'foo=bar; hubspotutk=abc123; baz=qux', + 'foo=bar;hubspotutk=abc123;baz=qux', + 'foo=bar; hubspotutk=abc123;baz=qux' + ])('reads the hubspotutk cookie value from "%s"', (cookie) => { + expect(readHubspotTrackingCookie(cookie)).toBe('abc123') + })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/website/src/utils/submitHubspotForm.test.ts` around lines 245 - 261, Replace the three redundant tests that call readHubspotTrackingCookie with different cookie-separator strings by converting them into a single table-driven test using it.each; create an array of input strings like 'foo=bar; hubspotutk=abc123; baz=qux', 'foo=bar;hubspotutk=abc123;baz=qux', and 'foo=bar; hubspotutk=abc123;baz=qux' and assert each yields 'abc123' in one it.each block that references readHubspotTrackingCookie to remove repetition and follow composable testing patterns.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/website/src/components/contact/FormSection.test.ts`:
- Around line 10-16: The tests import FormSection at module load so
hubspotConfig and isFormConfigured are evaluated with the wrong env; to fix,
call vi.resetModules() in a beforeEach, stub import.meta.env (use vi.stubEnv)
for each scenario before importing, then dynamically import FormSection (e.g.,
await import('.../FormSection.vue')) after stubbing so the module evaluates with
the desired environment; update both the "configured" and "unconfigured" test
setups that reference FormSection, hubspotConfig, and isFormConfigured to follow
this pattern.
In `@apps/website/src/i18n/translations.ts`:
- Around line 3406-3410: The contact.form.privacyDisclosure string in
translations.ts currently hardcodes the anchor href (/privacy-policy); remove
the hardcoded link markup and replace it with a placeholder (e.g., a token or
just plain text surrounding the "Privacy Policy" text) so the localized copy
contains only text and a link placeholder, then in FormSection.vue render the
anchor around that placeholder using the locale-aware path returned by
localePath('privacy-policy') (or localePath('/privacy-policy') consistent with
the app) so the link respects locale routing while keeping the translated text
in translations.ts.
---
Nitpick comments:
In `@apps/website/src/utils/submitHubspotForm.test.ts`:
- Around line 278-282: The test installs a spy via warnSpy = vi.spyOn(console,
'warn') but only calls warnSpy.mockClear() in afterEach, which leaves the global
console.warn mocked; update the teardown to restore the original implementation
by calling warnSpy.mockRestore() (or warnSpy.mockReset() followed by
mockRestore) — either in afterEach alongside mockClear or in afterAll — so that
console.warn is returned to its original implementation after the suite.
- Around line 245-261: Replace the three redundant tests that call
readHubspotTrackingCookie with different cookie-separator strings by converting
them into a single table-driven test using it.each; create an array of input
strings like 'foo=bar; hubspotutk=abc123; baz=qux',
'foo=bar;hubspotutk=abc123;baz=qux', and 'foo=bar; hubspotutk=abc123;baz=qux'
and assert each yields 'abc123' in one it.each block that references
readHubspotTrackingCookie to remove repetition and follow composable testing
patterns.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 96e844cb-a798-4684-90e8-0262f3ff674e
📒 Files selected for processing (5)
apps/website/src/components/contact/FormSection.test.tsapps/website/src/components/contact/FormSection.vueapps/website/src/i18n/translations.tsapps/website/src/utils/submitHubspotForm.test.tsapps/website/src/utils/submitHubspotForm.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/website/src/utils/submitHubspotForm.ts
| beforeAll(() => { | ||
| vi.stubEnv('PUBLIC_HUBSPOT_PORTAL_ID', '244637579') | ||
| vi.stubEnv( | ||
| 'PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES', | ||
| '94e05eab-1373-47f7-ab5e-d84f9e6aa262' | ||
| ) | ||
| }) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/website/src/components/contact/FormSection.test.tsRepository: Comfy-Org/ComfyUI_frontend
Length of output: 9410
🏁 Script executed:
cat -n apps/website/src/components/contact/FormSection.vueRepository: Comfy-Org/ComfyUI_frontend
Length of output: 18421
🏁 Script executed:
# Check if import.meta.env values are accessed at module scope in FormSection.vue
rg -A 5 "const hubspotConfig|const isFormConfigured" apps/website/src/components/contact/FormSection.vueRepository: Comfy-Org/ComfyUI_frontend
Length of output: 431
🏁 Script executed:
# Verify the import order and hook timing in the test file
head -50 apps/website/src/components/contact/FormSection.test.ts | rg -n "beforeAll|import FormSection|vi.stubEnv"Repository: Comfy-Org/ComfyUI_frontend
Length of output: 286
🏁 Script executed:
# Check the component rendering to confirm isFormConfigured is used to control button state
rg -B 2 -A 2 ":disabled=.*isFormConfigured" apps/website/src/components/contact/FormSection.vueRepository: Comfy-Org/ComfyUI_frontend
Length of output: 230
Stub import.meta.env before loading FormSection.
FormSection.vue builds hubspotConfig and isFormConfigured at module evaluation time. This file statically imports the component on line 41, which executes during test file initialization before beforeAll hooks run. As a result, the component captures whatever env state existed when the test file loaded, not the state set up by subsequent env stubs. Both test scenarios (configured and unconfigured) exercise the same component snapshot, making the assertions unreliable. Use vi.resetModules() in beforeEach, convert the import to dynamic, and re-import after each env stub/unstub so each test case actually evaluates the component with its intended environment.
💡 Minimal pattern to isolate the env-dependent cases
-import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
...
-beforeAll(() => {
+beforeEach(() => {
+ vi.resetModules()
vi.stubEnv('PUBLIC_HUBSPOT_PORTAL_ID', '244637579')
vi.stubEnv(
'PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES',
'94e05eab-1373-47f7-ab5e-d84f9e6aa262'
)
})
...
-import FormSection from './FormSection.vue'
+async function renderFormSection() {
+ const { default: FormSection } = await import('./FormSection.vue')
+ return render(FormSection)
+}
...
- render(FormSection)
+ await renderFormSection()
...
- beforeAll(() => {
- vi.unstubAllEnvs()
- })
-
- it('disables the submit button when neither env nor production defaults provide IDs', () => {
- render(FormSection)
+ it('disables the submit button when neither env nor production defaults provide IDs', async () => {
+ vi.unstubAllEnvs()
+ vi.resetModules()
+ await renderFormSection()Also applies to: 41-41, 216-219
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/website/src/components/contact/FormSection.test.ts` around lines 10 -
16, The tests import FormSection at module load so hubspotConfig and
isFormConfigured are evaluated with the wrong env; to fix, call
vi.resetModules() in a beforeEach, stub import.meta.env (use vi.stubEnv) for
each scenario before importing, then dynamically import FormSection (e.g., await
import('.../FormSection.vue')) after stubbing so the module evaluates with the
desired environment; update both the "configured" and "unconfigured" test setups
that reference FormSection, hubspotConfig, and isFormConfigured to follow this
pattern.
There was a problem hiding this comment.
Pushing back on this one — the analysis is wrong about Vue 3 SFC evaluation timing.
Vue 3 <script setup> compiles the body into a setup(__props) function on the component definition. import.meta.env.PUBLIC_HUBSPOT_PORTAL_ID is read inside that function, which runs once per render, not at import time. So the static import FormSection from './FormSection.vue' only registers the component definition; nothing about hubspotConfig is captured until render(FormSection) is called inside each test.
I verified this empirically with a throwaway probe test before deciding:
// import FormSection statically at the top
import FormSection from './FormSection.vue'
it('A: with no stubs, button disabled', () => {
vi.unstubAllEnvs()
render(FormSection)
expect(submitButton().disabled).toBe(true) // ✓
})
it('B: with stubs (applied AFTER static import), button enabled', () => {
vi.stubEnv('PUBLIC_HUBSPOT_PORTAL_ID', '999')
vi.stubEnv('PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES', 'guid')
render(FormSection)
expect(submitButton().disabled).toBe(false) // ✓
})Both pass. If hubspotConfig were captured at import time, B would also see the empty values and the button would still be disabled. The existing tests already rely on this — the configured tests assert args.config.portalId === '244637579' (the stubbed value), and the unconfigured test sees disabled === true. Both states are observable, which proves the env reads happen at render time.
So vi.stubEnv in beforeAll + vi.unstubAllEnvs() in the second describe's beforeAll is the simpler, faster pattern here. Switching to vi.resetModules() + dynamic await import('./FormSection.vue') per test would re-compile the SFC each time (much slower) without changing observable behavior.
Happy to revisit if I'm missing something — e.g., if the Vue compiler is configured to hoist import.meta.env reads to module scope in this project's setup, the analysis would change. From the compiled output I see in the test runner, that's not happening here.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
…kie tests - Render the privacy-policy link in the disclosure with the locale-aware path from getRoutes(locale).privacyPolicy instead of a hardcoded /privacy-policy. The i18n string is split into a before/link-label/ after triple so zh-CN visitors land on /zh-CN/privacy-policy. - Restore the global console.warn implementation in afterAll for the resolveHubspotRegion describe block so the warn spy doesn't leak across files. - Collapse the three cookie-separator-variant tests into a single it.each block per the project's "no redundant tests" guideline. - Add component tests covering both the en and zh-CN privacy-policy link hrefs to lock the locale routing in place.
|
Latest review's 2 nitpicks also addressed in
The Major (env-stub timing) finding I'm pushing back on with empirical evidence — see the inline reply on that thread. tl;dr: Vue 3 Quality gates: 50 tests pass (was 48; +2 for the new locale-aware link assertions), typecheck/knip/lint clean, build succeeds. |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
apps/website/src/components/contact/FormSection.vue (1)
33-44:⚠️ Potential issue | 🟠 Major
import.meta.env.PRODstill points preview/staging production builds at the live HubSpot form.
PRODhere means "built/run in production mode," not "this is the one true production deployment." A preview or staging deployment built in production mode will still pick up these hard-coded IDs when the overrides are missing, so the CRM-pollution risk from the earlier review is still present. Please gate the fallback on an explicit deployment-environment signal, or require explicit IDs outside the live site. The current fail-safe test only covers test mode, so it won’t catch this path. Based on learnings: In Vite/Vitest,import.meta.env.DEVis true for any mode that is not 'production' (i.e., DEV is the opposite of PROD, and can be true in 'test', 'development', etc.). Do not assume DEV implies only 'development' mode. When reviewing code and tests, treat DEV as a non-production flag and verify environment-specific logic accordingly.In Vite, what do `import.meta.env.PROD` and `import.meta.env.DEV` mean, and is `PROD` true for preview or staging deployments that are built in production mode?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/website/src/components/contact/FormSection.vue` around lines 33 - 44, The current fallback uses import.meta.env.PROD which is true for any build with production mode and can cause preview/staging to use live HubSpot IDs; update hubspotConfig and isFormConfigured so the hard-coded PROD_HUBSPOT_* IDs are only used when an explicit deployment marker indicates real production (e.g., a new env var like import.meta.env.PUBLIC_DEPLOYMENT_ENV === 'production' or PUBLIC_USE_LIVE_HUBSPOT === 'true'), otherwise require explicit PUBLIC_HUBSPOT_PORTAL_ID and PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES to be present and treat missing values as unconfigured; adjust resolveBranch in the fallback logic around hubspotConfig.portalId, hubspotConfig.formGuid, import.meta.env.PROD and references to PROD_HUBSPOT_PORTAL_ID/PROD_HUBSPOT_FORM_ID_CONTACT_SALES to gate on the explicit deploy var.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@apps/website/src/components/contact/FormSection.vue`:
- Around line 33-44: The current fallback uses import.meta.env.PROD which is
true for any build with production mode and can cause preview/staging to use
live HubSpot IDs; update hubspotConfig and isFormConfigured so the hard-coded
PROD_HUBSPOT_* IDs are only used when an explicit deployment marker indicates
real production (e.g., a new env var like import.meta.env.PUBLIC_DEPLOYMENT_ENV
=== 'production' or PUBLIC_USE_LIVE_HUBSPOT === 'true'), otherwise require
explicit PUBLIC_HUBSPOT_PORTAL_ID and PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES to be
present and treat missing values as unconfigured; adjust resolveBranch in the
fallback logic around hubspotConfig.portalId, hubspotConfig.formGuid,
import.meta.env.PROD and references to
PROD_HUBSPOT_PORTAL_ID/PROD_HUBSPOT_FORM_ID_CONTACT_SALES to gate on the
explicit deploy var.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: a0e96045-62ed-4b28-9b73-07cb151096df
📒 Files selected for processing (4)
apps/website/src/components/contact/FormSection.test.tsapps/website/src/components/contact/FormSection.vueapps/website/src/i18n/translations.tsapps/website/src/utils/submitHubspotForm.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/website/src/i18n/translations.ts
…back import.meta.env.PROD is true for any production-mode build, including Vercel preview deploys. The previous fallback meant preview/staging deploys could submit test entries to the live HubSpot CRM whenever the env vars weren't set. Drop the hardcoded fallback entirely so every environment (production included) must set PUBLIC_HUBSPOT_PORTAL_ID and PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES explicitly. When unset the submit button is disabled and a console.warn flags the misconfiguration. .env_example documents the production values in a comment for ops to copy into Vercel project settings. Renamed the fail-safe describe block to drop the now-misleading "preview/dev" qualifier, since the fail-safe applies in every environment.
|
CodeRabbit's re-flag was correct — What changed
This PR will ship the contact form in a disabled state on production unless someone sets these in Vercel → Project Settings → Environment Variables (Production scope only): Leave them unset for Preview and Development scopes so those deploys keep failing safe. <@U09UNN70STE> can you grab those before this lands? Quality gates: 50 tests pass, typecheck/knip/lint clean, build succeeds with 39 pages. |
|
A lower diff PR in the meantime is #11723, but I have an open mind, we could also take this' implementation, just unsure why HubSpot has this as a legacy v3 API. |
|
Replying to <@benceruleanlu>'s comment: Thanks for flagging — totally happy to defer to whichever path the team wants. These two PRs are exactly the two non-deprecated paths to HubSpot that the original Slack thread enumerated:
Fair concern — I read it the same way at first. After looking at the official OpenAPI spec, the answer is: it's only categorized as "legacy" in HubSpot's docs nav, not actually deprecated. There's no
If the design lean is "keep our own brand-styled form, don't iframe HubSpot's UI on the contact page", this PR is ready (modulo the env-var rollout I flagged in the previous comment). If the lean is "smaller diff, less code to maintain, accept HubSpot's iframe styling", #11723 is the cleaner pick — and the Bloomberg lead-loss issue gets fixed either way. I'd let you and <@U09UNN70STE>'s equivalent here make the call — both paths land submissions in the same HubSpot form, so the CRM side is identical. One thing worth noting: a few of the items I caught while writing this PR (HubSpot's form requires fields the website form doesn't surface, the multi-select join semantics, the Happy to close this in favor of #11723 if that's the call. Or if you'd prefer to take this implementation, the open ops action is the env-var rollout I flagged just above. |
PR Created by the Glary-Bot Agent
Summary
The
/contactform'shandleSubmit()was a stub (// TODO: implement form submission). Every "Contact Sales" submission has been silently dropped — including, per the team thread, an enterprise lead from Bloomberg who reported the form as broken.This PR wires the form to HubSpot's Forms Submissions API v3 (the unauthenticated, CORS-enabled endpoint) so submissions land in the existing HubSpot contact-sales form (
244637579 / 94e05eab-1373-47f7-ab5e-d84f9e6aa262). I pulled the live HubSpot form definition while building this and aligned every field accordingly.Fixes the broken form behavior reported in the project Slack thread.
What changed
Form structure (now matches the HubSpot form definition exactly)
The HubSpot form expects a different field set than the website form was rendering. The mismatches would have produced
FIELD_NOT_IN_FORM_DEFINITIONandREQUIRED_FIELDerrors on every submission even after wiring up the API call.firstnamelastnameCompanyjane@acme.org)emailphonepackageIndividualetc.No/Teams/Yesto_give_you_ann_idea_of_pricing_upfront__while_…usingYesProductionetc.Yes, in productionetc.are_youyour_team_currently_using_comfywho_primarily_builds_workflowscomfy_intake_notesMulti-select checkbox values are joined with
;per HubSpot's enumeration convention. Every field is sent withobjectTypeId: '0-1'per HubSpot's documented schema.Submission utility (
src/utils/submitHubspotForm.ts)fetchdependency injection,AbortController-based timeout, and a typedHubspotSubmissionErrorthat carries HubSpot's per-fielderrors[]arrayhubspotutkcookie and forwards it ascontext.hutkso submissions tie back to HubSpot's session tracking when the tracking script is loadedConfiguration
244637579and Form GUID94e05eab-1373-47f7-ab5e-d84f9e6aa262are baked into the component and documented in.env_example. These IDs are public — they appear in HubSpot's own embed code on every site that uses the form — and intentionally inlined into the static client bundle (consistent with HubSpot's official embed). They are not secrets.PUBLIC_HUBSPOT_PORTAL_ID,PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES, andPUBLIC_HUBSPOT_REGION(na1default; only set toeu1if the HubSpot account is hosted in the EU region).Tests
submitHubspotForm.test.ts): payload shape includingobjectTypeId, NA1/EU1 region switching, empty-value pruning, context handling andhubspotutkcookie reading, success body parse,HubspotSubmissionErroron 400 with errors, unparseable error bodies, unconfigured-form guard, timeout/abortFormSection.test.ts) using@testing-library/vue+@testing-library/user-event: end-to-end payload assertion, success state + form reset, and HubSpot per-field error surfacing@vitejs/plugin-vueintovitest.config.tsso.vuecomponents can be unit-tested withhappy-domVerification
pnpm typecheck(astro check): 0 errors / 0 warnings / 0 hintspnpm lint: 0 errors (1 pre-existing warning in unrelated billing test)pnpm format: cleanpnpm knip: cleanpnpm test:unit: 38 passed (35 pre-existing + 3 new component, 15 of which are HubSpot util){"redirectUri": "https://244637579.hs-sites-na2.com/intake?..."}with all expected query parameters echoed back, including the multi-valuewho_primarily_builds_workflows=One+dedicated+technical+owner;Small+group+of+power+usersNotes for reviewers
The HubSpot form has reCAPTCHA disabled — required for this API submission path. If reCAPTCHA is added back, every submission will return
FORM_HAS_RECAPTCHA_ENABLEDand we'll need to switch to a different integration path (HubSpot's embed JS or a server proxy that solves the challenge).I did not address the broader UX bug where the visible custom-styled radio/checkbox
<span>indicators sit alongsidesr-onlyinputs — that's pre-existing and a separate piece of work.Fixes the broken contact form reported in #project-new-website-brand-refresh
Screenshots
┆Issue is synchronized with this Notion page by Unito