@flamingo-stack/openframe-frontend-core 0.0.574 → 0.0.575-snapshot.20260827225444
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/meeting-scheduler/index.cjs +105 -98
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +103 -96
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/hooks/use-meeting-booking.d.ts +1 -1
- package/dist/hooks/use-meeting-booking.d.ts.map +1 -1
- package/dist/schemas/meeting-booking-schema.cjs +9 -0
- package/dist/schemas/meeting-booking-schema.cjs.map +1 -1
- package/dist/schemas/meeting-booking-schema.d.ts +10 -1
- package/dist/schemas/meeting-booking-schema.d.ts.map +1 -1
- package/dist/schemas/meeting-booking-schema.js +9 -1
- package/dist/schemas/meeting-booking-schema.js.map +1 -1
- package/package.json +1 -1
- package/src/components/meeting-scheduler/index.tsx +16 -14
- package/src/hooks/use-meeting-booking.ts +6 -13
- package/src/schemas/meeting-booking-schema.ts +18 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type BookingConfirmation, type MeetingAvailability, type MeetingBookingErrorCode } from '../schemas/meeting-booking-schema';
|
|
2
2
|
/**
|
|
3
3
|
* useMeetingBooking — data layer for `<HubSpotMeetingScheduler>` (the widget
|
|
4
4
|
* files never inline fetch; same split as `useContactSubmission`/ContactForm).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-meeting-booking.d.ts","sourceRoot":"","sources":["../../src/hooks/use-meeting-booking.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"use-meeting-booking.d.ts","sourceRoot":"","sources":["../../src/hooks/use-meeting-booking.ts"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,uBAAuB,EAC7B,MAAM,mCAAmC,CAAC;AAI3C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC,IAAI,CAAC,EAAE,uBAAuB,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAOD;0EAC0E;AAC1E,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,6DAEhG;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;CAC3C;;IAqGG,iFAAiF;;IAEjF,6EAA6E;;;;sCAhGnC,MAAM;;6BA+EhC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,OAAO,CAAC,aAAa,CAAC;;EA0BnE"}
|
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
var zod = require('zod');
|
|
4
4
|
|
|
5
5
|
// src/schemas/meeting-booking-schema.ts
|
|
6
|
+
var MEETING_BOOKING_ERROR_CODES = [
|
|
7
|
+
"SLOT_TAKEN",
|
|
8
|
+
"VALIDATION",
|
|
9
|
+
"INVALID_EMAIL",
|
|
10
|
+
"LINK_GONE",
|
|
11
|
+
"TEMPORARILY_UNAVAILABLE",
|
|
12
|
+
"MEETING_UNAVAILABLE"
|
|
13
|
+
];
|
|
6
14
|
var SUPPORTED_FORM_FIELD_TYPES = ["text", "textarea", "select", "radio", "checkbox"];
|
|
7
15
|
function isSupportedFormField(field) {
|
|
8
16
|
return SUPPORTED_FORM_FIELD_TYPES.includes(field.type);
|
|
@@ -89,6 +97,7 @@ function makeBookingSchema(formFields, legalConsent) {
|
|
|
89
97
|
});
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
exports.MEETING_BOOKING_ERROR_CODES = MEETING_BOOKING_ERROR_CODES;
|
|
92
101
|
exports.SUPPORTED_FORM_FIELD_TYPES = SUPPORTED_FORM_FIELD_TYPES;
|
|
93
102
|
exports.isSupportedFormField = isSupportedFormField;
|
|
94
103
|
exports.isValidBcp47Locale = isValidBcp47Locale;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/schemas/meeting-booking-schema.ts"],"names":["z"],"mappings":"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","file":"meeting-booking-schema.cjs","sourcesContent":["import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n meetingId: string;\n monthOffset: number;\n hasMore: boolean;\n /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n durationsMs: number[];\n /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n slotsByDurationMs: Record<string, number[]>;\n formFields: MeetingFormField[];\n /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n legalConsent: MeetingLegalConsent | null;\n /**\n * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n * builds from its own people data (e.g. a profiles table matched\n * server-side). NEVER carries emails or busy-time data; optional so\n * existing hosts stay wire-compatible.\n */\n hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n name: string;\n avatarUrl: string | null;\n /** Job title / role line under the name (null → omitted). */\n title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n id: string;\n /** The link's public HubSpot booking URL — escape-hatch target only. */\n link: string;\n /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n slug: string;\n /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n purpose: string;\n title: string;\n description: string | null;\n kind: 'personal' | 'team';\n /** Display-only minutes projection (booking stays ms end-to-end). */\n durationsMinutes: number[];\n hosts: MeetingHost[];\n /** Earliest bookable slot (epoch ms) from the current-month payload. */\n nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n fetchedAt: string;\n}\n\nexport interface MeetingFormField {\n name: string;\n label: string;\n type: string;\n required: boolean;\n options?: string[];\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n processingConsentText: string;\n processingConsentCheckboxLabel: string | null;\n communicationConsentText: string | null;\n communicationConsentCheckboxes: Array<{\n communicationTypeId: string;\n label: string;\n required: boolean;\n }>;\n privacyPolicyText: string | null;\n isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n meetingId: string;\n title: string;\n startTimeMs: number;\n durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport type MeetingBookingErrorCode =\n 'SLOT_TAKEN' | 'VALIDATION' | 'INVALID_EMAIL' | 'LINK_GONE' | 'TEMPORARILY_UNAVAILABLE' | 'MEETING_UNAVAILABLE';\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * HubSpot custom-question types the native form supports. The widget's\n * renderer switches over THIS set and `makeBookingSchema` maps over THIS set;\n * fail-closed = a field whose `type` is not in the set (the widget then\n * renders the \"Open in HubSpot\" escape hatch for that link instead of a\n * half-working native form). Exact upstream type strings are pinned against\n * the rollout fixture link — extend here (renderer + validator move together).\n */\nexport const SUPPORTED_FORM_FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox'] as const;\nexport type SupportedFormFieldType = (typeof SUPPORTED_FORM_FIELD_TYPES)[number];\n\nexport function isSupportedFormField(field: MeetingFormField): boolean {\n return (SUPPORTED_FORM_FIELD_TYPES as readonly string[]).includes(field.type);\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n if (!IANA_TZ_RE.test(tz)) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n try {\n return Intl.getCanonicalLocales(locale).length > 0;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n const answers: Record<string, z.ZodTypeAny> = {};\n for (const field of formFields) {\n if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time\n let validator: z.ZodTypeAny;\n switch (field.type as SupportedFormFieldType) {\n case 'checkbox':\n validator = z.boolean();\n break;\n case 'select':\n case 'radio':\n validator = z.string().refine(v => !v || (field.options ?? []).includes(v), {\n message: `Please choose a valid option for ${field.label}`,\n });\n break;\n case 'textarea':\n validator = z.string().max(5000, { message: `${field.label} is too long` });\n break;\n case 'text':\n default:\n validator = z.string().max(1000, { message: `${field.label} is too long` });\n break;\n }\n if (field.required) {\n validator =\n field.type === 'checkbox'\n ? z.literal(true, { message: `${field.label} is required` })\n : (validator as z.ZodString).min(1, { message: `${field.label} is required` });\n } else if (field.type !== 'checkbox') {\n validator = (validator as z.ZodString).optional().or(z.literal(''));\n } else {\n validator = z.boolean().optional();\n }\n answers[field.name] = validator;\n }\n\n const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n .filter(c => c.required)\n .map(c => c.communicationTypeId);\n\n // A required answer cannot be enforced by an `.optional()` parent object —\n // omitting the `formFields` key entirely would skip every per-question\n // rule. When the link declares at least one required supported question,\n // the object itself is required.\n const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);\n const answersObject = z.object(answers);\n\n return (\n z\n .object({\n meetingId: z.string().min(1),\n startTimeMs: z.number().int().positive(),\n durationMs: z.number().int().positive(),\n firstName: z.string().min(1, { message: 'First name is required' }).max(255),\n lastName: z.string().min(1, { message: 'Last name is required' }).max(255),\n email: z.string().email({ message: 'Please enter a valid email address' }).max(255),\n timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' }),\n locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n // Plain `.optional()` (no `.default()`) so zod's input and output types\n // match — react-hook-form's zodResolver needs them identical, and the\n // server handles `undefined` explicitly anyway. REQUIRED when the link\n // declares required questions (see `hasRequiredAnswers` above).\n formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n legalConsentResponses: z\n .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n .optional(),\n })\n // Object-level rule: `.refine` on an `.optional()` field is skipped when\n // the field is absent — required consents must reject even on a payload\n // that omits the array entirely.\n .superRefine((data, ctx) => {\n const responses = data.legalConsentResponses ?? [];\n const ok = requiredConsentIds.every(id =>\n responses.some(r => r.communicationTypeId === id && r.consented === true),\n );\n if (!ok) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['legalConsentResponses'],\n message: 'Required consent checkboxes must be accepted',\n });\n }\n })\n );\n}\n\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/schemas/meeting-booking-schema.ts"],"names":["z"],"mappings":"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","file":"meeting-booking-schema.cjs","sourcesContent":["import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n meetingId: string;\n monthOffset: number;\n hasMore: boolean;\n /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n durationsMs: number[];\n /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n slotsByDurationMs: Record<string, number[]>;\n formFields: MeetingFormField[];\n /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n legalConsent: MeetingLegalConsent | null;\n /**\n * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n * builds from its own people data (e.g. a profiles table matched\n * server-side). NEVER carries emails or busy-time data; optional so\n * existing hosts stay wire-compatible.\n */\n hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n name: string;\n avatarUrl: string | null;\n /** Job title / role line under the name (null → omitted). */\n title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n id: string;\n /** The link's public HubSpot booking URL — escape-hatch target only. */\n link: string;\n /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n slug: string;\n /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n purpose: string;\n title: string;\n description: string | null;\n kind: 'personal' | 'team';\n /** Display-only minutes projection (booking stays ms end-to-end). */\n durationsMinutes: number[];\n hosts: MeetingHost[];\n /** Earliest bookable slot (epoch ms) from the current-month payload. */\n nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n fetchedAt: string;\n}\n\nexport interface MeetingFormField {\n name: string;\n label: string;\n type: string;\n required: boolean;\n options?: string[];\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n processingConsentText: string;\n processingConsentCheckboxLabel: string | null;\n communicationConsentText: string | null;\n communicationConsentCheckboxes: Array<{\n communicationTypeId: string;\n label: string;\n required: boolean;\n }>;\n privacyPolicyText: string | null;\n isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n meetingId: string;\n title: string;\n startTimeMs: number;\n durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport const MEETING_BOOKING_ERROR_CODES = [\n 'SLOT_TAKEN',\n 'VALIDATION',\n 'INVALID_EMAIL',\n 'LINK_GONE',\n 'TEMPORARILY_UNAVAILABLE',\n 'MEETING_UNAVAILABLE',\n] as const;\n\n/**\n * Derived from the runtime array ABOVE — the array is the single source of\n * truth. The booking hook validates server codes against it at runtime, so a\n * code added only to a hand-written type would be silently coerced to\n * TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the\n * INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime\n * allowlist stale — an array annotation is not exhaustiveness-checked).\n */\nexport type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * HubSpot custom-question types the native form supports. The widget's\n * renderer switches over THIS set and `makeBookingSchema` maps over THIS set;\n * fail-closed = a field whose `type` is not in the set (the widget then\n * renders the \"Open in HubSpot\" escape hatch for that link instead of a\n * half-working native form). Exact upstream type strings are pinned against\n * the rollout fixture link — extend here (renderer + validator move together).\n */\nexport const SUPPORTED_FORM_FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox'] as const;\nexport type SupportedFormFieldType = (typeof SUPPORTED_FORM_FIELD_TYPES)[number];\n\nexport function isSupportedFormField(field: MeetingFormField): boolean {\n return (SUPPORTED_FORM_FIELD_TYPES as readonly string[]).includes(field.type);\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n if (!IANA_TZ_RE.test(tz)) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n try {\n return Intl.getCanonicalLocales(locale).length > 0;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n const answers: Record<string, z.ZodTypeAny> = {};\n for (const field of formFields) {\n if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time\n let validator: z.ZodTypeAny;\n switch (field.type as SupportedFormFieldType) {\n case 'checkbox':\n validator = z.boolean();\n break;\n case 'select':\n case 'radio':\n validator = z.string().refine(v => !v || (field.options ?? []).includes(v), {\n message: `Please choose a valid option for ${field.label}`,\n });\n break;\n case 'textarea':\n validator = z.string().max(5000, { message: `${field.label} is too long` });\n break;\n case 'text':\n default:\n validator = z.string().max(1000, { message: `${field.label} is too long` });\n break;\n }\n if (field.required) {\n validator =\n field.type === 'checkbox'\n ? z.literal(true, { message: `${field.label} is required` })\n : (validator as z.ZodString).min(1, { message: `${field.label} is required` });\n } else if (field.type !== 'checkbox') {\n validator = (validator as z.ZodString).optional().or(z.literal(''));\n } else {\n validator = z.boolean().optional();\n }\n answers[field.name] = validator;\n }\n\n const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n .filter(c => c.required)\n .map(c => c.communicationTypeId);\n\n // A required answer cannot be enforced by an `.optional()` parent object —\n // omitting the `formFields` key entirely would skip every per-question\n // rule. When the link declares at least one required supported question,\n // the object itself is required.\n const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);\n const answersObject = z.object(answers);\n\n return (\n z\n .object({\n meetingId: z.string().min(1),\n startTimeMs: z.number().int().positive(),\n durationMs: z.number().int().positive(),\n firstName: z.string().min(1, { message: 'First name is required' }).max(255),\n lastName: z.string().min(1, { message: 'Last name is required' }).max(255),\n email: z.string().email({ message: 'Please enter a valid email address' }).max(255),\n timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' }),\n locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n // Plain `.optional()` (no `.default()`) so zod's input and output types\n // match — react-hook-form's zodResolver needs them identical, and the\n // server handles `undefined` explicitly anyway. REQUIRED when the link\n // declares required questions (see `hasRequiredAnswers` above).\n formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n legalConsentResponses: z\n .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n .optional(),\n })\n // Object-level rule: `.refine` on an `.optional()` field is skipped when\n // the field is absent — required consents must reject even on a payload\n // that omits the array entirely.\n .superRefine((data, ctx) => {\n const responses = data.legalConsentResponses ?? [];\n const ok = requiredConsentIds.every(id =>\n responses.some(r => r.communicationTypeId === id && r.consented === true),\n );\n if (!ok) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['legalConsentResponses'],\n message: 'Required consent checkboxes must be accepted',\n });\n }\n })\n );\n}\n\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n"]}
|
|
@@ -125,7 +125,16 @@ export interface BookingConfirmation {
|
|
|
125
125
|
* mailboxes), so the widget tells the visitor to fix the email instead of
|
|
126
126
|
* blaming the slot or their other details.
|
|
127
127
|
*/
|
|
128
|
-
export
|
|
128
|
+
export declare const MEETING_BOOKING_ERROR_CODES: readonly ["SLOT_TAKEN", "VALIDATION", "INVALID_EMAIL", "LINK_GONE", "TEMPORARILY_UNAVAILABLE", "MEETING_UNAVAILABLE"];
|
|
129
|
+
/**
|
|
130
|
+
* Derived from the runtime array ABOVE — the array is the single source of
|
|
131
|
+
* truth. The booking hook validates server codes against it at runtime, so a
|
|
132
|
+
* code added only to a hand-written type would be silently coerced to
|
|
133
|
+
* TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the
|
|
134
|
+
* INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime
|
|
135
|
+
* allowlist stale — an array annotation is not exhaustiveness-checked).
|
|
136
|
+
*/
|
|
137
|
+
export type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];
|
|
129
138
|
/**
|
|
130
139
|
* HubSpot custom-question types the native form supports. The widget's
|
|
131
140
|
* renderer switches over THIS set and `makeBookingSchema` maps over THIS set;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meeting-booking-schema.d.ts","sourceRoot":"","sources":["../../src/schemas/meeting-booking-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;GAcG;AAMH;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,4EAA4E;IAC5E,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC5C,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,8GAA8G;IAC9G,YAAY,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACzC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,WAAW,EAAE,CAAC;CACvB;AAED,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,6DAA6D;IAC7D,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,IAAI,EAAE,UAAU,GAAG,MAAM,CAAC;IAC1B,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,wEAAwE;IACxE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC,CAAC;IAC7E,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,qBAAqB,EAAE,MAAM,CAAC;IAC9B,8BAA8B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9C,wBAAwB,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,8BAA8B,EAAE,KAAK,CAAC;QACpC,mBAAmB,EAAE,MAAM,CAAC;QAC5B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC,CAAC;IACH,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,
|
|
1
|
+
{"version":3,"file":"meeting-booking-schema.d.ts","sourceRoot":"","sources":["../../src/schemas/meeting-booking-schema.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
// src/schemas/meeting-booking-schema.ts
|
|
4
|
+
var MEETING_BOOKING_ERROR_CODES = [
|
|
5
|
+
"SLOT_TAKEN",
|
|
6
|
+
"VALIDATION",
|
|
7
|
+
"INVALID_EMAIL",
|
|
8
|
+
"LINK_GONE",
|
|
9
|
+
"TEMPORARILY_UNAVAILABLE",
|
|
10
|
+
"MEETING_UNAVAILABLE"
|
|
11
|
+
];
|
|
4
12
|
var SUPPORTED_FORM_FIELD_TYPES = ["text", "textarea", "select", "radio", "checkbox"];
|
|
5
13
|
function isSupportedFormField(field) {
|
|
6
14
|
return SUPPORTED_FORM_FIELD_TYPES.includes(field.type);
|
|
@@ -87,6 +95,6 @@ function makeBookingSchema(formFields, legalConsent) {
|
|
|
87
95
|
});
|
|
88
96
|
}
|
|
89
97
|
|
|
90
|
-
export { SUPPORTED_FORM_FIELD_TYPES, isSupportedFormField, isValidBcp47Locale, isValidIanaTimezone, makeBookingSchema };
|
|
98
|
+
export { MEETING_BOOKING_ERROR_CODES, SUPPORTED_FORM_FIELD_TYPES, isSupportedFormField, isValidBcp47Locale, isValidIanaTimezone, makeBookingSchema };
|
|
91
99
|
//# sourceMappingURL=meeting-booking-schema.js.map
|
|
92
100
|
//# sourceMappingURL=meeting-booking-schema.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/schemas/meeting-booking-schema.ts"],"names":[],"mappings":"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","file":"meeting-booking-schema.js","sourcesContent":["import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n meetingId: string;\n monthOffset: number;\n hasMore: boolean;\n /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n durationsMs: number[];\n /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n slotsByDurationMs: Record<string, number[]>;\n formFields: MeetingFormField[];\n /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n legalConsent: MeetingLegalConsent | null;\n /**\n * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n * builds from its own people data (e.g. a profiles table matched\n * server-side). NEVER carries emails or busy-time data; optional so\n * existing hosts stay wire-compatible.\n */\n hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n name: string;\n avatarUrl: string | null;\n /** Job title / role line under the name (null → omitted). */\n title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n id: string;\n /** The link's public HubSpot booking URL — escape-hatch target only. */\n link: string;\n /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n slug: string;\n /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n purpose: string;\n title: string;\n description: string | null;\n kind: 'personal' | 'team';\n /** Display-only minutes projection (booking stays ms end-to-end). */\n durationsMinutes: number[];\n hosts: MeetingHost[];\n /** Earliest bookable slot (epoch ms) from the current-month payload. */\n nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n fetchedAt: string;\n}\n\nexport interface MeetingFormField {\n name: string;\n label: string;\n type: string;\n required: boolean;\n options?: string[];\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n processingConsentText: string;\n processingConsentCheckboxLabel: string | null;\n communicationConsentText: string | null;\n communicationConsentCheckboxes: Array<{\n communicationTypeId: string;\n label: string;\n required: boolean;\n }>;\n privacyPolicyText: string | null;\n isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n meetingId: string;\n title: string;\n startTimeMs: number;\n durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport type MeetingBookingErrorCode =\n 'SLOT_TAKEN' | 'VALIDATION' | 'INVALID_EMAIL' | 'LINK_GONE' | 'TEMPORARILY_UNAVAILABLE' | 'MEETING_UNAVAILABLE';\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * HubSpot custom-question types the native form supports. The widget's\n * renderer switches over THIS set and `makeBookingSchema` maps over THIS set;\n * fail-closed = a field whose `type` is not in the set (the widget then\n * renders the \"Open in HubSpot\" escape hatch for that link instead of a\n * half-working native form). Exact upstream type strings are pinned against\n * the rollout fixture link — extend here (renderer + validator move together).\n */\nexport const SUPPORTED_FORM_FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox'] as const;\nexport type SupportedFormFieldType = (typeof SUPPORTED_FORM_FIELD_TYPES)[number];\n\nexport function isSupportedFormField(field: MeetingFormField): boolean {\n return (SUPPORTED_FORM_FIELD_TYPES as readonly string[]).includes(field.type);\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n if (!IANA_TZ_RE.test(tz)) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n try {\n return Intl.getCanonicalLocales(locale).length > 0;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n const answers: Record<string, z.ZodTypeAny> = {};\n for (const field of formFields) {\n if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time\n let validator: z.ZodTypeAny;\n switch (field.type as SupportedFormFieldType) {\n case 'checkbox':\n validator = z.boolean();\n break;\n case 'select':\n case 'radio':\n validator = z.string().refine(v => !v || (field.options ?? []).includes(v), {\n message: `Please choose a valid option for ${field.label}`,\n });\n break;\n case 'textarea':\n validator = z.string().max(5000, { message: `${field.label} is too long` });\n break;\n case 'text':\n default:\n validator = z.string().max(1000, { message: `${field.label} is too long` });\n break;\n }\n if (field.required) {\n validator =\n field.type === 'checkbox'\n ? z.literal(true, { message: `${field.label} is required` })\n : (validator as z.ZodString).min(1, { message: `${field.label} is required` });\n } else if (field.type !== 'checkbox') {\n validator = (validator as z.ZodString).optional().or(z.literal(''));\n } else {\n validator = z.boolean().optional();\n }\n answers[field.name] = validator;\n }\n\n const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n .filter(c => c.required)\n .map(c => c.communicationTypeId);\n\n // A required answer cannot be enforced by an `.optional()` parent object —\n // omitting the `formFields` key entirely would skip every per-question\n // rule. When the link declares at least one required supported question,\n // the object itself is required.\n const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);\n const answersObject = z.object(answers);\n\n return (\n z\n .object({\n meetingId: z.string().min(1),\n startTimeMs: z.number().int().positive(),\n durationMs: z.number().int().positive(),\n firstName: z.string().min(1, { message: 'First name is required' }).max(255),\n lastName: z.string().min(1, { message: 'Last name is required' }).max(255),\n email: z.string().email({ message: 'Please enter a valid email address' }).max(255),\n timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' }),\n locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n // Plain `.optional()` (no `.default()`) so zod's input and output types\n // match — react-hook-form's zodResolver needs them identical, and the\n // server handles `undefined` explicitly anyway. REQUIRED when the link\n // declares required questions (see `hasRequiredAnswers` above).\n formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n legalConsentResponses: z\n .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n .optional(),\n })\n // Object-level rule: `.refine` on an `.optional()` field is skipped when\n // the field is absent — required consents must reject even on a payload\n // that omits the array entirely.\n .superRefine((data, ctx) => {\n const responses = data.legalConsentResponses ?? [];\n const ok = requiredConsentIds.every(id =>\n responses.some(r => r.communicationTypeId === id && r.consented === true),\n );\n if (!ok) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['legalConsentResponses'],\n message: 'Required consent checkboxes must be accepted',\n });\n }\n })\n );\n}\n\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/schemas/meeting-booking-schema.ts"],"names":[],"mappings":"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","file":"meeting-booking-schema.js","sourcesContent":["import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n meetingId: string;\n monthOffset: number;\n hasMore: boolean;\n /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n durationsMs: number[];\n /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n slotsByDurationMs: Record<string, number[]>;\n formFields: MeetingFormField[];\n /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n legalConsent: MeetingLegalConsent | null;\n /**\n * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n * builds from its own people data (e.g. a profiles table matched\n * server-side). NEVER carries emails or busy-time data; optional so\n * existing hosts stay wire-compatible.\n */\n hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n name: string;\n avatarUrl: string | null;\n /** Job title / role line under the name (null → omitted). */\n title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n id: string;\n /** The link's public HubSpot booking URL — escape-hatch target only. */\n link: string;\n /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n slug: string;\n /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n purpose: string;\n title: string;\n description: string | null;\n kind: 'personal' | 'team';\n /** Display-only minutes projection (booking stays ms end-to-end). */\n durationsMinutes: number[];\n hosts: MeetingHost[];\n /** Earliest bookable slot (epoch ms) from the current-month payload. */\n nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n fetchedAt: string;\n}\n\nexport interface MeetingFormField {\n name: string;\n label: string;\n type: string;\n required: boolean;\n options?: string[];\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n processingConsentText: string;\n processingConsentCheckboxLabel: string | null;\n communicationConsentText: string | null;\n communicationConsentCheckboxes: Array<{\n communicationTypeId: string;\n label: string;\n required: boolean;\n }>;\n privacyPolicyText: string | null;\n isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n meetingId: string;\n title: string;\n startTimeMs: number;\n durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport const MEETING_BOOKING_ERROR_CODES = [\n 'SLOT_TAKEN',\n 'VALIDATION',\n 'INVALID_EMAIL',\n 'LINK_GONE',\n 'TEMPORARILY_UNAVAILABLE',\n 'MEETING_UNAVAILABLE',\n] as const;\n\n/**\n * Derived from the runtime array ABOVE — the array is the single source of\n * truth. The booking hook validates server codes against it at runtime, so a\n * code added only to a hand-written type would be silently coerced to\n * TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the\n * INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime\n * allowlist stale — an array annotation is not exhaustiveness-checked).\n */\nexport type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * HubSpot custom-question types the native form supports. The widget's\n * renderer switches over THIS set and `makeBookingSchema` maps over THIS set;\n * fail-closed = a field whose `type` is not in the set (the widget then\n * renders the \"Open in HubSpot\" escape hatch for that link instead of a\n * half-working native form). Exact upstream type strings are pinned against\n * the rollout fixture link — extend here (renderer + validator move together).\n */\nexport const SUPPORTED_FORM_FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox'] as const;\nexport type SupportedFormFieldType = (typeof SUPPORTED_FORM_FIELD_TYPES)[number];\n\nexport function isSupportedFormField(field: MeetingFormField): boolean {\n return (SUPPORTED_FORM_FIELD_TYPES as readonly string[]).includes(field.type);\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n if (!IANA_TZ_RE.test(tz)) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n try {\n return Intl.getCanonicalLocales(locale).length > 0;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n const answers: Record<string, z.ZodTypeAny> = {};\n for (const field of formFields) {\n if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time\n let validator: z.ZodTypeAny;\n switch (field.type as SupportedFormFieldType) {\n case 'checkbox':\n validator = z.boolean();\n break;\n case 'select':\n case 'radio':\n validator = z.string().refine(v => !v || (field.options ?? []).includes(v), {\n message: `Please choose a valid option for ${field.label}`,\n });\n break;\n case 'textarea':\n validator = z.string().max(5000, { message: `${field.label} is too long` });\n break;\n case 'text':\n default:\n validator = z.string().max(1000, { message: `${field.label} is too long` });\n break;\n }\n if (field.required) {\n validator =\n field.type === 'checkbox'\n ? z.literal(true, { message: `${field.label} is required` })\n : (validator as z.ZodString).min(1, { message: `${field.label} is required` });\n } else if (field.type !== 'checkbox') {\n validator = (validator as z.ZodString).optional().or(z.literal(''));\n } else {\n validator = z.boolean().optional();\n }\n answers[field.name] = validator;\n }\n\n const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n .filter(c => c.required)\n .map(c => c.communicationTypeId);\n\n // A required answer cannot be enforced by an `.optional()` parent object —\n // omitting the `formFields` key entirely would skip every per-question\n // rule. When the link declares at least one required supported question,\n // the object itself is required.\n const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);\n const answersObject = z.object(answers);\n\n return (\n z\n .object({\n meetingId: z.string().min(1),\n startTimeMs: z.number().int().positive(),\n durationMs: z.number().int().positive(),\n firstName: z.string().min(1, { message: 'First name is required' }).max(255),\n lastName: z.string().min(1, { message: 'Last name is required' }).max(255),\n email: z.string().email({ message: 'Please enter a valid email address' }).max(255),\n timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' }),\n locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n // Plain `.optional()` (no `.default()`) so zod's input and output types\n // match — react-hook-form's zodResolver needs them identical, and the\n // server handles `undefined` explicitly anyway. REQUIRED when the link\n // declares required questions (see `hasRequiredAnswers` above).\n formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n legalConsentResponses: z\n .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n .optional(),\n })\n // Object-level rule: `.refine` on an `.optional()` field is skipped when\n // the field is absent — required consents must reject even on a payload\n // that omits the array entirely.\n .superRefine((data, ctx) => {\n const responses = data.legalConsentResponses ?? [];\n const ok = requiredConsentIds.every(id =>\n responses.some(r => r.communicationTypeId === id && r.consented === true),\n );\n if (!ok) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['legalConsentResponses'],\n message: 'Required consent checkboxes must be accepted',\n });\n }\n })\n );\n}\n\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n"]}
|
package/package.json
CHANGED
|
@@ -39,6 +39,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
39
39
|
import { useIsHydrated } from '../../hooks/ui/use-is-hydrated';
|
|
40
40
|
import { useHumanitySignals } from '../../hooks/use-humanity-signals';
|
|
41
41
|
import { useMeetingBooking } from '../../hooks/use-meeting-booking';
|
|
42
|
+
import { useToast } from '../../hooks/use-toast';
|
|
42
43
|
import {
|
|
43
44
|
isSupportedFormField,
|
|
44
45
|
type BookingConfirmation,
|
|
@@ -56,11 +57,11 @@ import { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';
|
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
59
|
* Visitor-facing copy per booking-error code. Everything except SLOT_TAKEN
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* the calendar instead: its recovery flow returns the visitor there.
|
|
63
|
-
*
|
|
60
|
+
* surfaces as an error TOAST at submit time, and ONLY as a toast (owner
|
|
61
|
+
* decision 2026-08-27 — no inline duplicate; hosts must mount the lib
|
|
62
|
+
* Toaster, as every hub platform does globally). SLOT_TAKEN renders above
|
|
63
|
+
* the calendar instead: its recovery flow returns the visitor there. An
|
|
64
|
+
* unrecognized code (newer host than widget) falls back to the VALIDATION
|
|
64
65
|
* copy — fail-safe, never blank.
|
|
65
66
|
*/
|
|
66
67
|
const BOOKING_ERROR_COPY: Record<Exclude<MeetingBookingErrorCode, 'SLOT_TAKEN'>, string> = {
|
|
@@ -304,6 +305,7 @@ export function HubSpotMeetingScheduler({
|
|
|
304
305
|
const [bookingError, setBookingError] = useState<MeetingBookingErrorCode | null>(null);
|
|
305
306
|
|
|
306
307
|
const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals();
|
|
308
|
+
const { toast } = useToast();
|
|
307
309
|
|
|
308
310
|
// Reset the machine when the host switches links. Adjusted while rendering —
|
|
309
311
|
// React's documented pattern for a prop-driven reset — so the swapped-in link
|
|
@@ -413,9 +415,17 @@ export function HubSpotMeetingScheduler({
|
|
|
413
415
|
setStep('slot');
|
|
414
416
|
resetSignals();
|
|
415
417
|
void refetchAvailability();
|
|
418
|
+
} else {
|
|
419
|
+
// The error surface is the TOAST, full stop (host-mounted Toaster —
|
|
420
|
+
// every hub platform mounts it globally; embedders must too).
|
|
421
|
+
toast({
|
|
422
|
+
variant: 'error',
|
|
423
|
+
title: 'Booking failed',
|
|
424
|
+
description: BOOKING_ERROR_COPY[code] ?? BOOKING_ERROR_COPY.VALIDATION,
|
|
425
|
+
});
|
|
416
426
|
}
|
|
417
427
|
},
|
|
418
|
-
[book, onBooked, refetchAvailability, resetSignals],
|
|
428
|
+
[book, onBooked, refetchAvailability, resetSignals, toast],
|
|
419
429
|
);
|
|
420
430
|
|
|
421
431
|
const escapeHatch = fallbackUrl ? (
|
|
@@ -525,14 +535,6 @@ export function HubSpotMeetingScheduler({
|
|
|
525
535
|
}).format(new Date(selectedSlot))}{' '}
|
|
526
536
|
· {formatDurationCompact(durationMs / 1000)}
|
|
527
537
|
</p>
|
|
528
|
-
{bookingError && bookingError !== 'SLOT_TAKEN' && (
|
|
529
|
-
<Alert variant="error" className="flex flex-col items-start gap-[var(--spacing-system-xs)]">
|
|
530
|
-
<AlertDescription>
|
|
531
|
-
{BOOKING_ERROR_COPY[bookingError] ?? BOOKING_ERROR_COPY.VALIDATION}
|
|
532
|
-
</AlertDescription>
|
|
533
|
-
{escapeHatch}
|
|
534
|
-
</Alert>
|
|
535
|
-
)}
|
|
536
538
|
<BookingForm
|
|
537
539
|
availability={availability}
|
|
538
540
|
meetingId={meetingId}
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
4
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import {
|
|
6
|
+
MEETING_BOOKING_ERROR_CODES,
|
|
7
|
+
type BookingConfirmation,
|
|
8
|
+
type MeetingAvailability,
|
|
9
|
+
type MeetingBookingErrorCode,
|
|
9
10
|
} from '../schemas/meeting-booking-schema';
|
|
10
11
|
import { contentFetch } from '../utils/embed-content-fetch';
|
|
11
12
|
import { MAX_MONTH_OFFSET } from '../utils/hubspot-meetings-convention';
|
|
@@ -52,14 +53,6 @@ export interface BookingResult {
|
|
|
52
53
|
message?: string;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const KNOWN_CODES: MeetingBookingErrorCode[] = [
|
|
56
|
-
'SLOT_TAKEN',
|
|
57
|
-
'VALIDATION',
|
|
58
|
-
'LINK_GONE',
|
|
59
|
-
'TEMPORARILY_UNAVAILABLE',
|
|
60
|
-
'MEETING_UNAVAILABLE',
|
|
61
|
-
];
|
|
62
|
-
|
|
63
56
|
/** Always refetch — availability is served live (no-store) by the host; a
|
|
64
57
|
* cached month shown while paging back is exactly the staleness the server
|
|
65
58
|
* layers were stripped to kill (2026-08-27). */
|
|
@@ -139,7 +132,7 @@ export function useMeetingBooking(options: {
|
|
|
139
132
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
140
133
|
if (res.ok) return { ok: true, confirmation: data as unknown as BookingConfirmation };
|
|
141
134
|
const rawCode = typeof data.code === 'string' ? data.code : '';
|
|
142
|
-
const code = (
|
|
135
|
+
const code = (MEETING_BOOKING_ERROR_CODES as readonly string[]).includes(rawCode)
|
|
143
136
|
? (rawCode as MeetingBookingErrorCode)
|
|
144
137
|
: 'TEMPORARILY_UNAVAILABLE';
|
|
145
138
|
return { ok: false, code, message: typeof data.error === 'string' ? data.error : undefined };
|
|
@@ -134,8 +134,24 @@ export interface BookingConfirmation {
|
|
|
134
134
|
* mailboxes), so the widget tells the visitor to fix the email instead of
|
|
135
135
|
* blaming the slot or their other details.
|
|
136
136
|
*/
|
|
137
|
-
export
|
|
138
|
-
'SLOT_TAKEN'
|
|
137
|
+
export const MEETING_BOOKING_ERROR_CODES = [
|
|
138
|
+
'SLOT_TAKEN',
|
|
139
|
+
'VALIDATION',
|
|
140
|
+
'INVALID_EMAIL',
|
|
141
|
+
'LINK_GONE',
|
|
142
|
+
'TEMPORARILY_UNAVAILABLE',
|
|
143
|
+
'MEETING_UNAVAILABLE',
|
|
144
|
+
] as const;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Derived from the runtime array ABOVE — the array is the single source of
|
|
148
|
+
* truth. The booking hook validates server codes against it at runtime, so a
|
|
149
|
+
* code added only to a hand-written type would be silently coerced to
|
|
150
|
+
* TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the
|
|
151
|
+
* INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime
|
|
152
|
+
* allowlist stale — an array annotation is not exhaustiveness-checked).
|
|
153
|
+
*/
|
|
154
|
+
export type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];
|
|
139
155
|
|
|
140
156
|
// ---------------------------------------------------------------------------
|
|
141
157
|
// Field-type vocabulary — ONE set drives the renderer AND the validator
|