strapi-plugin-hubspot 0.16.2 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +8 -5
  3. package/dist/_chunks/{Forms-BOQWely0.mjs → Forms-DyV6KyiO.mjs} +20 -6
  4. package/dist/_chunks/{Forms-mMNP7hc9.js → Forms-uUN4XjhW.js} +20 -6
  5. package/dist/_chunks/{HubspotFormInput-DI35Aqiu.js → HubspotFormInput-BkWat_wu.js} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-DCkoavrN.mjs → HubspotFormInput-DEXPX-6j.mjs} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-Cq1TuL_l.js → HubspotObjectInput-CFDYW-TM.js} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-BbnvhdbD.mjs → HubspotObjectInput-DL8fYK2x.mjs} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-BLZzsW08.js → HubspotPropertyInput-CFMkbQeT.js} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-BHfpBD36.mjs → HubspotPropertyInput-WU5FMJj3.mjs} +3 -3
  11. package/dist/_chunks/{Settings-BAsx0LGv.js → Settings-B_hhdpHa.js} +21 -3
  12. package/dist/_chunks/{Settings-DqRpHtnT.mjs → Settings-CiXEps_C.mjs} +21 -3
  13. package/dist/_chunks/{en-Djd3TID0.mjs → en-BIUBYzHv.mjs} +5 -1
  14. package/dist/_chunks/{en-sfzopB5K.js → en-CFhEtgZJ.js} +5 -1
  15. package/dist/_chunks/{fr-C7t1elmR.mjs → fr-CMYcnFH0.mjs} +5 -1
  16. package/dist/_chunks/{fr-sF5Josfh.js → fr-zdUuuV0M.js} +5 -1
  17. package/dist/_chunks/{index-CLAy5dVN.mjs → index-BeBn7bJa.mjs} +6 -6
  18. package/dist/_chunks/{index-R3ReZXF1.js → index-CG_WQzSa.js} +6 -6
  19. package/dist/_chunks/{objectLabels-CW2iBOkD.js → objectLabels-BGq-Ypgn.js} +1 -1
  20. package/dist/_chunks/{objectLabels--zOmqsfm.mjs → objectLabels-CS_0QCNz.mjs} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-BRzzvs9t.js → useHubspotSchema-BZP2qkAu.js} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-Bz2o1BU-.mjs → useHubspotSchema-CP7LRdzO.mjs} +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.mjs +1 -1
  25. package/dist/admin/src/builder/types.d.ts +1 -1
  26. package/dist/server/index.js +108 -30
  27. package/dist/server/index.mjs +108 -30
  28. package/dist/server/src/forms.d.ts +3 -2
  29. package/dist/server/src/hsforms.d.ts +53 -7
  30. package/dist/server/src/importHubspot.d.ts +9 -0
  31. package/dist/server/src/index.d.ts +1 -0
  32. package/dist/server/src/settings.d.ts +6 -0
  33. package/package.json +1 -1
@@ -26,7 +26,7 @@ const index = {
26
26
  icon: icons.PaperPlane,
27
27
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "HubSpot Forms" },
28
28
  permissions: [{ action: `plugin::${PLUGIN_ID}.forms`, subject: null }],
29
- Component: async () => (await Promise.resolve().then(() => require("./Forms-mMNP7hc9.js"))).default
29
+ Component: async () => (await Promise.resolve().then(() => require("./Forms-uUN4XjhW.js"))).default
30
30
  });
31
31
  app.customFields.register({
32
32
  name: "property",
@@ -41,7 +41,7 @@ const index = {
41
41
  defaultMessage: "Picked from the portal's real properties"
42
42
  },
43
43
  components: {
44
- Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-BLZzsW08.js"))
44
+ Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-CFMkbQeT.js"))
45
45
  },
46
46
  // Exposed in the Content-Type Builder. Without this, `objectField` could
47
47
  // only be set by hand-editing schema.json — which is not how anyone
@@ -104,7 +104,7 @@ const index = {
104
104
  defaultMessage: "Picked from the portal's configured objects"
105
105
  },
106
106
  components: {
107
- Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-Cq1TuL_l.js"))
107
+ Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-CFDYW-TM.js"))
108
108
  }
109
109
  });
110
110
  app.customFields.register({
@@ -120,7 +120,7 @@ const index = {
120
120
  defaultMessage: "A form built in the HubSpot form builder, stored by slug"
121
121
  },
122
122
  components: {
123
- Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-DI35Aqiu.js"))
123
+ Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-BkWat_wu.js"))
124
124
  }
125
125
  });
126
126
  app.createSettingSection(
@@ -136,7 +136,7 @@ const index = {
136
136
  // Mirrors the RBAC action registered server-side: a role without it
137
137
  // doesn't see the link at all — the routes refuse it anyway.
138
138
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
139
- Component: async () => (await Promise.resolve().then(() => require("./Settings-BAsx0LGv.js"))).default
139
+ Component: async () => (await Promise.resolve().then(() => require("./Settings-B_hhdpHa.js"))).default
140
140
  }
141
141
  ]
142
142
  );
@@ -146,7 +146,7 @@ const index = {
146
146
  return Promise.all(
147
147
  locales.map(async (locale) => {
148
148
  try {
149
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-sfzopB5K.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-sF5Josfh.js")) }), `./translations/${locale}.json`, 3);
149
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-CFhEtgZJ.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-zdUuuV0M.js")) }), `./translations/${locale}.json`, 3);
150
150
  return { data: prefixPluginTranslations(data), locale };
151
151
  } catch {
152
152
  return { data: {}, locale };
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- const index = require("./index-R3ReZXF1.js");
2
+ const index = require("./index-CG_WQzSa.js");
3
3
  const STANDARD = /* @__PURE__ */ new Set([
4
4
  "contact",
5
5
  "company",
@@ -1,4 +1,4 @@
1
- import { g as getTranslation } from "./index-CLAy5dVN.mjs";
1
+ import { g as getTranslation } from "./index-BeBn7bJa.mjs";
2
2
  const STANDARD = /* @__PURE__ */ new Set([
3
3
  "contact",
4
4
  "company",
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  const React = require("react");
3
3
  const admin = require("@strapi/strapi/admin");
4
- const index = require("./index-R3ReZXF1.js");
4
+ const index = require("./index-CG_WQzSa.js");
5
5
  function _interopNamespace(e) {
6
6
  if (e && e.__esModule) return e;
7
7
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { useFetchClient } from "@strapi/strapi/admin";
3
- import { P as PLUGIN_ID } from "./index-CLAy5dVN.mjs";
3
+ import { P as PLUGIN_ID } from "./index-BeBn7bJa.mjs";
4
4
  let cached = null;
5
5
  let inFlight = null;
6
6
  const listeners = /* @__PURE__ */ new Set();
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-R3ReZXF1.js");
2
+ const index = require("../_chunks/index-CG_WQzSa.js");
3
3
  require("@strapi/icons");
4
4
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-CLAy5dVN.mjs";
1
+ import { i } from "../_chunks/index-BeBn7bJa.mjs";
2
2
  import "@strapi/icons";
3
3
  export {
4
4
  i as default
@@ -103,7 +103,7 @@ export interface SubmissionRowDto {
103
103
  formTitle?: string | null;
104
104
  email?: string | null;
105
105
  values: Record<string, string | number | boolean>;
106
- meta?: Record<string, string> | null;
106
+ meta?: Record<string, unknown> | null;
107
107
  locale?: string | null;
108
108
  hubspotSynced?: boolean;
109
109
  createdAt?: string;
@@ -191,7 +191,8 @@ function policyFrom(stored, forms) {
191
191
  return {
192
192
  submissionMode: asSubmissionMode(stored.submissionMode) ?? asSubmissionMode(forms.submissionMode) ?? "auto",
193
193
  writeExtraProperties: asBool(stored.writeExtraProperties) ?? asBool(forms.writeExtraProperties) ?? true,
194
- syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false
194
+ syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false,
195
+ timelineNote: asBool(stored.timelineNote) ?? asBool(forms.timelineNote) ?? true
195
196
  };
196
197
  }
197
198
  async function resolvePolicy(strapi) {
@@ -275,6 +276,9 @@ async function patchStoredSettings(strapi, body) {
275
276
  if ("syncFieldsOnPublish" in body && typeof body.syncFieldsOnPublish === "boolean") {
276
277
  next.syncFieldsOnPublish = body.syncFieldsOnPublish;
277
278
  }
279
+ if ("timelineNote" in body && typeof body.timelineNote === "boolean") {
280
+ next.timelineNote = body.timelineNote;
281
+ }
278
282
  await setStoredSettings(strapi, next);
279
283
  }
280
284
  async function clearStoredApiKey(strapi) {
@@ -1932,6 +1936,59 @@ function formFieldNames(raw) {
1932
1936
  }
1933
1937
  return names;
1934
1938
  }
1939
+ function formRequiredNames(raw) {
1940
+ const names = [];
1941
+ for (const group of raw.fieldGroups ?? []) {
1942
+ for (const field of group.fields ?? []) {
1943
+ if (field.name && field.required) names.push(field.name);
1944
+ }
1945
+ }
1946
+ return names;
1947
+ }
1948
+ function parseFormShape(raw) {
1949
+ const legal = raw.legalConsentOptions;
1950
+ const hasLegalConsent = Boolean(
1951
+ legal && typeof legal === "object" && legal.type && legal.type !== "none"
1952
+ );
1953
+ const communications = [];
1954
+ for (const box of legal?.communicationsCheckboxes ?? []) {
1955
+ const id = Number(box.subscriptionTypeId ?? box.communicationTypeId);
1956
+ if (!Number.isFinite(id) || id <= 0) continue;
1957
+ communications.push({
1958
+ subscriptionTypeId: id,
1959
+ text: String(box.label || box.text || "").trim() || "I agree to receive communications."
1960
+ });
1961
+ }
1962
+ const consentToProcessText = legal?.consentToProcessText?.trim();
1963
+ return {
1964
+ names: new Set(formFieldNames(raw)),
1965
+ required: new Set(formRequiredNames(raw)),
1966
+ hasLegalConsent,
1967
+ captcha: raw.configuration?.captchaEnabled === true,
1968
+ ...consentToProcessText ? { consentToProcessText } : {},
1969
+ communications
1970
+ };
1971
+ }
1972
+ function canRetryEmailOnly(required) {
1973
+ const names = [...required ?? []];
1974
+ return names.length === 0 || names.every((name) => name === "email");
1975
+ }
1976
+ function buildLegalConsent(shape, given, fallbackText) {
1977
+ if (!given) return void 0;
1978
+ if (shape && !shape.hasLegalConsent) return void 0;
1979
+ const consent = {
1980
+ consentToProcess: true,
1981
+ text: shape?.consentToProcessText?.trim() || fallbackText?.trim() || DEFAULT_CONSENT_TEXT
1982
+ };
1983
+ if (shape?.communications.length) {
1984
+ consent.communications = shape.communications.map((item) => ({
1985
+ value: true,
1986
+ subscriptionTypeId: item.subscriptionTypeId,
1987
+ text: item.text
1988
+ }));
1989
+ }
1990
+ return { consent };
1991
+ }
1935
1992
  const shapeCache = /* @__PURE__ */ new Map();
1936
1993
  function clearFormShapeCache(formGuid) {
1937
1994
  if (formGuid) shapeCache.delete(formGuid);
@@ -1940,16 +1997,13 @@ function clearFormShapeCache(formGuid) {
1940
1997
  async function loadFormShape(apiKey, formGuid) {
1941
1998
  const cached = shapeCache.get(formGuid);
1942
1999
  if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
1943
- return { names: cached.names, hasLegalConsent: cached.hasLegalConsent };
2000
+ return cached.shape;
1944
2001
  }
1945
2002
  try {
1946
2003
  const raw = await fetchHubspotForm(apiKey, formGuid);
1947
- const names = new Set(formFieldNames(raw));
1948
- const hasLegalConsent = Boolean(
1949
- raw.legalConsentOptions && typeof raw.legalConsentOptions === "object" && raw.legalConsentOptions.type && raw.legalConsentOptions.type !== "none"
1950
- );
1951
- shapeCache.set(formGuid, { at: Date.now(), names, hasLegalConsent });
1952
- return { names, hasLegalConsent };
2004
+ const shape = parseFormShape(raw);
2005
+ shapeCache.set(formGuid, { at: Date.now(), shape });
2006
+ return shape;
1953
2007
  } catch {
1954
2008
  return null;
1955
2009
  }
@@ -2007,28 +2061,45 @@ async function submitMarketingForm(input) {
2007
2061
  if (!portalId || !isFormGuid(formGuid) || !input.fields.length) {
2008
2062
  return { ok: false, status: 0, error: "Missing portal, form GUID or fields" };
2009
2063
  }
2064
+ if (input.shape?.captcha) {
2065
+ return {
2066
+ ok: false,
2067
+ status: 0,
2068
+ error: "HubSpot form has CAPTCHA enabled — the Forms API cannot submit it. Turn CAPTCHA off on that marketing form."
2069
+ };
2070
+ }
2010
2071
  const url = formsSubmitUrl(portalId, formGuid, input.region);
2011
2072
  const context = contextPayload(input.context);
2073
+ const fields = input.fields.map((field) => ({
2074
+ objectTypeId: field.objectTypeId || "0-1",
2075
+ name: field.name,
2076
+ value: field.value
2077
+ }));
2012
2078
  const base = {
2013
- fields: input.fields,
2079
+ fields,
2014
2080
  ...Object.keys(context).length ? { context } : {}
2015
2081
  };
2016
- const withConsent = input.consent?.given && input.formHasLegalConsent !== false ? {
2017
- ...base,
2018
- legalConsentOptions: {
2019
- consent: {
2020
- consentToProcess: true,
2021
- text: input.consent.text?.trim() || DEFAULT_CONSENT_TEXT
2022
- }
2023
- }
2024
- } : base;
2082
+ const legal = buildLegalConsent(
2083
+ input.shape,
2084
+ input.consent?.given === true,
2085
+ input.consent?.text
2086
+ );
2087
+ const withConsent = legal ? { ...base, legalConsentOptions: legal } : base;
2025
2088
  let result = await postForm(url, input.apiKey, withConsent);
2026
- if (!result.ok && result.status === 400 && withConsent !== base) {
2089
+ const mustKeepConsent = Boolean(input.shape?.hasLegalConsent && legal);
2090
+ if (!result.ok && result.status === 400 && withConsent !== base && !mustKeepConsent) {
2027
2091
  result = await postForm(url, input.apiKey, base);
2028
2092
  }
2029
- if (!result.ok && result.status === 400 && input.fields.length > 1) {
2030
- const email = input.fields.find((f) => f.name === "email");
2031
- if (email) result = await postForm(url, input.apiKey, { ...base, fields: [email] });
2093
+ if (!result.ok && result.status === 400 && input.fields.length > 1 && canRetryEmailOnly(input.shape?.required)) {
2094
+ const email = fields.find((f) => f.name === "email");
2095
+ if (email) {
2096
+ const slim = { ...base, fields: [email] };
2097
+ result = await postForm(
2098
+ url,
2099
+ input.apiKey,
2100
+ legal && mustKeepConsent ? { ...slim, legalConsentOptions: legal } : slim
2101
+ );
2102
+ }
2032
2103
  }
2033
2104
  return result;
2034
2105
  }
@@ -2410,6 +2481,7 @@ function createFormsService(strapi, _opts = {}) {
2410
2481
  }
2411
2482
  let usedFormsApi = false;
2412
2483
  let sentFieldNames = [];
2484
+ let conversionError;
2413
2485
  if (email && apiKey) {
2414
2486
  const groups = groupByObject(form.definition, resolution.values);
2415
2487
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -2445,11 +2517,11 @@ function createFormsService(strapi, _opts = {}) {
2445
2517
  context: {
2446
2518
  hutk: sanitizeHutk(meta.hutk),
2447
2519
  pageUri: typeof meta.pageUrl === "string" ? meta.pageUrl : void 0,
2448
- pageName: typeof meta.pageName === "string" && meta.pageName || (form.title || form.name) || void 0,
2520
+ pageName: typeof meta.pageName === "string" && meta.pageName || typeof meta.originLabel === "string" && meta.originLabel || (form.title || form.name) || void 0,
2449
2521
  ipAddress: sanitizeIp(meta.ipAddress)
2450
2522
  },
2451
2523
  consent: { given: meta.consent === true },
2452
- formHasLegalConsent: shape?.hasLegalConsent
2524
+ shape
2453
2525
  });
2454
2526
  if (submitted.ok) {
2455
2527
  hubspotSynced = true;
@@ -2462,10 +2534,12 @@ function createFormsService(strapi, _opts = {}) {
2462
2534
  );
2463
2535
  }
2464
2536
  } else {
2537
+ conversionError = submitted.error;
2465
2538
  strapi.log.warn(`[hubspot] Forms API submit failed — ${submitted.error}`);
2466
2539
  }
2467
2540
  } catch (err) {
2468
- strapi.log.warn(`[hubspot] Forms API submit failed — ${err.message}`);
2541
+ conversionError = err.message;
2542
+ strapi.log.warn(`[hubspot] Forms API submit failed — ${conversionError}`);
2469
2543
  }
2470
2544
  }
2471
2545
  if (!usedFormsApi) {
@@ -2525,7 +2599,7 @@ function createFormsService(strapi, _opts = {}) {
2525
2599
  strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
2526
2600
  }
2527
2601
  }
2528
- if (contactId && config2.timelineNote && !usedFormsApi) {
2602
+ if (contactId && policy.timelineNote) {
2529
2603
  try {
2530
2604
  await createLeadNote(apiKey, {
2531
2605
  contactId,
@@ -2547,7 +2621,11 @@ function createFormsService(strapi, _opts = {}) {
2547
2621
  formTitle: form.title || form.name,
2548
2622
  email,
2549
2623
  values: resolution.values,
2550
- meta,
2624
+ meta: {
2625
+ ...meta,
2626
+ hubspotConversion: usedFormsApi,
2627
+ ...conversionError ? { hubspotError: conversionError } : {}
2628
+ },
2551
2629
  locale: form.locale ?? null,
2552
2630
  hubspotSynced,
2553
2631
  contactId,
@@ -2734,7 +2812,7 @@ function createFormsAdminController(strapi) {
2734
2812
  locale: ctx.query.locale,
2735
2813
  status: "published"
2736
2814
  });
2737
- ctx.body = { form: entry, published: Boolean(published) };
2815
+ ctx.body = { form: entry, published: Boolean(published), publishedHubspotFormId: published?.hubspotFormId ?? null };
2738
2816
  },
2739
2817
  async create(ctx) {
2740
2818
  const body = ctx.request.body ?? {};
@@ -3334,8 +3412,8 @@ const config = {
3334
3412
  // Submission pipeline of the built forms:
3335
3413
  // - companyFromDomain: a corporate email upserts the Company (deduped by
3336
3414
  // domain) and associates it to the contact;
3337
- // - timelineNote: recap note on the CRM-upsert fallback only (off when
3338
- // submissions go through the marketing Forms API);
3415
+ // - timelineNote: recap note on the contact after a successful sync
3416
+ // (page URL, subject, answers) — alongside a Forms API conversion;
3339
3417
  // - defaultFormId: portal-wide marketing form GUID when a builder form
3340
3418
  // doesn't set its own — swap with the portal at production cutover.
3341
3419
  // - submissionMode / writeExtraProperties / syncFieldsOnPublish: see
@@ -190,7 +190,8 @@ function policyFrom(stored, forms) {
190
190
  return {
191
191
  submissionMode: asSubmissionMode(stored.submissionMode) ?? asSubmissionMode(forms.submissionMode) ?? "auto",
192
192
  writeExtraProperties: asBool(stored.writeExtraProperties) ?? asBool(forms.writeExtraProperties) ?? true,
193
- syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false
193
+ syncFieldsOnPublish: asBool(stored.syncFieldsOnPublish) ?? asBool(forms.syncFieldsOnPublish) ?? false,
194
+ timelineNote: asBool(stored.timelineNote) ?? asBool(forms.timelineNote) ?? true
194
195
  };
195
196
  }
196
197
  async function resolvePolicy(strapi) {
@@ -274,6 +275,9 @@ async function patchStoredSettings(strapi, body) {
274
275
  if ("syncFieldsOnPublish" in body && typeof body.syncFieldsOnPublish === "boolean") {
275
276
  next.syncFieldsOnPublish = body.syncFieldsOnPublish;
276
277
  }
278
+ if ("timelineNote" in body && typeof body.timelineNote === "boolean") {
279
+ next.timelineNote = body.timelineNote;
280
+ }
277
281
  await setStoredSettings(strapi, next);
278
282
  }
279
283
  async function clearStoredApiKey(strapi) {
@@ -1931,6 +1935,59 @@ function formFieldNames(raw) {
1931
1935
  }
1932
1936
  return names;
1933
1937
  }
1938
+ function formRequiredNames(raw) {
1939
+ const names = [];
1940
+ for (const group of raw.fieldGroups ?? []) {
1941
+ for (const field of group.fields ?? []) {
1942
+ if (field.name && field.required) names.push(field.name);
1943
+ }
1944
+ }
1945
+ return names;
1946
+ }
1947
+ function parseFormShape(raw) {
1948
+ const legal = raw.legalConsentOptions;
1949
+ const hasLegalConsent = Boolean(
1950
+ legal && typeof legal === "object" && legal.type && legal.type !== "none"
1951
+ );
1952
+ const communications = [];
1953
+ for (const box of legal?.communicationsCheckboxes ?? []) {
1954
+ const id = Number(box.subscriptionTypeId ?? box.communicationTypeId);
1955
+ if (!Number.isFinite(id) || id <= 0) continue;
1956
+ communications.push({
1957
+ subscriptionTypeId: id,
1958
+ text: String(box.label || box.text || "").trim() || "I agree to receive communications."
1959
+ });
1960
+ }
1961
+ const consentToProcessText = legal?.consentToProcessText?.trim();
1962
+ return {
1963
+ names: new Set(formFieldNames(raw)),
1964
+ required: new Set(formRequiredNames(raw)),
1965
+ hasLegalConsent,
1966
+ captcha: raw.configuration?.captchaEnabled === true,
1967
+ ...consentToProcessText ? { consentToProcessText } : {},
1968
+ communications
1969
+ };
1970
+ }
1971
+ function canRetryEmailOnly(required) {
1972
+ const names = [...required ?? []];
1973
+ return names.length === 0 || names.every((name) => name === "email");
1974
+ }
1975
+ function buildLegalConsent(shape, given, fallbackText) {
1976
+ if (!given) return void 0;
1977
+ if (shape && !shape.hasLegalConsent) return void 0;
1978
+ const consent = {
1979
+ consentToProcess: true,
1980
+ text: shape?.consentToProcessText?.trim() || fallbackText?.trim() || DEFAULT_CONSENT_TEXT
1981
+ };
1982
+ if (shape?.communications.length) {
1983
+ consent.communications = shape.communications.map((item) => ({
1984
+ value: true,
1985
+ subscriptionTypeId: item.subscriptionTypeId,
1986
+ text: item.text
1987
+ }));
1988
+ }
1989
+ return { consent };
1990
+ }
1934
1991
  const shapeCache = /* @__PURE__ */ new Map();
1935
1992
  function clearFormShapeCache(formGuid) {
1936
1993
  if (formGuid) shapeCache.delete(formGuid);
@@ -1939,16 +1996,13 @@ function clearFormShapeCache(formGuid) {
1939
1996
  async function loadFormShape(apiKey, formGuid) {
1940
1997
  const cached = shapeCache.get(formGuid);
1941
1998
  if (cached && Date.now() - cached.at < SHAPE_TTL_MS) {
1942
- return { names: cached.names, hasLegalConsent: cached.hasLegalConsent };
1999
+ return cached.shape;
1943
2000
  }
1944
2001
  try {
1945
2002
  const raw = await fetchHubspotForm(apiKey, formGuid);
1946
- const names = new Set(formFieldNames(raw));
1947
- const hasLegalConsent = Boolean(
1948
- raw.legalConsentOptions && typeof raw.legalConsentOptions === "object" && raw.legalConsentOptions.type && raw.legalConsentOptions.type !== "none"
1949
- );
1950
- shapeCache.set(formGuid, { at: Date.now(), names, hasLegalConsent });
1951
- return { names, hasLegalConsent };
2003
+ const shape = parseFormShape(raw);
2004
+ shapeCache.set(formGuid, { at: Date.now(), shape });
2005
+ return shape;
1952
2006
  } catch {
1953
2007
  return null;
1954
2008
  }
@@ -2006,28 +2060,45 @@ async function submitMarketingForm(input) {
2006
2060
  if (!portalId || !isFormGuid(formGuid) || !input.fields.length) {
2007
2061
  return { ok: false, status: 0, error: "Missing portal, form GUID or fields" };
2008
2062
  }
2063
+ if (input.shape?.captcha) {
2064
+ return {
2065
+ ok: false,
2066
+ status: 0,
2067
+ error: "HubSpot form has CAPTCHA enabled — the Forms API cannot submit it. Turn CAPTCHA off on that marketing form."
2068
+ };
2069
+ }
2009
2070
  const url = formsSubmitUrl(portalId, formGuid, input.region);
2010
2071
  const context = contextPayload(input.context);
2072
+ const fields = input.fields.map((field) => ({
2073
+ objectTypeId: field.objectTypeId || "0-1",
2074
+ name: field.name,
2075
+ value: field.value
2076
+ }));
2011
2077
  const base = {
2012
- fields: input.fields,
2078
+ fields,
2013
2079
  ...Object.keys(context).length ? { context } : {}
2014
2080
  };
2015
- const withConsent = input.consent?.given && input.formHasLegalConsent !== false ? {
2016
- ...base,
2017
- legalConsentOptions: {
2018
- consent: {
2019
- consentToProcess: true,
2020
- text: input.consent.text?.trim() || DEFAULT_CONSENT_TEXT
2021
- }
2022
- }
2023
- } : base;
2081
+ const legal = buildLegalConsent(
2082
+ input.shape,
2083
+ input.consent?.given === true,
2084
+ input.consent?.text
2085
+ );
2086
+ const withConsent = legal ? { ...base, legalConsentOptions: legal } : base;
2024
2087
  let result = await postForm(url, input.apiKey, withConsent);
2025
- if (!result.ok && result.status === 400 && withConsent !== base) {
2088
+ const mustKeepConsent = Boolean(input.shape?.hasLegalConsent && legal);
2089
+ if (!result.ok && result.status === 400 && withConsent !== base && !mustKeepConsent) {
2026
2090
  result = await postForm(url, input.apiKey, base);
2027
2091
  }
2028
- if (!result.ok && result.status === 400 && input.fields.length > 1) {
2029
- const email = input.fields.find((f) => f.name === "email");
2030
- if (email) result = await postForm(url, input.apiKey, { ...base, fields: [email] });
2092
+ if (!result.ok && result.status === 400 && input.fields.length > 1 && canRetryEmailOnly(input.shape?.required)) {
2093
+ const email = fields.find((f) => f.name === "email");
2094
+ if (email) {
2095
+ const slim = { ...base, fields: [email] };
2096
+ result = await postForm(
2097
+ url,
2098
+ input.apiKey,
2099
+ legal && mustKeepConsent ? { ...slim, legalConsentOptions: legal } : slim
2100
+ );
2101
+ }
2031
2102
  }
2032
2103
  return result;
2033
2104
  }
@@ -2409,6 +2480,7 @@ function createFormsService(strapi, _opts = {}) {
2409
2480
  }
2410
2481
  let usedFormsApi = false;
2411
2482
  let sentFieldNames = [];
2483
+ let conversionError;
2412
2484
  if (email && apiKey) {
2413
2485
  const groups = groupByObject(form.definition, resolution.values);
2414
2486
  for (const [object, props] of Object.entries(companyGroups)) {
@@ -2444,11 +2516,11 @@ function createFormsService(strapi, _opts = {}) {
2444
2516
  context: {
2445
2517
  hutk: sanitizeHutk(meta.hutk),
2446
2518
  pageUri: typeof meta.pageUrl === "string" ? meta.pageUrl : void 0,
2447
- pageName: typeof meta.pageName === "string" && meta.pageName || (form.title || form.name) || void 0,
2519
+ pageName: typeof meta.pageName === "string" && meta.pageName || typeof meta.originLabel === "string" && meta.originLabel || (form.title || form.name) || void 0,
2448
2520
  ipAddress: sanitizeIp(meta.ipAddress)
2449
2521
  },
2450
2522
  consent: { given: meta.consent === true },
2451
- formHasLegalConsent: shape?.hasLegalConsent
2523
+ shape
2452
2524
  });
2453
2525
  if (submitted.ok) {
2454
2526
  hubspotSynced = true;
@@ -2461,10 +2533,12 @@ function createFormsService(strapi, _opts = {}) {
2461
2533
  );
2462
2534
  }
2463
2535
  } else {
2536
+ conversionError = submitted.error;
2464
2537
  strapi.log.warn(`[hubspot] Forms API submit failed — ${submitted.error}`);
2465
2538
  }
2466
2539
  } catch (err) {
2467
- strapi.log.warn(`[hubspot] Forms API submit failed — ${err.message}`);
2540
+ conversionError = err.message;
2541
+ strapi.log.warn(`[hubspot] Forms API submit failed — ${conversionError}`);
2468
2542
  }
2469
2543
  }
2470
2544
  if (!usedFormsApi) {
@@ -2524,7 +2598,7 @@ function createFormsService(strapi, _opts = {}) {
2524
2598
  strapi.log.warn(`[hubspot] company sync failed (contact OK) — ${err.message}`);
2525
2599
  }
2526
2600
  }
2527
- if (contactId && config2.timelineNote && !usedFormsApi) {
2601
+ if (contactId && policy.timelineNote) {
2528
2602
  try {
2529
2603
  await createLeadNote(apiKey, {
2530
2604
  contactId,
@@ -2546,7 +2620,11 @@ function createFormsService(strapi, _opts = {}) {
2546
2620
  formTitle: form.title || form.name,
2547
2621
  email,
2548
2622
  values: resolution.values,
2549
- meta,
2623
+ meta: {
2624
+ ...meta,
2625
+ hubspotConversion: usedFormsApi,
2626
+ ...conversionError ? { hubspotError: conversionError } : {}
2627
+ },
2550
2628
  locale: form.locale ?? null,
2551
2629
  hubspotSynced,
2552
2630
  contactId,
@@ -2733,7 +2811,7 @@ function createFormsAdminController(strapi) {
2733
2811
  locale: ctx.query.locale,
2734
2812
  status: "published"
2735
2813
  });
2736
- ctx.body = { form: entry, published: Boolean(published) };
2814
+ ctx.body = { form: entry, published: Boolean(published), publishedHubspotFormId: published?.hubspotFormId ?? null };
2737
2815
  },
2738
2816
  async create(ctx) {
2739
2817
  const body = ctx.request.body ?? {};
@@ -3333,8 +3411,8 @@ const config = {
3333
3411
  // Submission pipeline of the built forms:
3334
3412
  // - companyFromDomain: a corporate email upserts the Company (deduped by
3335
3413
  // domain) and associates it to the contact;
3336
- // - timelineNote: recap note on the CRM-upsert fallback only (off when
3337
- // submissions go through the marketing Forms API);
3414
+ // - timelineNote: recap note on the contact after a successful sync
3415
+ // (page URL, subject, answers) — alongside a Forms API conversion;
3338
3416
  // - defaultFormId: portal-wide marketing form GUID when a builder form
3339
3417
  // doesn't set its own — swap with the portal at production cutover.
3340
3418
  // - submissionMode / writeExtraProperties / syncFieldsOnPublish: see
@@ -6,8 +6,9 @@
6
6
  * conversion (with leftover CRM writes for mapped fields the HubSpot form
7
7
  * dropped), CRM upsert only, or automatic (Forms API when a GUID is linked).
8
8
  * Company find-or-create still runs after a successful submit. Timeline notes
9
- * are opt-in and only used on the CRM-upsert path. The form submission is
10
- * the lead — a CRM outage never loses it.
9
+ * (page URL, subject, answers) are optional extra context on the contact,
10
+ * alongside a native conversion. The form submission is the lead — a CRM
11
+ * outage never loses it.
11
12
  */
12
13
  import type { Core } from "@strapi/strapi";
13
14
  import { type FormDefinition, type Primitive } from "./conditions";