riverbankcms 0.1.0 → 0.2.2
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/acceptance/matrix.mjs +86 -22
- package/dist/cli/index.mjs +18913 -14808
- package/dist/cli/init-docs/content/agents-section.md +9 -2
- package/dist/cli/init-docs/content/cli-reference.md +77 -11
- package/dist/cli/init-docs/content/content-management.md +12 -3
- package/dist/cli/init-docs/content/getting-started.md +8 -10
- package/dist/cli/init-docs/content/workflow-add-block.md +8 -1
- package/dist/cli/init-docs/content/workflow-block-extensions.md +25 -9
- package/dist/cli/init-docs/content/workflow-custom-block.md +35 -8
- package/dist/cli/init-docs/content/workflow-isr-revalidation.md +26 -24
- package/dist/cli/init-docs/content/workflow-preview-mode.md +30 -6
- package/dist/cli/init-docs/content/workflow-remote-setup.md +66 -10
- package/dist/eventUpsertInputSchema-CzMX5BZI.mjs +4941 -0
- package/dist/load-config-226EuL6w.mjs +5 -0
- package/dist/{load-config-r1s4htWu.mjs → load-config-Boa0tkmk.mjs} +1 -1
- package/dist/plan-2c4zVtlW.mjs +2213 -0
- package/dist/scaffold/index.mjs +5 -0
- package/dist/sdk-event-upsert-BCZUdIrL.mjs +887 -0
- package/dist/{sdk-peer-DrblJmno.mjs → sdk-peer-DiZYsNXj.mjs} +6 -3
- package/dist/src-XEJYAKbQ.mjs +4697 -0
- package/dist/{style-Chv9Gcwq.mjs → style-3BjmymXd.mjs} +30532 -35235
- package/dist/testing.mjs +4 -2
- package/package.json +7 -2
- package/dist/load-config-CRB4AHKq.mjs +0 -5
|
@@ -0,0 +1,4697 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//#region ../core/src/result.ts
|
|
3
|
+
function ok(value) {
|
|
4
|
+
return {
|
|
5
|
+
ok: true,
|
|
6
|
+
value
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function err(error) {
|
|
10
|
+
return {
|
|
11
|
+
ok: false,
|
|
12
|
+
error
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region ../core/src/branded.ts
|
|
18
|
+
const UUID_PATTERN$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
+
function parseUuidBrand(value, unsafeConstructor) {
|
|
20
|
+
if (typeof value !== "string") {
|
|
21
|
+
return err({ kind: "not_string" });
|
|
22
|
+
}
|
|
23
|
+
const normalized = value.trim().toLowerCase();
|
|
24
|
+
if (normalized.length === 0) {
|
|
25
|
+
return err({ kind: "empty_string" });
|
|
26
|
+
}
|
|
27
|
+
if (!UUID_PATTERN$1.test(normalized)) {
|
|
28
|
+
return err({ kind: "invalid_uuid" });
|
|
29
|
+
}
|
|
30
|
+
return ok(unsafeConstructor(normalized));
|
|
31
|
+
}
|
|
32
|
+
function expectParsedBrandedId(result) {
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
throw new Error(`Expected a validated branded UUID (${result.error.kind}).`);
|
|
35
|
+
}
|
|
36
|
+
return result.value;
|
|
37
|
+
}
|
|
38
|
+
const unsafeAsEntryId = (id) => id;
|
|
39
|
+
/**
|
|
40
|
+
* Mint a preview-fixture entry id. Unsafe at the type level because the
|
|
41
|
+
* function can't verify the input string is actually shaped as a preview
|
|
42
|
+
* id — the predicate `isPreviewEntryId` is the recommended narrowing path.
|
|
43
|
+
*/
|
|
44
|
+
const unsafeAsPreviewEntryId = (id) => id;
|
|
45
|
+
const unsafeAsEventInstanceId = (id) => id;
|
|
46
|
+
const unsafeAsSiteId = (id) => id;
|
|
47
|
+
const unsafeAsApiKeyId = (id) => id;
|
|
48
|
+
const unsafeAsSiteRouteId = (id) => id;
|
|
49
|
+
const unsafeAsPageId = (id) => id;
|
|
50
|
+
const unsafeAsPageSurfaceId = (id) => id;
|
|
51
|
+
const unsafeAsOccurrenceId = (id) => id;
|
|
52
|
+
const unsafeAsEventOfferingId = (id) => id;
|
|
53
|
+
const unsafeAsEventSeriesId = (id) => id;
|
|
54
|
+
const unsafeAsEventPageRouteSlug = (value) => value;
|
|
55
|
+
const unsafeAsTicketTypeId = (id) => id;
|
|
56
|
+
const unsafeAsEventCategoryId = (id) => id;
|
|
57
|
+
const unsafeAsVenueId = (id) => id;
|
|
58
|
+
const unsafeAsPricingOptionId = (id) => id;
|
|
59
|
+
const unsafeAsMoneyDateString = (value) => value;
|
|
60
|
+
const unsafeAsStripeCheckoutSessionId = (value) => value;
|
|
61
|
+
const unsafeAsProductId = (id) => id;
|
|
62
|
+
const unsafeAsProductVariantId = (id) => id;
|
|
63
|
+
const unsafeAsProductCategoryId = (id) => id;
|
|
64
|
+
const unsafeAsMediaAssetId = (id) => id;
|
|
65
|
+
const unsafeAsUploadSessionId = (id) => id;
|
|
66
|
+
const unsafeAsStorageBucketName = (value) => value;
|
|
67
|
+
const unsafeAsStoragePath = (value) => value;
|
|
68
|
+
const unsafeAsRelativeStoragePath = (value) => value;
|
|
69
|
+
const unsafeAsSiteScopedStoragePath = (value) => value;
|
|
70
|
+
const unsafeAsFileChecksumSha256 = (value) => value;
|
|
71
|
+
const unsafeAsDetectedMimeType = (value) => value;
|
|
72
|
+
const unsafeAsDeclaredMimeType = (value) => value;
|
|
73
|
+
const unsafeAsOriginalFilename = (value) => value;
|
|
74
|
+
const unsafeAsSafeDownloadFilename = (value) => value;
|
|
75
|
+
const unsafeAsPositiveByteCount = (value) => value;
|
|
76
|
+
const unsafeAsPositiveSeconds = (value) => value;
|
|
77
|
+
const unsafeAsBookingSessionId = (id) => id;
|
|
78
|
+
const unsafeAsDatedOfferingCapacityHoldId = (value) => value;
|
|
79
|
+
const unsafeAsDatedOfferingCapacityHoldAuditRef = (value) => value;
|
|
80
|
+
const unsafeAsDatedOfferingRevocationAuditEventId = (value) => value;
|
|
81
|
+
const unsafeAsDatedOfferingNonCashProjectionClaimToken = (value) => value;
|
|
82
|
+
const unsafeAsDatedOfferingWaitlistCapacityEffectId = (value) => value;
|
|
83
|
+
const unsafeAsDatedOfferingWaitlistCapacityEffectClaimToken = (value) => value;
|
|
84
|
+
const unsafeAsDatedOfferingRunCancellationOperationId = (value) => value;
|
|
85
|
+
const unsafeAsEntitlementId = (id) => id;
|
|
86
|
+
const unsafeAsSiteFontId = (id) => id;
|
|
87
|
+
const unsafeAsUserId = (id) => id;
|
|
88
|
+
const unsafeAsFormId = (id) => id;
|
|
89
|
+
const unsafeAsFormSubmissionId = (id) => id;
|
|
90
|
+
const unsafeAsPassId = (id) => id;
|
|
91
|
+
const unsafeAsMembershipId = (id) => id;
|
|
92
|
+
const unsafeAsCourseId = (id) => id;
|
|
93
|
+
const unsafeAsCourseProgramId = (id) => id;
|
|
94
|
+
const unsafeAsCustomerId = (id) => id;
|
|
95
|
+
const unsafeAsOrderId = (id) => id;
|
|
96
|
+
const unsafeAsOrderItemId = (id) => id;
|
|
97
|
+
const unsafeAsOrderRefundId = (id) => id;
|
|
98
|
+
const unsafeAsOrderPaymentReceiptId = (id) => id;
|
|
99
|
+
const unsafeAsOrderFlexibleBalanceContractId = (id) => id;
|
|
100
|
+
const unsafeAsOrderPaymentPlanId = (id) => id;
|
|
101
|
+
const unsafeAsOrderPaymentPlanInstalmentId = (id) => id;
|
|
102
|
+
const unsafeAsManualPaymentOperationId = (id) => id;
|
|
103
|
+
const unsafeAsCheckoutLineId = (id) => id;
|
|
104
|
+
const unsafeAsAttendeeId = (id) => id;
|
|
105
|
+
const unsafeAsCustomerPassId = (id) => id;
|
|
106
|
+
const unsafeAsCustomerMembershipId = (id) => id;
|
|
107
|
+
const unsafeAsEnrollmentId = (id) => id;
|
|
108
|
+
const unsafeAsAppointmentId = (id) => id;
|
|
109
|
+
const unsafeAsAppointmentServiceId = (id) => id;
|
|
110
|
+
const unsafeAsAppointmentResourceId = (id) => id;
|
|
111
|
+
const unsafeAsAppointmentReservationId = (id) => id;
|
|
112
|
+
const unsafeAsAppointmentReservationRunId = (id) => id;
|
|
113
|
+
const unsafeAsAppointmentPaymentRequestId = (id) => id;
|
|
114
|
+
const unsafeAsStaffMemberId = (id) => id;
|
|
115
|
+
const unsafeAsBookingCalendarFeedTokenId = (id) => id;
|
|
116
|
+
const unsafeAsBookingCalendarFeedTokenPublicId = (id) => id;
|
|
117
|
+
const unsafeAsBookingCalendarFeedTokenSecret = (value) => value;
|
|
118
|
+
const unsafeAsBookingCalendarFeedTokenSecretHash = (value) => value;
|
|
119
|
+
const unsafeAsContentEntryId = (id) => id;
|
|
120
|
+
const unsafeAsContentBlockId = (id) => id;
|
|
121
|
+
const unsafeAsContentVersionId = (id) => id;
|
|
122
|
+
const unsafeAsEntryContentHash = (value) => value;
|
|
123
|
+
const unsafeAsStripeInvoiceId = (id) => id;
|
|
124
|
+
const unsafeAsNewsletterSubscriberId = (id) => id;
|
|
125
|
+
const unsafeAsNavigationMenuId = (id) => id;
|
|
126
|
+
const unsafeAsNavigationItemId = (id) => id;
|
|
127
|
+
const unsafeAsEmailAuditRowId = (id) => id;
|
|
128
|
+
const unsafeAsWaitlistEntryId = (id) => id;
|
|
129
|
+
const unsafeAsWaitlistHoldId = (id) => id;
|
|
130
|
+
const unsafeAsShipmentId = (id) => id;
|
|
131
|
+
const unsafeAsActivityLogEntryId = (id) => id;
|
|
132
|
+
const unsafeAsDemoBlueprintId = (id) => id;
|
|
133
|
+
const unsafeAsDemoBlueprintVersionId = (id) => id;
|
|
134
|
+
const unsafeAsDemoInstanceId = (id) => id;
|
|
135
|
+
const unsafeAsDemoClaimId = (id) => id;
|
|
136
|
+
const unsafeAsDemoLifecycleAuditEventId = (id) => id;
|
|
137
|
+
const unsafeAsDemoRuntimeId = (id) => id;
|
|
138
|
+
const unsafeAsDemoCapability = (value) => value;
|
|
139
|
+
const unsafeAsDemoPaymentFlowId = (id) => id;
|
|
140
|
+
const unsafeAsDemoClaimTtlHours = (value) => value;
|
|
141
|
+
const unsafeAsGiftingOfferId = (id) => id;
|
|
142
|
+
const unsafeAsGiftingInstrumentId = (id) => id;
|
|
143
|
+
const unsafeAsBookingRequirementId = (id) => id;
|
|
144
|
+
const unsafeAsBookingRequirementEvidenceId = (id) => id;
|
|
145
|
+
const unsafeAsBookingRequirementDocumentUploadId = (id) => id;
|
|
146
|
+
const unsafeAsBookingRequirementEvidenceAttachmentId = (id) => id;
|
|
147
|
+
const unsafeAsParticipantId = (id) => id;
|
|
148
|
+
const unsafeAsParticipantParticipationId = (id) => id;
|
|
149
|
+
const unsafeAsParticipantRosterAssertionJobId = (id) => id;
|
|
150
|
+
const unsafeAsParticipantBookingQuestionsTaskId = (id) => id;
|
|
151
|
+
const unsafeAsEventAttendeeGuestId = (id) => id;
|
|
152
|
+
const unsafeAsPersonRecordId = (id) => id;
|
|
153
|
+
const unsafeAsEncounterRecordId = (id) => id;
|
|
154
|
+
const unsafeAsRecordEntryId = (id) => id;
|
|
155
|
+
const unsafeAsProtectedPayloadKeyVersion = (id) => id;
|
|
156
|
+
const parseSiteId = (value) => parseUuidBrand(value, unsafeAsSiteId);
|
|
157
|
+
const parseApiKeyId = (value) => parseUuidBrand(value, unsafeAsApiKeyId);
|
|
158
|
+
const parseSiteRouteId = (value) => parseUuidBrand(value, unsafeAsSiteRouteId);
|
|
159
|
+
const parsePageId = (value) => parseUuidBrand(value, unsafeAsPageId);
|
|
160
|
+
const parsePageSurfaceId = (value) => parseUuidBrand(value, unsafeAsPageSurfaceId);
|
|
161
|
+
const parseOccurrenceId = (value) => parseUuidBrand(value, unsafeAsOccurrenceId);
|
|
162
|
+
const parseEventOfferingId = (value) => parseUuidBrand(value, unsafeAsEventOfferingId);
|
|
163
|
+
const parseCourseProgramId = (value) => parseUuidBrand(value, unsafeAsCourseProgramId);
|
|
164
|
+
const parseTicketTypeId = (value) => parseUuidBrand(value, unsafeAsTicketTypeId);
|
|
165
|
+
const parsePricingOptionId = (value) => parseUuidBrand(value, unsafeAsPricingOptionId);
|
|
166
|
+
const parseUserId = (value) => parseUuidBrand(value, unsafeAsUserId);
|
|
167
|
+
const parseProductId = (value) => parseUuidBrand(value, unsafeAsProductId);
|
|
168
|
+
const parseProductVariantId = (value) => parseUuidBrand(value, unsafeAsProductVariantId);
|
|
169
|
+
const parseProductCategoryId = (value) => parseUuidBrand(value, unsafeAsProductCategoryId);
|
|
170
|
+
const parseMediaAssetId = (value) => parseUuidBrand(value, unsafeAsMediaAssetId);
|
|
171
|
+
const parseContentEntryId = (value) => parseUuidBrand(value, unsafeAsContentEntryId);
|
|
172
|
+
const parseContentBlockId = (value) => parseUuidBrand(value, unsafeAsContentBlockId);
|
|
173
|
+
const parseContentVersionId = (value) => parseUuidBrand(value, unsafeAsContentVersionId);
|
|
174
|
+
const parseNavigationMenuId = (value) => parseUuidBrand(value, unsafeAsNavigationMenuId);
|
|
175
|
+
const parseNavigationItemId = (value) => parseUuidBrand(value, unsafeAsNavigationItemId);
|
|
176
|
+
const parsePassId = (value) => parseUuidBrand(value, unsafeAsPassId);
|
|
177
|
+
const parseMembershipId = (value) => parseUuidBrand(value, unsafeAsMembershipId);
|
|
178
|
+
const parseCourseId = (value) => parseUuidBrand(value, unsafeAsCourseId);
|
|
179
|
+
const parseFormId = (value) => parseUuidBrand(value, unsafeAsFormId);
|
|
180
|
+
const parseAppointmentId = (value) => parseUuidBrand(value, unsafeAsAppointmentId);
|
|
181
|
+
const parseAppointmentServiceId = (value) => parseUuidBrand(value, unsafeAsAppointmentServiceId);
|
|
182
|
+
const parseAppointmentResourceId = (value) => parseUuidBrand(value, unsafeAsAppointmentResourceId);
|
|
183
|
+
const parseAppointmentReservationId = (value) => parseUuidBrand(value, unsafeAsAppointmentReservationId);
|
|
184
|
+
const parseAppointmentReservationRunId = (value) => parseUuidBrand(value, unsafeAsAppointmentReservationRunId);
|
|
185
|
+
const parseAttendeeId = (value) => parseUuidBrand(value, unsafeAsAttendeeId);
|
|
186
|
+
const parseDatedOfferingCapacityHoldId = (value) => parseUuidBrand(value, unsafeAsDatedOfferingCapacityHoldId);
|
|
187
|
+
const parseDatedOfferingCapacityHoldAuditRef = (value) => parseUuidBrand(value, unsafeAsDatedOfferingCapacityHoldAuditRef);
|
|
188
|
+
const parseDatedOfferingRevocationAuditEventId = (value) => parseUuidBrand(value, unsafeAsDatedOfferingRevocationAuditEventId);
|
|
189
|
+
const parseDatedOfferingNonCashProjectionClaimToken = (value) => parseUuidBrand(value, unsafeAsDatedOfferingNonCashProjectionClaimToken);
|
|
190
|
+
const parseDatedOfferingWaitlistCapacityEffectId = (value) => parseUuidBrand(value, unsafeAsDatedOfferingWaitlistCapacityEffectId);
|
|
191
|
+
const parseDatedOfferingWaitlistCapacityEffectClaimToken = (value) => parseUuidBrand(value, unsafeAsDatedOfferingWaitlistCapacityEffectClaimToken);
|
|
192
|
+
const parseDatedOfferingRunCancellationOperationId = (value) => parseUuidBrand(value, unsafeAsDatedOfferingRunCancellationOperationId);
|
|
193
|
+
const parseCustomerId = (value) => parseUuidBrand(value, unsafeAsCustomerId);
|
|
194
|
+
const parseCustomerPassId = (value) => parseUuidBrand(value, unsafeAsCustomerPassId);
|
|
195
|
+
const parseCustomerMembershipId = (value) => parseUuidBrand(value, unsafeAsCustomerMembershipId);
|
|
196
|
+
const parseEventSeriesId = (value) => parseUuidBrand(value, unsafeAsEventSeriesId);
|
|
197
|
+
const parseEnrollmentId = (value) => parseUuidBrand(value, unsafeAsEnrollmentId);
|
|
198
|
+
const parseParticipantId = (value) => parseUuidBrand(value, unsafeAsParticipantId);
|
|
199
|
+
const parseParticipantParticipationId = (value) => parseUuidBrand(value, unsafeAsParticipantParticipationId);
|
|
200
|
+
const parseOrderItemId = (value) => parseUuidBrand(value, unsafeAsOrderItemId);
|
|
201
|
+
const parseOrderId = (value) => parseUuidBrand(value, unsafeAsOrderId);
|
|
202
|
+
const parseOrderRefundId = (value) => parseUuidBrand(value, unsafeAsOrderRefundId);
|
|
203
|
+
const parseOrderPaymentReceiptId = (value) => parseUuidBrand(value, unsafeAsOrderPaymentReceiptId);
|
|
204
|
+
const parseOrderFlexibleBalanceContractId = (value) => parseUuidBrand(value, unsafeAsOrderFlexibleBalanceContractId);
|
|
205
|
+
const parseOrderPaymentPlanId = (value) => parseUuidBrand(value, unsafeAsOrderPaymentPlanId);
|
|
206
|
+
const parseOrderPaymentPlanInstalmentId = (value) => parseUuidBrand(value, unsafeAsOrderPaymentPlanInstalmentId);
|
|
207
|
+
const parseManualPaymentOperationId = (value) => parseUuidBrand(value, unsafeAsManualPaymentOperationId);
|
|
208
|
+
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region ../core/src/date-math.ts
|
|
211
|
+
function addDaysToDate(date, dayCount) {
|
|
212
|
+
const next = new Date(date);
|
|
213
|
+
next.setUTCDate(next.getUTCDate() + dayCount);
|
|
214
|
+
return next;
|
|
215
|
+
}
|
|
216
|
+
function addDaysToIso(isoString, dayCount) {
|
|
217
|
+
const date = new Date(isoString);
|
|
218
|
+
if (!Number.isFinite(date.getTime())) {
|
|
219
|
+
return isoString;
|
|
220
|
+
}
|
|
221
|
+
return addDaysToDate(date, dayCount).toISOString();
|
|
222
|
+
}
|
|
223
|
+
function subtractDaysFromIso(isoString, dayCount) {
|
|
224
|
+
return addDaysToIso(isoString, -dayCount);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region ../core/src/duration.ts
|
|
229
|
+
const DURATION_UNIT_MINUTES = {
|
|
230
|
+
minutes: 1,
|
|
231
|
+
hours: 60,
|
|
232
|
+
days: 60 * 24,
|
|
233
|
+
weeks: 60 * 24 * 7
|
|
234
|
+
};
|
|
235
|
+
const UNIT_LABELS_SINGULAR = {
|
|
236
|
+
minutes: "minute",
|
|
237
|
+
hours: "hour",
|
|
238
|
+
days: "day",
|
|
239
|
+
weeks: "week"
|
|
240
|
+
};
|
|
241
|
+
const UNIT_LABELS_PLURAL = {
|
|
242
|
+
minutes: "minutes",
|
|
243
|
+
hours: "hours",
|
|
244
|
+
days: "days",
|
|
245
|
+
weeks: "weeks"
|
|
246
|
+
};
|
|
247
|
+
function formatDurationUnitLabel(unit, count) {
|
|
248
|
+
return count === 1 ? UNIT_LABELS_SINGULAR[unit] : UNIT_LABELS_PLURAL[unit];
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Picks the coarsest allowed unit that cleanly divides `minutes`. Falls back
|
|
252
|
+
* to the smallest allowed unit when no clean divisor exists.
|
|
253
|
+
*/
|
|
254
|
+
function pickDurationDisplayUnit(minutes, allowed) {
|
|
255
|
+
if (allowed.length === 0) return "minutes";
|
|
256
|
+
if (minutes === 0) return allowed[0] ?? "minutes";
|
|
257
|
+
const sortedDesc = [...allowed].sort((a, b) => DURATION_UNIT_MINUTES[b] - DURATION_UNIT_MINUTES[a]);
|
|
258
|
+
for (const unit of sortedDesc) {
|
|
259
|
+
const unitMinutes = DURATION_UNIT_MINUTES[unit];
|
|
260
|
+
if (minutes >= unitMinutes && minutes % unitMinutes === 0) return unit;
|
|
261
|
+
}
|
|
262
|
+
return allowed[0] ?? "minutes";
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Guarantees the widget invariant `displayValue x unit === storedMinutes` is
|
|
266
|
+
* representable by prepending minutes when no caller-provided unit divides.
|
|
267
|
+
*/
|
|
268
|
+
function expandAllowedDurationUnits(value, base) {
|
|
269
|
+
if (base.includes("minutes")) return base;
|
|
270
|
+
const dividesAny = base.some((unit) => DURATION_UNIT_MINUTES[unit] > 0 && value % DURATION_UNIT_MINUTES[unit] === 0);
|
|
271
|
+
return dividesAny ? base : ["minutes", ...base];
|
|
272
|
+
}
|
|
273
|
+
function clampDurationMinutes(value, min, max) {
|
|
274
|
+
let result = value;
|
|
275
|
+
if (typeof min === "number" && result < min) result = min;
|
|
276
|
+
if (typeof max === "number" && result > max) result = max;
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region ../core/src/demo-capability-policy.ts
|
|
282
|
+
const demoSurfaceClassificationValues = [
|
|
283
|
+
"allowed",
|
|
284
|
+
"blocked-hidden",
|
|
285
|
+
"blocked-disabled",
|
|
286
|
+
"internal-only",
|
|
287
|
+
"needs-classification"
|
|
288
|
+
];
|
|
289
|
+
/** Evaluates a classified demo mutation surface and fails closed for inactive claims or unknown surfaces. */
|
|
290
|
+
function evaluateDemoCapability(input) {
|
|
291
|
+
const classification = input.classification;
|
|
292
|
+
if (input.claim.kind !== "active") {
|
|
293
|
+
return {
|
|
294
|
+
allowed: false,
|
|
295
|
+
classification,
|
|
296
|
+
reason: "claim_not_active",
|
|
297
|
+
surface: input.surface
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
switch (classification) {
|
|
301
|
+
case "allowed": return {
|
|
302
|
+
allowed: true,
|
|
303
|
+
classification: "allowed"
|
|
304
|
+
};
|
|
305
|
+
case "blocked-hidden":
|
|
306
|
+
case "blocked-disabled": return {
|
|
307
|
+
allowed: false,
|
|
308
|
+
classification,
|
|
309
|
+
reason: "dangerous_surface",
|
|
310
|
+
surface: input.surface
|
|
311
|
+
};
|
|
312
|
+
case "internal-only": return {
|
|
313
|
+
allowed: false,
|
|
314
|
+
classification: "internal-only",
|
|
315
|
+
reason: "internal_only",
|
|
316
|
+
surface: input.surface
|
|
317
|
+
};
|
|
318
|
+
case "needs-classification": return {
|
|
319
|
+
allowed: false,
|
|
320
|
+
classification: "needs-classification",
|
|
321
|
+
reason: "surface_not_classified",
|
|
322
|
+
surface: input.surface
|
|
323
|
+
};
|
|
324
|
+
default: return assertNever(classification);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region ../core/src/demo-claim-access.ts
|
|
330
|
+
function deriveDemoClaimAccessState(input) {
|
|
331
|
+
if (input.instance.status !== "claimed") {
|
|
332
|
+
return { kind: input.instance.status === "expired" ? "expired" : "missing" };
|
|
333
|
+
}
|
|
334
|
+
if (input.claim === null) {
|
|
335
|
+
return { kind: "missing" };
|
|
336
|
+
}
|
|
337
|
+
if (input.claim.status === "released") {
|
|
338
|
+
return { kind: "released" };
|
|
339
|
+
}
|
|
340
|
+
if (input.claim.status === "expired") {
|
|
341
|
+
return { kind: "expired" };
|
|
342
|
+
}
|
|
343
|
+
if (new Date(input.claim.expiresAt).getTime() <= input.now.getTime()) {
|
|
344
|
+
return { kind: "expired" };
|
|
345
|
+
}
|
|
346
|
+
return { kind: "active" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region ../core/src/demo-lifecycle-status.ts
|
|
351
|
+
const demoBlueprintStatusValues = [
|
|
352
|
+
"draft",
|
|
353
|
+
"published",
|
|
354
|
+
"archived"
|
|
355
|
+
];
|
|
356
|
+
const demoInstanceStatusValues = [
|
|
357
|
+
"warming",
|
|
358
|
+
"available",
|
|
359
|
+
"claimed",
|
|
360
|
+
"expired",
|
|
361
|
+
"recycling",
|
|
362
|
+
"failed"
|
|
363
|
+
];
|
|
364
|
+
const demoClaimStatusValues = [
|
|
365
|
+
"active",
|
|
366
|
+
"expired",
|
|
367
|
+
"released"
|
|
368
|
+
];
|
|
369
|
+
const demoBlueprintArtifactKindValues = ["site_backup", "manual_snapshot"];
|
|
370
|
+
function narrowDemoInstanceStatus(value) {
|
|
371
|
+
if (demoInstanceStatusValues.includes(value)) {
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
throw new Error(`Unexpected demo instance status: ${value}`);
|
|
375
|
+
}
|
|
376
|
+
function narrowDemoClaimStatus(value) {
|
|
377
|
+
if (demoClaimStatusValues.includes(value)) {
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
380
|
+
throw new Error(`Unexpected demo claim status: ${value}`);
|
|
381
|
+
}
|
|
382
|
+
function narrowDemoBlueprintArtifactKind(value) {
|
|
383
|
+
if (demoBlueprintArtifactKindValues.includes(value)) {
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
throw new Error(`Unexpected demo blueprint artifact kind: ${value}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region ../core/src/demo-runtime.ts
|
|
391
|
+
const RIVERBANK_RUNTIME_ENV_VAR = "RIVERBANK_RUNTIME_ENV";
|
|
392
|
+
const riverbankRuntimeEnvValues = [
|
|
393
|
+
"production",
|
|
394
|
+
"staging",
|
|
395
|
+
"demo"
|
|
396
|
+
];
|
|
397
|
+
const runtimeByEnv = {
|
|
398
|
+
production: {
|
|
399
|
+
kind: "production",
|
|
400
|
+
demoAllowed: false
|
|
401
|
+
},
|
|
402
|
+
staging: {
|
|
403
|
+
kind: "staging",
|
|
404
|
+
demoAllowed: false
|
|
405
|
+
},
|
|
406
|
+
demo: {
|
|
407
|
+
kind: "demo",
|
|
408
|
+
demoAllowed: true
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
function parseRiverbankRuntimeEnv(rawValue) {
|
|
412
|
+
if (rawValue == null || rawValue.trim() === "") {
|
|
413
|
+
return {
|
|
414
|
+
kind: "invalid",
|
|
415
|
+
error: {
|
|
416
|
+
kind: "missing_runtime_env",
|
|
417
|
+
envVar: RIVERBANK_RUNTIME_ENV_VAR
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const normalizedValue = rawValue.trim().toLowerCase();
|
|
422
|
+
if (isRiverbankRuntimeEnv(normalizedValue)) {
|
|
423
|
+
return {
|
|
424
|
+
kind: "resolved",
|
|
425
|
+
runtime: runtimeByEnv[normalizedValue]
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
kind: "invalid",
|
|
430
|
+
error: {
|
|
431
|
+
kind: "unsupported_runtime_env",
|
|
432
|
+
envVar: RIVERBANK_RUNTIME_ENV_VAR,
|
|
433
|
+
rawValue,
|
|
434
|
+
normalizedValue,
|
|
435
|
+
supportedValues: riverbankRuntimeEnvValues
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function resolveRiverbankRuntimeEnv(env) {
|
|
440
|
+
return parseRiverbankRuntimeEnv(env[RIVERBANK_RUNTIME_ENV_VAR]);
|
|
441
|
+
}
|
|
442
|
+
function requireDemoRuntime(resolution) {
|
|
443
|
+
if (resolution.kind === "invalid") {
|
|
444
|
+
return {
|
|
445
|
+
allowed: false,
|
|
446
|
+
reason: resolution.error.kind,
|
|
447
|
+
error: resolution.error
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (resolution.runtime.kind === "demo") {
|
|
451
|
+
return {
|
|
452
|
+
allowed: true,
|
|
453
|
+
runtime: resolution.runtime
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
allowed: false,
|
|
458
|
+
reason: "not_demo_runtime",
|
|
459
|
+
runtime: resolution.runtime
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function isRiverbankRuntimeEnv(value) {
|
|
463
|
+
return riverbankRuntimeEnvValues.includes(value);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region ../core/src/collections.ts
|
|
468
|
+
function dedupeStrings(values) {
|
|
469
|
+
return [...new Set(values)];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region ../core/src/assert-never.ts
|
|
474
|
+
function assertNever(value, message = "Unexpected variant") {
|
|
475
|
+
throw new Error(`${message}: ${String(value)}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region ../core/src/currency-format.ts
|
|
480
|
+
const CURRENCY_CODE_INPUT_PATTERN = /^[A-Za-z]{3}$/;
|
|
481
|
+
const CANONICAL_CURRENCY_CODE_PATTERN = /^[A-Z]{3}$/;
|
|
482
|
+
function isCurrencyCode(value) {
|
|
483
|
+
return typeof value === "string" && CANONICAL_CURRENCY_CODE_PATTERN.test(value);
|
|
484
|
+
}
|
|
485
|
+
/** Parse + normalize a currency code, or return null if it isn't a 3-letter code. */
|
|
486
|
+
function parseCurrencyCode(value) {
|
|
487
|
+
return CURRENCY_CODE_INPUT_PATTERN.test(value) ? value.toUpperCase() : null;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Brand an already-trusted currency code (DB column, generated constant, fixture).
|
|
491
|
+
* Use `parseCurrencyCode` for untrusted boundary values.
|
|
492
|
+
*/
|
|
493
|
+
function unsafeCurrencyCode(value) {
|
|
494
|
+
return value.toUpperCase();
|
|
495
|
+
}
|
|
496
|
+
const CURRENCY_LOCALE_BY_CODE = {
|
|
497
|
+
USD: "en-US",
|
|
498
|
+
GBP: "en-GB",
|
|
499
|
+
EUR: "de-DE",
|
|
500
|
+
AUD: "en-AU",
|
|
501
|
+
CAD: "en-CA",
|
|
502
|
+
NZD: "en-NZ",
|
|
503
|
+
JPY: "ja-JP",
|
|
504
|
+
CNY: "zh-CN",
|
|
505
|
+
CHF: "de-CH"
|
|
506
|
+
};
|
|
507
|
+
function resolveCurrencyLocale(currencyCode, override) {
|
|
508
|
+
return override ?? CURRENCY_LOCALE_BY_CODE[currencyCode] ?? "en";
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The single currency-amount formatter for the product: minor units in,
|
|
512
|
+
* localized string out.
|
|
513
|
+
*
|
|
514
|
+
* The locale is derived from the currency by default, so output is deterministic
|
|
515
|
+
* across server and client (no SSR/hydration mismatch). All other money
|
|
516
|
+
* formatters in the codebase should delegate here rather than constructing their
|
|
517
|
+
* own `Intl.NumberFormat`.
|
|
518
|
+
*/
|
|
519
|
+
function formatCurrencyAmount(amountCents, currency, options) {
|
|
520
|
+
const currencyCode = currency.toUpperCase();
|
|
521
|
+
const locale = resolveCurrencyLocale(currencyCode, options?.locale);
|
|
522
|
+
try {
|
|
523
|
+
const exactFormatter = new Intl.NumberFormat(locale, {
|
|
524
|
+
style: "currency",
|
|
525
|
+
currency: currencyCode
|
|
526
|
+
});
|
|
527
|
+
const fractionDigits = options?.decimals === "auto" ? {
|
|
528
|
+
minimumFractionDigits: 0,
|
|
529
|
+
maximumFractionDigits: exactFormatter.resolvedOptions().maximumFractionDigits
|
|
530
|
+
} : {};
|
|
531
|
+
return new Intl.NumberFormat(locale, {
|
|
532
|
+
style: "currency",
|
|
533
|
+
currency: currencyCode,
|
|
534
|
+
...fractionDigits
|
|
535
|
+
}).format(amountCents / 100);
|
|
536
|
+
} catch {
|
|
537
|
+
return `${(amountCents / 100).toFixed(2)} ${currencyCode}`;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region ../core/src/event-pricing.ts
|
|
543
|
+
const EVENT_PRICING_MODES = [
|
|
544
|
+
"single",
|
|
545
|
+
"ticket_types",
|
|
546
|
+
"sliding_scale"
|
|
547
|
+
];
|
|
548
|
+
function isEventPricingMode(value) {
|
|
549
|
+
return typeof value === "string" && EVENT_PRICING_MODES.includes(value);
|
|
550
|
+
}
|
|
551
|
+
function publicEventPricingDisplay(pricing) {
|
|
552
|
+
return pricing.display;
|
|
553
|
+
}
|
|
554
|
+
function publicEventPricingType(pricing) {
|
|
555
|
+
switch (pricing.kind) {
|
|
556
|
+
case "drop_in":
|
|
557
|
+
case "free_registration": return "free";
|
|
558
|
+
case "single_paid":
|
|
559
|
+
case "ticket_types":
|
|
560
|
+
case "sliding_scale":
|
|
561
|
+
case "course": return isFreePublicEventPriceDisplay(pricing.display) ? "free" : "paid";
|
|
562
|
+
case "external": return "external";
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function publicEventPricingCurrency(pricing) {
|
|
566
|
+
switch (pricing.kind) {
|
|
567
|
+
case "drop_in":
|
|
568
|
+
case "free_registration":
|
|
569
|
+
case "single_paid":
|
|
570
|
+
case "ticket_types":
|
|
571
|
+
case "sliding_scale": return pricing.currency;
|
|
572
|
+
case "course": return pricing.display.display.currency;
|
|
573
|
+
case "external": return null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function publicEventScalarPrice(pricing) {
|
|
577
|
+
return pricing.kind === "single_paid" ? pricing.amount : null;
|
|
578
|
+
}
|
|
579
|
+
function publicEventPricingMode(pricing) {
|
|
580
|
+
switch (pricing.kind) {
|
|
581
|
+
case "single_paid":
|
|
582
|
+
case "free_registration": return "single";
|
|
583
|
+
case "ticket_types": return "ticket_types";
|
|
584
|
+
case "sliding_scale": return "sliding_scale";
|
|
585
|
+
case "drop_in":
|
|
586
|
+
case "course":
|
|
587
|
+
case "external": return undefined;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function publicEventTicketTypes(pricing) {
|
|
591
|
+
return pricing.kind === "ticket_types" ? pricing.ticketTypes : [];
|
|
592
|
+
}
|
|
593
|
+
function isFreePublicEventPriceDisplay(display) {
|
|
594
|
+
switch (display.kind) {
|
|
595
|
+
case "free": return true;
|
|
596
|
+
case "course": return display.display.kind === "free";
|
|
597
|
+
case "exact":
|
|
598
|
+
case "from":
|
|
599
|
+
case "range":
|
|
600
|
+
case "mixed":
|
|
601
|
+
case "unavailable":
|
|
602
|
+
case "external": return false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function formatCoursePriceDisplayLabel(display, options) {
|
|
606
|
+
switch (display.kind) {
|
|
607
|
+
case "free":
|
|
608
|
+
case "unavailable": return "Course";
|
|
609
|
+
case "exact": return `Course (${formatCurrencyAmount(display.amount, display.currency, options)})`;
|
|
610
|
+
case "from": return `Course (From ${formatCurrencyAmount(display.amount, display.currency, options)})`;
|
|
611
|
+
case "range": return `Course (${formatCurrencyAmount(display.minAmount, display.currency, options)} - ${formatCurrencyAmount(display.maxAmount, display.currency, options)})`;
|
|
612
|
+
default: return assertNever(display);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* The single renderer for a precomputed booking price `display` ADT. This is
|
|
617
|
+
* the one place booking price variants map to user-facing strings — every call
|
|
618
|
+
* site (booking entry, events calendar, event/course details, listings, portal)
|
|
619
|
+
* should route through here so labels never drift. Adding a variant to the ADT
|
|
620
|
+
* breaks compilation here (via `assertNever`) until it is handled.
|
|
621
|
+
*/
|
|
622
|
+
function formatBookingPriceDisplay(display, options = {}) {
|
|
623
|
+
switch (display.kind) {
|
|
624
|
+
case "free": return "Free";
|
|
625
|
+
case "exact": return formatCurrencyAmount(display.amount, display.currency, options);
|
|
626
|
+
case "from": return `From ${formatCurrencyAmount(display.amount, display.currency, options)}`;
|
|
627
|
+
case "range": return `${formatCurrencyAmount(display.minAmount, display.currency, options)} - ${formatCurrencyAmount(display.maxAmount, display.currency, options)}`;
|
|
628
|
+
case "mixed": return display.paidToAmount > display.paidFromAmount ? `Free / ${formatCurrencyAmount(display.paidFromAmount, display.currency, options)} - ${formatCurrencyAmount(display.paidToAmount, display.currency, options)}` : `Free / ${formatCurrencyAmount(display.paidFromAmount, display.currency, options)}`;
|
|
629
|
+
case "unavailable": return "Pricing unavailable";
|
|
630
|
+
case "external": return display.label;
|
|
631
|
+
case "course": return formatCoursePriceDisplayLabel(display.display, options);
|
|
632
|
+
default: return assertNever(display);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** Render an event's price directly from its pricing value. */
|
|
636
|
+
function formatEventPricing(pricing, options = {}) {
|
|
637
|
+
return formatBookingPriceDisplay(publicEventPricingDisplay(pricing), options);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region ../core/src/event-page-surface-subject.ts
|
|
642
|
+
function createEventSeriesPageSurfaceSubject(eventSeriesId, bookingScope) {
|
|
643
|
+
return bookingScope === "whole_series" ? {
|
|
644
|
+
kind: "eventSeries",
|
|
645
|
+
eventSeriesId,
|
|
646
|
+
bookingScope,
|
|
647
|
+
isWholeSeriesBooking: true
|
|
648
|
+
} : {
|
|
649
|
+
kind: "eventSeries",
|
|
650
|
+
eventSeriesId,
|
|
651
|
+
bookingScope
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function parseEventPageSurfaceSubject(value) {
|
|
655
|
+
if (!isRecord$6(value)) return err({ kind: "notObject" });
|
|
656
|
+
switch (value.kind) {
|
|
657
|
+
case "eventSeries": {
|
|
658
|
+
const eventSeriesId = parseEventSeriesId(value.eventSeriesId);
|
|
659
|
+
if (!eventSeriesId.ok) {
|
|
660
|
+
return err({
|
|
661
|
+
kind: "invalidEventSeriesId",
|
|
662
|
+
error: eventSeriesId.error
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (value.bookingScope !== "single_occurrence" && value.bookingScope !== "whole_series") {
|
|
666
|
+
return err({
|
|
667
|
+
kind: "invalidEventSeriesBookingScope",
|
|
668
|
+
bookingScope: value.bookingScope
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return ok(createEventSeriesPageSurfaceSubject(eventSeriesId.value, value.bookingScope));
|
|
672
|
+
}
|
|
673
|
+
case "eventOffering": {
|
|
674
|
+
const eventOfferingId = parseEventOfferingId(value.eventOfferingId);
|
|
675
|
+
if (!eventOfferingId.ok) {
|
|
676
|
+
return err({
|
|
677
|
+
kind: "invalidEventOfferingId",
|
|
678
|
+
error: eventOfferingId.error
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
if (!Array.isArray(value.availableEventSeriesIds)) {
|
|
682
|
+
return err({
|
|
683
|
+
kind: "unsupportedSubject",
|
|
684
|
+
subjectKind: value.kind
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
const parsedSeriesIds = [];
|
|
688
|
+
for (const valueId of value.availableEventSeriesIds) {
|
|
689
|
+
const eventSeriesId = parseEventSeriesId(valueId);
|
|
690
|
+
if (!eventSeriesId.ok) {
|
|
691
|
+
return err({
|
|
692
|
+
kind: "invalidEventSeriesId",
|
|
693
|
+
error: eventSeriesId.error
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
parsedSeriesIds.push(eventSeriesId.value);
|
|
697
|
+
}
|
|
698
|
+
return ok({
|
|
699
|
+
kind: "eventOffering",
|
|
700
|
+
eventOfferingId: eventOfferingId.value,
|
|
701
|
+
availableEventSeriesIds: parsedSeriesIds
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
default: return err({
|
|
705
|
+
kind: "unsupportedSubject",
|
|
706
|
+
subjectKind: value.kind
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function toEventPageSurfaceSubjectWire(subject) {
|
|
711
|
+
return subject;
|
|
712
|
+
}
|
|
713
|
+
function isRecord$6(value) {
|
|
714
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region ../core/src/money-input.ts
|
|
719
|
+
function unsafeAsCents(value) {
|
|
720
|
+
return value;
|
|
721
|
+
}
|
|
722
|
+
function unsafeAsNonNegativeCents(value) {
|
|
723
|
+
return value;
|
|
724
|
+
}
|
|
725
|
+
function unsafeAsPositiveCents(value) {
|
|
726
|
+
return value;
|
|
727
|
+
}
|
|
728
|
+
function unwrapCents(value) {
|
|
729
|
+
return value;
|
|
730
|
+
}
|
|
731
|
+
function isWholeCents(value) {
|
|
732
|
+
return Number.isFinite(value) && Number.isInteger(value);
|
|
733
|
+
}
|
|
734
|
+
function isNonNegativeWholeCents(value) {
|
|
735
|
+
return isWholeCents(value) && value >= 0;
|
|
736
|
+
}
|
|
737
|
+
function isPositiveWholeCents(value) {
|
|
738
|
+
return isWholeCents(value) && value > 0;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Normalizes a derived amount to signed whole cents. Use parse*Cents for
|
|
742
|
+
* untrusted input that should fail instead of truncating or clamping.
|
|
743
|
+
*/
|
|
744
|
+
function toCents(value) {
|
|
745
|
+
return unsafeAsCents(Math.trunc(value));
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Normalizes a derived amount to non-negative whole cents. This intentionally
|
|
749
|
+
* clamps negatives to zero; use parseNonNegativeCents for validation.
|
|
750
|
+
*/
|
|
751
|
+
function toNonNegativeCents(value) {
|
|
752
|
+
return unsafeAsNonNegativeCents(Math.max(0, Math.trunc(value)));
|
|
753
|
+
}
|
|
754
|
+
function parseCents(value) {
|
|
755
|
+
if (!Number.isFinite(value)) {
|
|
756
|
+
return err({ kind: "invalid-number" });
|
|
757
|
+
}
|
|
758
|
+
if (!Number.isInteger(value)) {
|
|
759
|
+
return err({ kind: "not-whole-cents" });
|
|
760
|
+
}
|
|
761
|
+
return ok(unsafeAsCents(value));
|
|
762
|
+
}
|
|
763
|
+
function parseNonNegativeCents(value) {
|
|
764
|
+
const parsed = parseCents(value);
|
|
765
|
+
if (!parsed.ok) {
|
|
766
|
+
return parsed;
|
|
767
|
+
}
|
|
768
|
+
if (parsed.value < 0) {
|
|
769
|
+
return err({ kind: "negative" });
|
|
770
|
+
}
|
|
771
|
+
return ok(unsafeAsNonNegativeCents(parsed.value));
|
|
772
|
+
}
|
|
773
|
+
function parsePositiveCents(value) {
|
|
774
|
+
const parsed = parseNonNegativeCents(value);
|
|
775
|
+
if (!parsed.ok) {
|
|
776
|
+
return parsed;
|
|
777
|
+
}
|
|
778
|
+
if (parsed.value === 0) {
|
|
779
|
+
return err({ kind: "zero-not-allowed" });
|
|
780
|
+
}
|
|
781
|
+
return ok(unsafeAsPositiveCents(parsed.value));
|
|
782
|
+
}
|
|
783
|
+
function normalizeMoneyInput(value, options) {
|
|
784
|
+
return (options.stripFormatting === true ? value.replace(/[^0-9.-]/g, "") : value).trim();
|
|
785
|
+
}
|
|
786
|
+
function parseFiniteMoneyAmount(value, options) {
|
|
787
|
+
const normalized = normalizeMoneyInput(value, options);
|
|
788
|
+
if (normalized.length === 0) {
|
|
789
|
+
return err({ kind: "empty" });
|
|
790
|
+
}
|
|
791
|
+
const amount = Number(normalized);
|
|
792
|
+
if (!Number.isFinite(amount)) {
|
|
793
|
+
return err({ kind: "invalid-number" });
|
|
794
|
+
}
|
|
795
|
+
return ok(amount);
|
|
796
|
+
}
|
|
797
|
+
function roundMoneyAmountToCents(amount) {
|
|
798
|
+
return Math.round(amount * 100);
|
|
799
|
+
}
|
|
800
|
+
function parsePositiveMoneyInputToCents(value, options = {}) {
|
|
801
|
+
const parsed = parseFiniteMoneyAmount(value, options);
|
|
802
|
+
if (!parsed.ok) {
|
|
803
|
+
return parsed;
|
|
804
|
+
}
|
|
805
|
+
if (parsed.value < 0) {
|
|
806
|
+
return err({ kind: "negative" });
|
|
807
|
+
}
|
|
808
|
+
const amountCents = roundMoneyAmountToCents(parsed.value);
|
|
809
|
+
if (amountCents <= 0) {
|
|
810
|
+
return err({ kind: "zero-not-allowed" });
|
|
811
|
+
}
|
|
812
|
+
return ok(unsafeAsPositiveCents(amountCents));
|
|
813
|
+
}
|
|
814
|
+
function parseNonNegativeMoneyInputToCents(value, options = {}) {
|
|
815
|
+
const parsed = parseFiniteMoneyAmount(value, options);
|
|
816
|
+
if (!parsed.ok) {
|
|
817
|
+
return parsed;
|
|
818
|
+
}
|
|
819
|
+
if (parsed.value < 0) {
|
|
820
|
+
return err({ kind: "negative" });
|
|
821
|
+
}
|
|
822
|
+
return ok(unsafeAsNonNegativeCents(roundMoneyAmountToCents(parsed.value)));
|
|
823
|
+
}
|
|
824
|
+
function parseMoneyInputToCents(value, options = {}) {
|
|
825
|
+
const parsed = parseFiniteMoneyAmount(value, options);
|
|
826
|
+
if (!parsed.ok) {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
if (options.allowZero === true ? parsed.value < 0 : parsed.value <= 0) {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
return roundMoneyAmountToCents(parsed.value);
|
|
833
|
+
}
|
|
834
|
+
function formatMoneyInputFromCents(amountCents) {
|
|
835
|
+
if (typeof amountCents !== "number" || !Number.isFinite(amountCents)) {
|
|
836
|
+
return "";
|
|
837
|
+
}
|
|
838
|
+
const sign = amountCents < 0 ? "-" : "";
|
|
839
|
+
const absoluteCents = Math.abs(Math.trunc(amountCents));
|
|
840
|
+
const units = Math.floor(absoluteCents / 100);
|
|
841
|
+
const fractional = String(absoluteCents % 100).padStart(2, "0");
|
|
842
|
+
return `${sign}${units}.${fractional}`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
//#endregion
|
|
846
|
+
//#region ../core/src/booking-flexible-balance.ts
|
|
847
|
+
const DEFAULT_FLEXIBLE_BALANCE_STRIPE_MINIMUM_CHARGE_CENTS = 50;
|
|
848
|
+
function clampFlexibleBalanceConfiguredAmount(amountCents, totalAmountCents) {
|
|
849
|
+
return Math.max(0, Math.min(amountCents, totalAmountCents));
|
|
850
|
+
}
|
|
851
|
+
function resolveFlexibleBalanceInitialPaymentCents(input) {
|
|
852
|
+
return clampFlexibleBalanceConfiguredAmount(input.config.upfrontAmountCents, input.totalAmountCents);
|
|
853
|
+
}
|
|
854
|
+
function calculateFlexibleBalanceRemainingBalanceCents(input) {
|
|
855
|
+
return Math.max(0, input.totalAmountCents - input.paidAmountCents);
|
|
856
|
+
}
|
|
857
|
+
function resolveFlexibleBalanceDueAt(input) {
|
|
858
|
+
switch (input.dueDateRule.kind) {
|
|
859
|
+
case "relative_to_start_date": return subtractDaysFromIso(input.bookingStartAt, input.dueDateRule.daysBeforeStart);
|
|
860
|
+
case "fixed_calendar_date": return input.dueDateRule.dueAt;
|
|
861
|
+
default: return assertNever$4(input.dueDateRule);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
function calculateFlexibleBalanceMinimumPaymentCents(input) {
|
|
865
|
+
if (input.remainingBalanceCents <= 0) {
|
|
866
|
+
return 0;
|
|
867
|
+
}
|
|
868
|
+
const minimumPercentageBps = input.minimumPercentageBps ?? 1e3;
|
|
869
|
+
const percentageFloor = Math.ceil(input.remainingBalanceCents * minimumPercentageBps / 1e4);
|
|
870
|
+
return Math.min(input.remainingBalanceCents, Math.max(input.stripeMinimumChargeCents, percentageFloor));
|
|
871
|
+
}
|
|
872
|
+
function resolveFlexibleBalancePaymentEligibility(input) {
|
|
873
|
+
if (input.remainingBalanceCents <= 0 || input.status === "completed") {
|
|
874
|
+
return {
|
|
875
|
+
kind: "unavailable",
|
|
876
|
+
reason: "already_settled"
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
switch (input.status) {
|
|
880
|
+
case "payment_pending": return {
|
|
881
|
+
kind: "unavailable",
|
|
882
|
+
reason: "payment_pending"
|
|
883
|
+
};
|
|
884
|
+
case "cancelled_due_to_non_payment": return {
|
|
885
|
+
kind: "unavailable",
|
|
886
|
+
reason: "cancelled_due_to_non_payment"
|
|
887
|
+
};
|
|
888
|
+
case "refunded": return {
|
|
889
|
+
kind: "unavailable",
|
|
890
|
+
reason: "refunded"
|
|
891
|
+
};
|
|
892
|
+
case "waived": return {
|
|
893
|
+
kind: "unavailable",
|
|
894
|
+
reason: "waived"
|
|
895
|
+
};
|
|
896
|
+
case "overdue": return {
|
|
897
|
+
kind: "full_balance_only",
|
|
898
|
+
remainingBalanceCents: input.remainingBalanceCents,
|
|
899
|
+
reason: "overdue"
|
|
900
|
+
};
|
|
901
|
+
case "active": {
|
|
902
|
+
const minimumAmountCents = calculateFlexibleBalanceMinimumPaymentCents({
|
|
903
|
+
remainingBalanceCents: input.remainingBalanceCents,
|
|
904
|
+
stripeMinimumChargeCents: input.stripeMinimumChargeCents,
|
|
905
|
+
minimumPercentageBps: input.minimumPercentageBps
|
|
906
|
+
});
|
|
907
|
+
if (minimumAmountCents >= input.remainingBalanceCents) {
|
|
908
|
+
return {
|
|
909
|
+
kind: "full_balance_only",
|
|
910
|
+
remainingBalanceCents: input.remainingBalanceCents,
|
|
911
|
+
reason: "dust_balance"
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
kind: "eligible",
|
|
916
|
+
minimumAmountCents,
|
|
917
|
+
maximumCustomAmountCents: input.remainingBalanceCents,
|
|
918
|
+
suggestedPayFullAmountCents: input.remainingBalanceCents
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
default: return assertNever$4(input.status);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function isFlexibleBalanceReminderSuppressed(input) {
|
|
925
|
+
const suppressionWindowHours = input.suppressionWindowHours ?? 24;
|
|
926
|
+
const bookedAtMs = Date.parse(input.bookedAt);
|
|
927
|
+
const reminderDueAtMs = Date.parse(input.reminderDueAt);
|
|
928
|
+
if (!Number.isFinite(bookedAtMs) || !Number.isFinite(reminderDueAtMs)) {
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
return reminderDueAtMs - bookedAtMs < suppressionWindowHours * 60 * 60 * 1e3;
|
|
932
|
+
}
|
|
933
|
+
function buildFlexibleBalanceReminderSchedule(input) {
|
|
934
|
+
const schedule = [
|
|
935
|
+
{
|
|
936
|
+
kind: "due_7_days",
|
|
937
|
+
scheduledFor: subtractDaysFromIso(input.dueAt, 7)
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
kind: "due_1_day",
|
|
941
|
+
scheduledFor: subtractDaysFromIso(input.dueAt, 1)
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
kind: "overdue_1_day",
|
|
945
|
+
scheduledFor: addDaysToIso(input.dueAt, 1)
|
|
946
|
+
}
|
|
947
|
+
];
|
|
948
|
+
return schedule.map((entry) => ({
|
|
949
|
+
kind: entry.kind,
|
|
950
|
+
scheduledFor: entry.scheduledFor,
|
|
951
|
+
suppressed: isFlexibleBalanceReminderSuppressed({
|
|
952
|
+
bookedAt: input.bookedAt,
|
|
953
|
+
reminderDueAt: entry.scheduledFor
|
|
954
|
+
})
|
|
955
|
+
}));
|
|
956
|
+
}
|
|
957
|
+
function assertNever$4(value) {
|
|
958
|
+
throw new Error(`Unexpected value: ${String(value)}`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region ../core/src/booking-refunds.ts
|
|
963
|
+
function normalizeBookingProtectedDepositPolicyState(value) {
|
|
964
|
+
return value === "not_applicable" || value === "refundable" || value === "non_refundable" ? value : "not_applicable";
|
|
965
|
+
}
|
|
966
|
+
function shouldAllowProtectedDepositOverride(command) {
|
|
967
|
+
return command.protectedDepositOverride === "allow_protected_deposit_override";
|
|
968
|
+
}
|
|
969
|
+
function createBookingRefundAmountRequest(amountCents) {
|
|
970
|
+
return amountCents == null ? { kind: "full_remaining" } : {
|
|
971
|
+
kind: "partial_amount",
|
|
972
|
+
amountCents
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
function createOriginalPaymentRefundCommand(options) {
|
|
976
|
+
const protectedDepositOverride = options.protectedDepositOverride ?? "respect_policy";
|
|
977
|
+
return options.kind === "refund_and_revoke" ? {
|
|
978
|
+
kind: "refund_and_revoke",
|
|
979
|
+
amount: { kind: "full_remaining" },
|
|
980
|
+
requestedRefundMethod: "original_payment",
|
|
981
|
+
protectedDepositOverride
|
|
982
|
+
} : {
|
|
983
|
+
kind: "refund_only",
|
|
984
|
+
amount: options.amount ?? { kind: "full_remaining" },
|
|
985
|
+
requestedRefundMethod: "original_payment",
|
|
986
|
+
protectedDepositOverride
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region ../core/src/booking-instalments.ts
|
|
992
|
+
function isDepositBearingBookingInstalmentPlan(plan) {
|
|
993
|
+
switch (plan.kind) {
|
|
994
|
+
case "deposit_plus_one_remainder":
|
|
995
|
+
case "deposit_plus_scheduled_instalments": return true;
|
|
996
|
+
case "equal_scheduled_instalments": return false;
|
|
997
|
+
default: return assertNever$3(plan);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function getBookingInstalmentDepositRefundability(plan) {
|
|
1001
|
+
return isDepositBearingBookingInstalmentPlan(plan) ? plan.depositRefundability : null;
|
|
1002
|
+
}
|
|
1003
|
+
function getBookingInstalmentProtectedDepositAmountCents(plan) {
|
|
1004
|
+
return isDepositBearingBookingInstalmentPlan(plan) ? plan.depositAmountCents : null;
|
|
1005
|
+
}
|
|
1006
|
+
function clampConfiguredAmount(amountCents, totalAmountCents) {
|
|
1007
|
+
return Math.max(0, Math.min(amountCents, totalAmountCents));
|
|
1008
|
+
}
|
|
1009
|
+
function splitAmountWithRemainderInFirst(totalAmountCents, partCount) {
|
|
1010
|
+
if (partCount <= 0) {
|
|
1011
|
+
return [];
|
|
1012
|
+
}
|
|
1013
|
+
const baseAmountCents = Math.floor(totalAmountCents / partCount);
|
|
1014
|
+
const remainderCents = totalAmountCents - baseAmountCents * partCount;
|
|
1015
|
+
return Array.from({ length: partCount }, (_, index) => index === 0 ? baseAmountCents + remainderCents : baseAmountCents);
|
|
1016
|
+
}
|
|
1017
|
+
function addIntervalPreservingMonthAnchor(isoString, intervalCount, intervalUnit) {
|
|
1018
|
+
const date = new Date(isoString);
|
|
1019
|
+
if (!Number.isFinite(date.getTime())) {
|
|
1020
|
+
return isoString;
|
|
1021
|
+
}
|
|
1022
|
+
switch (intervalUnit) {
|
|
1023
|
+
case "day":
|
|
1024
|
+
date.setUTCDate(date.getUTCDate() + intervalCount);
|
|
1025
|
+
return date.toISOString();
|
|
1026
|
+
case "week":
|
|
1027
|
+
date.setUTCDate(date.getUTCDate() + intervalCount * 7);
|
|
1028
|
+
return date.toISOString();
|
|
1029
|
+
case "month": return addMonthsPreservingAnchorDay(date, intervalCount).toISOString();
|
|
1030
|
+
default: return assertNever$3(intervalUnit);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function getBookingInstalmentPlanIntervalCount(plan) {
|
|
1034
|
+
switch (plan.kind) {
|
|
1035
|
+
case "deposit_plus_one_remainder": return plan.remainderDueCount;
|
|
1036
|
+
case "deposit_plus_scheduled_instalments":
|
|
1037
|
+
case "equal_scheduled_instalments": return plan.intervalCount;
|
|
1038
|
+
default: return assertNever$3(plan);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function omitZeroAmountFutureRows(rows) {
|
|
1042
|
+
if (rows.length <= 1) {
|
|
1043
|
+
return rows;
|
|
1044
|
+
}
|
|
1045
|
+
const firstRow = rows[0];
|
|
1046
|
+
if (firstRow === undefined) {
|
|
1047
|
+
return [];
|
|
1048
|
+
}
|
|
1049
|
+
const futureRows = rows.slice(1);
|
|
1050
|
+
return [firstRow, ...futureRows.filter((row) => row.amountCents > 0)];
|
|
1051
|
+
}
|
|
1052
|
+
function formatOrderInstalmentStatusLabel(input) {
|
|
1053
|
+
switch (input.status) {
|
|
1054
|
+
case "pending_initial_payment": return input.phaseKind === "initial" ? "Due today" : "Scheduled";
|
|
1055
|
+
case "scheduled": return "Scheduled";
|
|
1056
|
+
case "paid": return input.phaseKind === "initial" ? "Paid today" : "Paid";
|
|
1057
|
+
case "failed": return "Payment failed";
|
|
1058
|
+
case "waived": return "Waived";
|
|
1059
|
+
case "cancelled": return "Cancelled";
|
|
1060
|
+
case "cancelled_as_paid_early": return "Paid early";
|
|
1061
|
+
case "refunded": return "Refunded";
|
|
1062
|
+
default: return assertNever$3(input.status);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const NEXT_DUE_STATUSES = new Set(["scheduled", "failed"]);
|
|
1066
|
+
function buildOrderInstalmentSummary(input) {
|
|
1067
|
+
const nextDue = input.instalments.find((instalment) => NEXT_DUE_STATUSES.has(instalment.status)) ?? null;
|
|
1068
|
+
return {
|
|
1069
|
+
status: input.paymentPlan.status,
|
|
1070
|
+
remainingBalanceCents: input.paymentPlan.remainingBalanceCents,
|
|
1071
|
+
nextDueAt: nextDue?.dueAt ?? null,
|
|
1072
|
+
nextDueAmountCents: nextDue?.amountCents ?? null,
|
|
1073
|
+
rows: [...input.instalments]
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
function addMonthsPreservingAnchorDay(date, intervalCount) {
|
|
1077
|
+
const anchorDay = date.getUTCDate();
|
|
1078
|
+
const nextMonth = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + intervalCount, 1, date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()));
|
|
1079
|
+
const lastDayOfTargetMonth = new Date(Date.UTC(nextMonth.getUTCFullYear(), nextMonth.getUTCMonth() + 1, 0)).getUTCDate();
|
|
1080
|
+
nextMonth.setUTCDate(Math.min(anchorDay, lastDayOfTargetMonth));
|
|
1081
|
+
return nextMonth;
|
|
1082
|
+
}
|
|
1083
|
+
function assertNever$3(value) {
|
|
1084
|
+
throw new Error(`Unexpected value: ${String(value)}`);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
//#endregion
|
|
1088
|
+
//#region ../core/src/booking-time-zone.ts
|
|
1089
|
+
const TIME_ZONE_VALIDITY_CACHE_LIMIT = 100;
|
|
1090
|
+
const timeZoneValidityCache = new Map();
|
|
1091
|
+
const NUMERIC_OFFSET_TIME_ZONE_PATTERN = /^(?:[+-]\d{2}(?::?\d{2})?|(?:UTC|GMT)[+-]\d{1,2}(?::?\d{2})?)$/iu;
|
|
1092
|
+
function isValidBookingTimeZone(timeZone) {
|
|
1093
|
+
const normalized = timeZone.trim();
|
|
1094
|
+
if (NUMERIC_OFFSET_TIME_ZONE_PATTERN.test(normalized)) return false;
|
|
1095
|
+
const cached = timeZoneValidityCache.get(normalized);
|
|
1096
|
+
if (cached !== undefined) return cached;
|
|
1097
|
+
let isValid;
|
|
1098
|
+
try {
|
|
1099
|
+
new Intl.DateTimeFormat("en-US", { timeZone: normalized }).format(new Date());
|
|
1100
|
+
isValid = true;
|
|
1101
|
+
} catch {
|
|
1102
|
+
isValid = false;
|
|
1103
|
+
}
|
|
1104
|
+
if (timeZoneValidityCache.size >= TIME_ZONE_VALIDITY_CACHE_LIMIT) {
|
|
1105
|
+
timeZoneValidityCache.clear();
|
|
1106
|
+
}
|
|
1107
|
+
timeZoneValidityCache.set(normalized, isValid);
|
|
1108
|
+
return isValid;
|
|
1109
|
+
}
|
|
1110
|
+
function parseBookingTimeZone(timeZone) {
|
|
1111
|
+
if (typeof timeZone !== "string") return err("invalid-time-zone");
|
|
1112
|
+
const normalized = timeZone.trim();
|
|
1113
|
+
if (normalized.length === 0 || NUMERIC_OFFSET_TIME_ZONE_PATTERN.test(normalized) || !isValidBookingTimeZone(normalized)) {
|
|
1114
|
+
return err("invalid-time-zone");
|
|
1115
|
+
}
|
|
1116
|
+
return ok(normalized);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
//#endregion
|
|
1120
|
+
//#region ../core/src/booking-date-time.ts
|
|
1121
|
+
const DEFAULT_LOCALE = "en-US";
|
|
1122
|
+
const FALLBACK_TIMEZONE = "UTC";
|
|
1123
|
+
const DEFAULT_BOOKING_DATE_TIME_FORMAT = {
|
|
1124
|
+
weekday: "short",
|
|
1125
|
+
month: "short",
|
|
1126
|
+
day: "numeric",
|
|
1127
|
+
hour: "numeric",
|
|
1128
|
+
minute: "2-digit"
|
|
1129
|
+
};
|
|
1130
|
+
const START_OF_DAY = {
|
|
1131
|
+
hour: 0,
|
|
1132
|
+
minute: 0,
|
|
1133
|
+
second: 0
|
|
1134
|
+
};
|
|
1135
|
+
const LOCAL_DATE_PATTERN = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/u;
|
|
1136
|
+
const DATE_TIME_FORMATTER_CACHE_LIMIT = 100;
|
|
1137
|
+
const dateTimeFormatterCache = new Map();
|
|
1138
|
+
function getSafeTimeZone(timeZone) {
|
|
1139
|
+
const normalized = typeof timeZone === "string" ? timeZone.trim() : "";
|
|
1140
|
+
return normalized.length > 0 && isValidBookingTimeZone(normalized) ? normalized : FALLBACK_TIMEZONE;
|
|
1141
|
+
}
|
|
1142
|
+
function parseStrictBookingTimeZone(timeZone) {
|
|
1143
|
+
const parsed = parseBookingTimeZone(timeZone);
|
|
1144
|
+
return parsed.ok ? parsed.value : null;
|
|
1145
|
+
}
|
|
1146
|
+
function formatParseError(error) {
|
|
1147
|
+
switch (error.reason) {
|
|
1148
|
+
case "invalid_starts_at": return `Expected a valid UTC/offset-aware ISO datetime in startsAt, received "${error.value}"`;
|
|
1149
|
+
case "invalid_ends_at": return `Expected a valid UTC/offset-aware ISO datetime in endsAt, received "${error.value ?? ""}"`;
|
|
1150
|
+
case "invalid_time_zone": return `Expected a valid IANA timezone in timeZone, received "${error.value}"`;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function formatToPartsLookup(date, locale, timeZone, format) {
|
|
1154
|
+
return new Map(getCachedDateTimeFormatter(locale, timeZone, format).formatToParts(date).map((part) => [part.type, part.value]));
|
|
1155
|
+
}
|
|
1156
|
+
function getCachedDateTimeFormatter(locale, timeZone, format) {
|
|
1157
|
+
const formatKey = JSON.stringify(Object.entries(format).sort(([left], [right]) => left.localeCompare(right)));
|
|
1158
|
+
const key = `${locale}\u0000${timeZone}\u0000${formatKey}`;
|
|
1159
|
+
const cached = dateTimeFormatterCache.get(key);
|
|
1160
|
+
if (cached !== undefined) {
|
|
1161
|
+
return cached;
|
|
1162
|
+
}
|
|
1163
|
+
const formatter = new Intl.DateTimeFormat(locale, {
|
|
1164
|
+
...format,
|
|
1165
|
+
timeZone
|
|
1166
|
+
});
|
|
1167
|
+
if (dateTimeFormatterCache.size >= DATE_TIME_FORMATTER_CACHE_LIMIT) {
|
|
1168
|
+
dateTimeFormatterCache.clear();
|
|
1169
|
+
}
|
|
1170
|
+
dateTimeFormatterCache.set(key, formatter);
|
|
1171
|
+
return formatter;
|
|
1172
|
+
}
|
|
1173
|
+
function getZonedDateTimeParts(date, timeZone) {
|
|
1174
|
+
const parts = formatToPartsLookup(date, "en-GB", timeZone, {
|
|
1175
|
+
year: "numeric",
|
|
1176
|
+
month: "2-digit",
|
|
1177
|
+
day: "2-digit",
|
|
1178
|
+
hour: "2-digit",
|
|
1179
|
+
minute: "2-digit",
|
|
1180
|
+
second: "2-digit",
|
|
1181
|
+
hour12: false
|
|
1182
|
+
});
|
|
1183
|
+
const read = (type) => Number(parts.get(type) ?? "");
|
|
1184
|
+
return {
|
|
1185
|
+
year: read("year"),
|
|
1186
|
+
month: read("month"),
|
|
1187
|
+
day: read("day"),
|
|
1188
|
+
hour: read("hour"),
|
|
1189
|
+
minute: read("minute"),
|
|
1190
|
+
second: read("second")
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
function formatDateKey(date) {
|
|
1194
|
+
const year = String(date.year).padStart(4, "0");
|
|
1195
|
+
const month = String(date.month).padStart(2, "0");
|
|
1196
|
+
const day = String(date.day).padStart(2, "0");
|
|
1197
|
+
return `${year}-${month}-${day}`;
|
|
1198
|
+
}
|
|
1199
|
+
function toBookingTimeZone(timeZone) {
|
|
1200
|
+
return getSafeTimeZone(timeZone);
|
|
1201
|
+
}
|
|
1202
|
+
const DEFAULT_BOOKING_TIME_ZONE = FALLBACK_TIMEZONE;
|
|
1203
|
+
function resolveBookingTimezone(venueTimezone, siteDefaultTimezone) {
|
|
1204
|
+
return venueTimezone ?? siteDefaultTimezone;
|
|
1205
|
+
}
|
|
1206
|
+
function resolveBookingTimeZoneId(...candidates) {
|
|
1207
|
+
for (const candidate of candidates) {
|
|
1208
|
+
const normalized = typeof candidate === "string" ? candidate.trim() : "";
|
|
1209
|
+
if (normalized && isValidBookingTimeZone(normalized)) {
|
|
1210
|
+
return normalized;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return FALLBACK_TIMEZONE;
|
|
1214
|
+
}
|
|
1215
|
+
function parseUtcIsoDateTimeString(value) {
|
|
1216
|
+
const normalized = value.trim();
|
|
1217
|
+
const hasUtcSuffix = normalized.endsWith("Z");
|
|
1218
|
+
const hasExplicitOffset = /[+-]\d{2}:\d{2}$/u.test(normalized);
|
|
1219
|
+
if (!hasUtcSuffix && !hasExplicitOffset) {
|
|
1220
|
+
return err("missing-timezone-offset");
|
|
1221
|
+
}
|
|
1222
|
+
const date = new Date(normalized);
|
|
1223
|
+
if (Number.isNaN(date.getTime())) {
|
|
1224
|
+
return err("invalid-date-time");
|
|
1225
|
+
}
|
|
1226
|
+
return ok(date.toISOString());
|
|
1227
|
+
}
|
|
1228
|
+
function toUtcIsoDateTimeString(value) {
|
|
1229
|
+
const parsed = parseUtcIsoDateTimeString(value);
|
|
1230
|
+
if (!parsed.ok) {
|
|
1231
|
+
const message = parsed.error === "missing-timezone-offset" ? `Expected an offset-aware ISO datetime, received "${value}"` : `Expected a valid ISO datetime, received "${value}"`;
|
|
1232
|
+
throw new Error(message);
|
|
1233
|
+
}
|
|
1234
|
+
return parsed.value;
|
|
1235
|
+
}
|
|
1236
|
+
function parseScheduledBookingDateTime(input) {
|
|
1237
|
+
let startsAt;
|
|
1238
|
+
try {
|
|
1239
|
+
startsAt = toUtcIsoDateTimeString(input.startsAt);
|
|
1240
|
+
} catch {
|
|
1241
|
+
return {
|
|
1242
|
+
ok: false,
|
|
1243
|
+
field: "startsAt",
|
|
1244
|
+
reason: "invalid_starts_at",
|
|
1245
|
+
value: input.startsAt
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
const timeZone = parseStrictBookingTimeZone(input.timeZone);
|
|
1249
|
+
if (timeZone === null) {
|
|
1250
|
+
return {
|
|
1251
|
+
ok: false,
|
|
1252
|
+
field: "timeZone",
|
|
1253
|
+
reason: "invalid_time_zone",
|
|
1254
|
+
value: typeof input.timeZone === "string" ? input.timeZone : ""
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
ok: true,
|
|
1259
|
+
value: {
|
|
1260
|
+
startsAt,
|
|
1261
|
+
timeZone,
|
|
1262
|
+
locale: input.locale
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
function parseScheduledBookingDateRange(input) {
|
|
1267
|
+
const start = parseScheduledBookingDateTime(input);
|
|
1268
|
+
if (!start.ok) {
|
|
1269
|
+
return start;
|
|
1270
|
+
}
|
|
1271
|
+
if (input.endsAt == null) {
|
|
1272
|
+
return {
|
|
1273
|
+
ok: true,
|
|
1274
|
+
value: start.value
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
try {
|
|
1278
|
+
return {
|
|
1279
|
+
ok: true,
|
|
1280
|
+
value: {
|
|
1281
|
+
...start.value,
|
|
1282
|
+
endsAt: toUtcIsoDateTimeString(input.endsAt)
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
} catch {
|
|
1286
|
+
return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
field: "endsAt",
|
|
1289
|
+
reason: "invalid_ends_at",
|
|
1290
|
+
value: input.endsAt
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
function toScheduledBookingDateTime(input) {
|
|
1295
|
+
const result = parseScheduledBookingDateTime(input);
|
|
1296
|
+
if (result.ok) {
|
|
1297
|
+
return result.value;
|
|
1298
|
+
}
|
|
1299
|
+
throw new Error(formatParseError(result));
|
|
1300
|
+
}
|
|
1301
|
+
function toScheduledBookingDateRange(input) {
|
|
1302
|
+
const result = parseScheduledBookingDateRange(input);
|
|
1303
|
+
if (result.ok) {
|
|
1304
|
+
return result.value;
|
|
1305
|
+
}
|
|
1306
|
+
throw new Error(formatParseError(result));
|
|
1307
|
+
}
|
|
1308
|
+
function formatBookingInstant(isoString, options) {
|
|
1309
|
+
const date = new Date(isoString);
|
|
1310
|
+
if (Number.isNaN(date.getTime())) {
|
|
1311
|
+
return isoString;
|
|
1312
|
+
}
|
|
1313
|
+
return getCachedDateTimeFormatter(options.locale ?? DEFAULT_LOCALE, getSafeTimeZone(options.timeZone), options.format).format(date);
|
|
1314
|
+
}
|
|
1315
|
+
function formatBookingDate(input, format = {
|
|
1316
|
+
weekday: "long",
|
|
1317
|
+
year: "numeric",
|
|
1318
|
+
month: "long",
|
|
1319
|
+
day: "numeric"
|
|
1320
|
+
}) {
|
|
1321
|
+
return formatBookingInstant(input.startsAt, {
|
|
1322
|
+
locale: input.locale,
|
|
1323
|
+
timeZone: input.timeZone,
|
|
1324
|
+
format
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
function formatBookingTime(input, format = {
|
|
1328
|
+
hour: "numeric",
|
|
1329
|
+
minute: "2-digit",
|
|
1330
|
+
hour12: true
|
|
1331
|
+
}) {
|
|
1332
|
+
return formatBookingInstant(input.startsAt, {
|
|
1333
|
+
locale: input.locale,
|
|
1334
|
+
timeZone: input.timeZone,
|
|
1335
|
+
format
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Formats the compact English event-time presentation used by public surfaces.
|
|
1340
|
+
*
|
|
1341
|
+
* This intentionally owns the lowercase day-period token and zero-minute
|
|
1342
|
+
* omission instead of depending on locale-specific spacing or punctuation.
|
|
1343
|
+
* Callers must only use it for an English presentation contract.
|
|
1344
|
+
*/
|
|
1345
|
+
function formatBookingCompactEnglishTime(input) {
|
|
1346
|
+
const date = new Date(input.startsAt);
|
|
1347
|
+
if (Number.isNaN(date.getTime())) {
|
|
1348
|
+
return input.startsAt;
|
|
1349
|
+
}
|
|
1350
|
+
const parts = formatToPartsLookup(date, input.locale ?? "en-GB", input.timeZone, {
|
|
1351
|
+
hour: "numeric",
|
|
1352
|
+
minute: "2-digit",
|
|
1353
|
+
hour12: true
|
|
1354
|
+
});
|
|
1355
|
+
const hour = parts.get("hour");
|
|
1356
|
+
const minute = parts.get("minute");
|
|
1357
|
+
const dayPeriod = parts.get("dayPeriod");
|
|
1358
|
+
if (hour === undefined || minute === undefined || dayPeriod === undefined) {
|
|
1359
|
+
return input.startsAt;
|
|
1360
|
+
}
|
|
1361
|
+
const clock = minute === "00" ? hour : `${hour}:${minute}`;
|
|
1362
|
+
return `${clock}${dayPeriod.toLowerCase()}`;
|
|
1363
|
+
}
|
|
1364
|
+
function formatBookingDateTime(input, format = DEFAULT_BOOKING_DATE_TIME_FORMAT) {
|
|
1365
|
+
if (format === DEFAULT_BOOKING_DATE_TIME_FORMAT) {
|
|
1366
|
+
return `${formatBookingDate(input, {
|
|
1367
|
+
weekday: "short",
|
|
1368
|
+
month: "short",
|
|
1369
|
+
day: "numeric"
|
|
1370
|
+
})}, ${formatBookingTime(input)}`;
|
|
1371
|
+
}
|
|
1372
|
+
return formatBookingInstant(input.startsAt, {
|
|
1373
|
+
locale: input.locale,
|
|
1374
|
+
timeZone: input.timeZone,
|
|
1375
|
+
format
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
function formatBookingDateParts(input, options) {
|
|
1379
|
+
return {
|
|
1380
|
+
date: formatBookingDate(input, options?.dateFormat ?? {
|
|
1381
|
+
weekday: "short",
|
|
1382
|
+
month: "short",
|
|
1383
|
+
day: "numeric",
|
|
1384
|
+
year: "numeric"
|
|
1385
|
+
}),
|
|
1386
|
+
time: formatBookingTime(input, options?.timeFormat ?? {
|
|
1387
|
+
hour: "numeric",
|
|
1388
|
+
minute: "2-digit",
|
|
1389
|
+
hour12: true
|
|
1390
|
+
})
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
function getBookingLocalDateKey(input) {
|
|
1394
|
+
return formatDateKey(getLocalScheduleDate(new Date(input.startsAt), input.timeZone));
|
|
1395
|
+
}
|
|
1396
|
+
function compareLocalScheduleDates(left, right) {
|
|
1397
|
+
return Date.UTC(left.year, left.month - 1, left.day) - Date.UTC(right.year, right.month - 1, right.day);
|
|
1398
|
+
}
|
|
1399
|
+
function parseBookingLocalDate(value) {
|
|
1400
|
+
const match = LOCAL_DATE_PATTERN.exec(value.trim());
|
|
1401
|
+
if (match === null || match.groups === undefined) {
|
|
1402
|
+
throw new Error(`Invalid booking date format: "${value}" (expected YYYY-MM-DD)`);
|
|
1403
|
+
}
|
|
1404
|
+
const year = Number(match.groups.year);
|
|
1405
|
+
const month = Number(match.groups.month);
|
|
1406
|
+
const day = Number(match.groups.day);
|
|
1407
|
+
const candidate = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
|
|
1408
|
+
if (Number.isNaN(candidate.getTime()) || candidate.getUTCFullYear() !== year || candidate.getUTCMonth() + 1 !== month || candidate.getUTCDate() !== day) {
|
|
1409
|
+
throw new Error(`Invalid booking date format: "${value}" (expected YYYY-MM-DD)`);
|
|
1410
|
+
}
|
|
1411
|
+
return {
|
|
1412
|
+
year,
|
|
1413
|
+
month,
|
|
1414
|
+
day
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
function parseBookingUtcDateWindow(input) {
|
|
1418
|
+
const timeZone = resolveBookingTimeZoneId(input.timeZone);
|
|
1419
|
+
let startLocalDate;
|
|
1420
|
+
try {
|
|
1421
|
+
startLocalDate = parseBookingLocalDate(input.startDate);
|
|
1422
|
+
} catch {
|
|
1423
|
+
return {
|
|
1424
|
+
ok: false,
|
|
1425
|
+
field: "startDate",
|
|
1426
|
+
reason: "invalid_date_format",
|
|
1427
|
+
value: input.startDate
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
let endLocalDate;
|
|
1431
|
+
try {
|
|
1432
|
+
endLocalDate = parseBookingLocalDate(input.endDate);
|
|
1433
|
+
} catch {
|
|
1434
|
+
return {
|
|
1435
|
+
ok: false,
|
|
1436
|
+
field: "endDate",
|
|
1437
|
+
reason: "invalid_date_format",
|
|
1438
|
+
value: input.endDate
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
if (compareLocalScheduleDates(startLocalDate, endLocalDate) > 0) {
|
|
1442
|
+
return {
|
|
1443
|
+
ok: false,
|
|
1444
|
+
field: "endDate",
|
|
1445
|
+
reason: "invalid_date_range",
|
|
1446
|
+
value: input.endDate
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
ok: true,
|
|
1451
|
+
value: {
|
|
1452
|
+
timeZone,
|
|
1453
|
+
startLocalDate,
|
|
1454
|
+
endLocalDate,
|
|
1455
|
+
startAt: localScheduleDateTimeToUtcDate(startLocalDate, START_OF_DAY, timeZone),
|
|
1456
|
+
endExclusive: localScheduleDateTimeToUtcDate(addLocalScheduleDays(endLocalDate, 1), START_OF_DAY, timeZone)
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
function buildBookingUtcDateWindow(input) {
|
|
1461
|
+
const result = parseBookingUtcDateWindow(input);
|
|
1462
|
+
if (result.ok) {
|
|
1463
|
+
return result.value;
|
|
1464
|
+
}
|
|
1465
|
+
if (result.reason === "invalid_date_range") {
|
|
1466
|
+
throw new Error(`Invalid booking date range: startDate "${input.startDate}" must be on or before endDate "${input.endDate}"`);
|
|
1467
|
+
}
|
|
1468
|
+
throw new Error(`Invalid booking date format: "${result.value}" (expected YYYY-MM-DD)`);
|
|
1469
|
+
}
|
|
1470
|
+
function formatBookingRange(input, options) {
|
|
1471
|
+
if (input.endsAt == null) {
|
|
1472
|
+
const parts = formatBookingDateParts(input, options);
|
|
1473
|
+
return `${parts.date}, ${parts.time}`;
|
|
1474
|
+
}
|
|
1475
|
+
const startKey = getBookingLocalDateKey(input);
|
|
1476
|
+
const endDateTime = toScheduledBookingDateTime({
|
|
1477
|
+
startsAt: input.endsAt,
|
|
1478
|
+
timeZone: input.timeZone,
|
|
1479
|
+
locale: input.locale
|
|
1480
|
+
});
|
|
1481
|
+
const endKey = getBookingLocalDateKey(endDateTime);
|
|
1482
|
+
const startDate = formatBookingDate(input, options?.dateFormat ?? {
|
|
1483
|
+
month: "short",
|
|
1484
|
+
day: "numeric",
|
|
1485
|
+
year: "numeric"
|
|
1486
|
+
});
|
|
1487
|
+
const startTime = formatBookingTime(input, options?.timeFormat);
|
|
1488
|
+
const endTime = formatBookingTime(endDateTime, options?.timeFormat);
|
|
1489
|
+
if (startKey === endKey) {
|
|
1490
|
+
return `${startDate}, ${startTime} - ${endTime}`;
|
|
1491
|
+
}
|
|
1492
|
+
const endDate = formatBookingDate(endDateTime, options?.dateFormat ?? {
|
|
1493
|
+
month: "short",
|
|
1494
|
+
day: "numeric",
|
|
1495
|
+
year: "numeric"
|
|
1496
|
+
});
|
|
1497
|
+
return `${startDate}, ${startTime} - ${endDate}, ${endTime}`;
|
|
1498
|
+
}
|
|
1499
|
+
function parseLocalScheduleTime(value) {
|
|
1500
|
+
const match = /^(?<hour>\d{1,2}):(?<minute>\d{2})(?::(?<second>\d{2}))?$/u.exec(value.trim());
|
|
1501
|
+
if (match === null || match.groups === undefined) {
|
|
1502
|
+
throw new Error(`Invalid start time format: "${value}" (expected HH:MM or HH:MM:SS)`);
|
|
1503
|
+
}
|
|
1504
|
+
const hour = Number(match.groups.hour);
|
|
1505
|
+
const minute = Number(match.groups.minute);
|
|
1506
|
+
const second = match.groups.second !== undefined ? Number(match.groups.second) : 0;
|
|
1507
|
+
if (Number.isNaN(hour) || Number.isNaN(minute) || Number.isNaN(second) || hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
|
|
1508
|
+
throw new Error(`Invalid start time format: "${value}" (expected HH:MM or HH:MM:SS)`);
|
|
1509
|
+
}
|
|
1510
|
+
return {
|
|
1511
|
+
hour,
|
|
1512
|
+
minute,
|
|
1513
|
+
second
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
function getLocalScheduleDate(date, timeZone) {
|
|
1517
|
+
const parts = getZonedDateTimeParts(date, getSafeTimeZone(timeZone));
|
|
1518
|
+
return {
|
|
1519
|
+
year: parts.year,
|
|
1520
|
+
month: parts.month,
|
|
1521
|
+
day: parts.day
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
function getLocalScheduleTime(date, timeZone) {
|
|
1525
|
+
const parts = getZonedDateTimeParts(date, getSafeTimeZone(timeZone));
|
|
1526
|
+
return {
|
|
1527
|
+
hour: parts.hour,
|
|
1528
|
+
minute: parts.minute,
|
|
1529
|
+
second: parts.second
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
function formatLocalScheduleDate(date) {
|
|
1533
|
+
return formatDateKey(date);
|
|
1534
|
+
}
|
|
1535
|
+
function formatLocalScheduleTime(time) {
|
|
1536
|
+
return `${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}`;
|
|
1537
|
+
}
|
|
1538
|
+
function getDaysInLocalMonth(year, month) {
|
|
1539
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
1540
|
+
}
|
|
1541
|
+
function addLocalScheduleDays(date, days) {
|
|
1542
|
+
const utcDate = new Date(Date.UTC(date.year, date.month - 1, date.day + days, 12, 0, 0));
|
|
1543
|
+
return {
|
|
1544
|
+
year: utcDate.getUTCFullYear(),
|
|
1545
|
+
month: utcDate.getUTCMonth() + 1,
|
|
1546
|
+
day: utcDate.getUTCDate()
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
function addLocalScheduleMonths(date, months) {
|
|
1550
|
+
const targetMonthIndex = date.month - 1 + months;
|
|
1551
|
+
const base = new Date(Date.UTC(date.year, targetMonthIndex, 1, 12, 0, 0));
|
|
1552
|
+
const year = base.getUTCFullYear();
|
|
1553
|
+
const month = base.getUTCMonth() + 1;
|
|
1554
|
+
const day = Math.min(date.day, getDaysInLocalMonth(year, month));
|
|
1555
|
+
return {
|
|
1556
|
+
year,
|
|
1557
|
+
month,
|
|
1558
|
+
day
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
function getLocalScheduleDayOfWeek(date) {
|
|
1562
|
+
return new Date(Date.UTC(date.year, date.month - 1, date.day, 12, 0, 0)).getUTCDay();
|
|
1563
|
+
}
|
|
1564
|
+
function localScheduleDateTimeToUtcDate(localDate, localTime, timeZone) {
|
|
1565
|
+
const resolvedTimeZone = getSafeTimeZone(timeZone);
|
|
1566
|
+
const targetLocalMs = Date.UTC(localDate.year, localDate.month - 1, localDate.day, localTime.hour, localTime.minute, localTime.second, 0);
|
|
1567
|
+
let guess = targetLocalMs;
|
|
1568
|
+
const seenGuesses = new Set();
|
|
1569
|
+
for (let iteration = 0; iteration < 4; iteration += 1) {
|
|
1570
|
+
seenGuesses.add(guess);
|
|
1571
|
+
const actual = getZonedDateTimeParts(new Date(guess), resolvedTimeZone);
|
|
1572
|
+
const actualLocalMs = Date.UTC(actual.year, actual.month - 1, actual.day, actual.hour, actual.minute, actual.second, 0);
|
|
1573
|
+
const diff = actualLocalMs - targetLocalMs;
|
|
1574
|
+
if (diff === 0) {
|
|
1575
|
+
return new Date(guess);
|
|
1576
|
+
}
|
|
1577
|
+
const nextGuess = guess - diff;
|
|
1578
|
+
if (seenGuesses.has(nextGuess)) {
|
|
1579
|
+
return new Date(Math.max(guess, nextGuess));
|
|
1580
|
+
}
|
|
1581
|
+
guess = nextGuess;
|
|
1582
|
+
}
|
|
1583
|
+
return new Date(guess);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
//#endregion
|
|
1587
|
+
//#region ../core/src/dated-offering-settlement.ts
|
|
1588
|
+
function parseStripeReference(value) {
|
|
1589
|
+
if (typeof value !== "string") return err({ kind: "not_string" });
|
|
1590
|
+
const normalized = value.trim();
|
|
1591
|
+
return normalized.length === 0 ? err({ kind: "empty_string" }) : ok(normalized);
|
|
1592
|
+
}
|
|
1593
|
+
const parseStripePaymentIntentRef = (value) => parseStripeReference(value);
|
|
1594
|
+
const parseStripeWebhookEventRef = (value) => parseStripeReference(value);
|
|
1595
|
+
const parseStripeBalanceTransactionRef = (value) => parseStripeReference(value);
|
|
1596
|
+
const parseStripeChargeRef = (value) => parseStripeReference(value);
|
|
1597
|
+
const parseStripeAccountRef = (value) => parseStripeReference(value);
|
|
1598
|
+
const parseStripeCheckoutSessionId = (value) => parseStripeReference(value);
|
|
1599
|
+
|
|
1600
|
+
//#endregion
|
|
1601
|
+
//#region ../core/src/person-records.ts
|
|
1602
|
+
const RECORD_ENTRY_KINDS = [
|
|
1603
|
+
"person_note",
|
|
1604
|
+
"prep_note",
|
|
1605
|
+
"session_note",
|
|
1606
|
+
"follow_up_note",
|
|
1607
|
+
"booking_questions_snapshot",
|
|
1608
|
+
"correction_note"
|
|
1609
|
+
];
|
|
1610
|
+
const PERSON_RECORD_NOTE_KINDS = [
|
|
1611
|
+
"person_note",
|
|
1612
|
+
"prep_note",
|
|
1613
|
+
"session_note",
|
|
1614
|
+
"follow_up_note",
|
|
1615
|
+
"correction_note"
|
|
1616
|
+
];
|
|
1617
|
+
function parseRecordEntryKind(value) {
|
|
1618
|
+
return RECORD_ENTRY_KINDS.includes(value) ? ok(value) : err({
|
|
1619
|
+
kind: "unsupported_record_entry_kind",
|
|
1620
|
+
value
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
//#endregion
|
|
1625
|
+
//#region ../core/src/rectification.ts
|
|
1626
|
+
const RECTIFICATION_SURFACES = [
|
|
1627
|
+
"customer_profile",
|
|
1628
|
+
"booking_identity",
|
|
1629
|
+
"event_registration_identity",
|
|
1630
|
+
"course_enrollment_identity",
|
|
1631
|
+
"person_record_entry",
|
|
1632
|
+
"booking_question_snapshot",
|
|
1633
|
+
"form_submission",
|
|
1634
|
+
"newsletter_subscriber",
|
|
1635
|
+
"order_financial_record",
|
|
1636
|
+
"audit_event",
|
|
1637
|
+
"derived_history_ledger"
|
|
1638
|
+
];
|
|
1639
|
+
const RECTIFICATION_ISSUE_URL_BASE = "https://github.com/wubss/builder/issues";
|
|
1640
|
+
const RECTIFICATION_IDENTITY_BOUNDARIES = {
|
|
1641
|
+
customer_commercial_current_identity: { kind: "customer_commercial_current_identity" },
|
|
1642
|
+
customer_backed_participant_current_identity: { kind: "customer_backed_participant_current_identity" },
|
|
1643
|
+
person_record_current_note: { kind: "person_record_current_note" },
|
|
1644
|
+
immutable_subject_snapshot: { kind: "immutable_subject_snapshot" },
|
|
1645
|
+
newsletter_current_subscription_identity: { kind: "newsletter_current_subscription_identity" },
|
|
1646
|
+
finance_integrity_record: { kind: "finance_integrity_record" },
|
|
1647
|
+
immutable_audit_history: { kind: "immutable_audit_history" },
|
|
1648
|
+
derived_history_projection_or_ledger: { kind: "derived_history_projection_or_ledger" }
|
|
1649
|
+
};
|
|
1650
|
+
const RECTIFICATION_HISTORY_PROPAGATION_PLANS = {
|
|
1651
|
+
update_current_identity_only: { kind: "update_current_identity_only" },
|
|
1652
|
+
append_correction_note: { kind: "append_correction_note" },
|
|
1653
|
+
annotate_immutable_snapshot: { kind: "annotate_immutable_snapshot" },
|
|
1654
|
+
manual_review_required: { kind: "manual_review_required" },
|
|
1655
|
+
blocked_immutable_history: { kind: "blocked_immutable_history" }
|
|
1656
|
+
};
|
|
1657
|
+
|
|
1658
|
+
//#endregion
|
|
1659
|
+
//#region ../core/src/location.ts
|
|
1660
|
+
function normalizeLocationAddressText(value) {
|
|
1661
|
+
if (typeof value !== "string") {
|
|
1662
|
+
return null;
|
|
1663
|
+
}
|
|
1664
|
+
const trimmed = value.trim();
|
|
1665
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1666
|
+
}
|
|
1667
|
+
function hasLocationCoordinates(value) {
|
|
1668
|
+
return value != null && typeof value.lat === "number" && typeof value.lng === "number";
|
|
1669
|
+
}
|
|
1670
|
+
function toLocationCoordinates(value) {
|
|
1671
|
+
if (!hasLocationCoordinates(value)) {
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
return {
|
|
1675
|
+
lat: value.lat,
|
|
1676
|
+
lng: value.lng
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
//#endregion
|
|
1681
|
+
//#region ../core/src/string.ts
|
|
1682
|
+
/**
|
|
1683
|
+
* String utilities
|
|
1684
|
+
*/
|
|
1685
|
+
/**
|
|
1686
|
+
* Return the string value when the input is already a string, otherwise null.
|
|
1687
|
+
*/
|
|
1688
|
+
function asNullableString(value) {
|
|
1689
|
+
return typeof value === "string" ? value : null;
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Return a non-empty string value, otherwise null.
|
|
1693
|
+
*/
|
|
1694
|
+
function asNonEmptyStringOrNull(value) {
|
|
1695
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Narrow nullable values to the present value.
|
|
1699
|
+
*/
|
|
1700
|
+
function isPresent(value) {
|
|
1701
|
+
return value !== null && value !== undefined;
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Return true for string values that contain at least one character.
|
|
1705
|
+
*/
|
|
1706
|
+
function isNonEmptyString(value) {
|
|
1707
|
+
return typeof value === "string" && value.length > 0;
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Narrow optional string values to non-empty strings.
|
|
1711
|
+
*/
|
|
1712
|
+
function hasText(value) {
|
|
1713
|
+
return isNonEmptyString(value);
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Trim a user-entered string and collapse blank values to null.
|
|
1717
|
+
*/
|
|
1718
|
+
function trimmedStringOrNull(value) {
|
|
1719
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
1720
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Capitalize the first character of a string
|
|
1724
|
+
*/
|
|
1725
|
+
function capitalize(str) {
|
|
1726
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
//#endregion
|
|
1730
|
+
//#region ../core/src/business-address.ts
|
|
1731
|
+
function normalizeCountryCode(value) {
|
|
1732
|
+
const trimmed = trimmedStringOrNull(value);
|
|
1733
|
+
return trimmed !== null ? trimmed.toUpperCase() : null;
|
|
1734
|
+
}
|
|
1735
|
+
function normalizeCoordinate(value) {
|
|
1736
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1737
|
+
}
|
|
1738
|
+
function isLatitude(value) {
|
|
1739
|
+
return value >= -90 && value <= 90;
|
|
1740
|
+
}
|
|
1741
|
+
function isLongitude(value) {
|
|
1742
|
+
return value >= -180 && value <= 180;
|
|
1743
|
+
}
|
|
1744
|
+
function buildBusinessAddressDisplayText(input) {
|
|
1745
|
+
const displayText = [
|
|
1746
|
+
trimmedStringOrNull(input.addressLine1),
|
|
1747
|
+
trimmedStringOrNull(input.addressLine2),
|
|
1748
|
+
trimmedStringOrNull(input.locality),
|
|
1749
|
+
trimmedStringOrNull(input.region),
|
|
1750
|
+
trimmedStringOrNull(input.postalCode),
|
|
1751
|
+
normalizeCountryCode(input.countryCode)
|
|
1752
|
+
].filter((part) => part !== null).join(", ");
|
|
1753
|
+
return displayText.length > 0 ? displayText : null;
|
|
1754
|
+
}
|
|
1755
|
+
function validateBusinessAddressDraft(input) {
|
|
1756
|
+
const addressLine1 = trimmedStringOrNull(input.addressLine1);
|
|
1757
|
+
const addressLine2 = trimmedStringOrNull(input.addressLine2);
|
|
1758
|
+
const locality = trimmedStringOrNull(input.locality);
|
|
1759
|
+
const region = trimmedStringOrNull(input.region);
|
|
1760
|
+
const postalCode = trimmedStringOrNull(input.postalCode);
|
|
1761
|
+
const countryCode = normalizeCountryCode(input.countryCode);
|
|
1762
|
+
const displayAddress = trimmedStringOrNull(input.displayAddress) ?? buildBusinessAddressDisplayText(input);
|
|
1763
|
+
const lat = normalizeCoordinate(input.lat);
|
|
1764
|
+
const lng = normalizeCoordinate(input.lng);
|
|
1765
|
+
const errors = [];
|
|
1766
|
+
if (addressLine1 === null) errors.push("missing_address_line_1");
|
|
1767
|
+
if (locality === null) errors.push("missing_locality");
|
|
1768
|
+
if (countryCode === null) errors.push("missing_country_code");
|
|
1769
|
+
if (displayAddress === null) errors.push("missing_display_address");
|
|
1770
|
+
if (lat === null) errors.push("missing_latitude");
|
|
1771
|
+
else if (!isLatitude(lat)) errors.push("invalid_latitude");
|
|
1772
|
+
if (lng === null) errors.push("missing_longitude");
|
|
1773
|
+
else if (!isLongitude(lng)) errors.push("invalid_longitude");
|
|
1774
|
+
if (errors.length > 0 || addressLine1 === null || locality === null || countryCode === null || displayAddress === null || lat === null || lng === null) {
|
|
1775
|
+
return {
|
|
1776
|
+
success: false,
|
|
1777
|
+
errors
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
return {
|
|
1781
|
+
success: true,
|
|
1782
|
+
address: {
|
|
1783
|
+
addressLine1,
|
|
1784
|
+
addressLine2,
|
|
1785
|
+
locality,
|
|
1786
|
+
region,
|
|
1787
|
+
postalCode,
|
|
1788
|
+
countryCode,
|
|
1789
|
+
displayAddress,
|
|
1790
|
+
lat,
|
|
1791
|
+
lng
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
function isCompleteBusinessAddress(value) {
|
|
1796
|
+
if (value == null) return false;
|
|
1797
|
+
return validateBusinessAddressDraft(value).success;
|
|
1798
|
+
}
|
|
1799
|
+
function businessAddressToAddressedLocation(address) {
|
|
1800
|
+
if (address == null) return null;
|
|
1801
|
+
return {
|
|
1802
|
+
addressText: address.displayAddress,
|
|
1803
|
+
lat: address.lat,
|
|
1804
|
+
lng: address.lng
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
//#endregion
|
|
1809
|
+
//#region ../core/src/try-async.ts
|
|
1810
|
+
async function tryAsync(run, toError) {
|
|
1811
|
+
try {
|
|
1812
|
+
return ok(await run());
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
return err(toError(error));
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
//#endregion
|
|
1819
|
+
//#region ../core/src/narrow-result.ts
|
|
1820
|
+
/** Unwrap a NarrowResult, throwing on failure. Use at persistence boundaries. */
|
|
1821
|
+
function unwrapNarrow(result) {
|
|
1822
|
+
if (!result.ok) throw new Error(result.error);
|
|
1823
|
+
return result.value;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
//#endregion
|
|
1827
|
+
//#region ../core/src/non-empty-array.ts
|
|
1828
|
+
function fromArray(items) {
|
|
1829
|
+
const [first, ...rest] = items;
|
|
1830
|
+
if (first === undefined) {
|
|
1831
|
+
return err("empty_array");
|
|
1832
|
+
}
|
|
1833
|
+
return ok([first, ...rest]);
|
|
1834
|
+
}
|
|
1835
|
+
function mapNonEmptyArray(items, map) {
|
|
1836
|
+
return items.map(map);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
//#endregion
|
|
1840
|
+
//#region ../core/src/editor-state.ts
|
|
1841
|
+
const unsafeAsEditorSiteId = (value) => value;
|
|
1842
|
+
const unsafeAsEditorPageId = (value) => value;
|
|
1843
|
+
const unsafeAsEditorEntryId = (value) => value;
|
|
1844
|
+
const unsafeAsEditorBlockId = (value) => value;
|
|
1845
|
+
const unsafeAsEditorMetadataValues = (value) => value;
|
|
1846
|
+
const unsafeAsEditorContentValues = (value) => value;
|
|
1847
|
+
const unsafeAsEditorBlockValues = (value) => value;
|
|
1848
|
+
function createEditorInsertBlockCommand(block, draft, index) {
|
|
1849
|
+
return {
|
|
1850
|
+
type: "insert-block",
|
|
1851
|
+
block,
|
|
1852
|
+
draft,
|
|
1853
|
+
index
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
function createEditorRemoveBlockCommand(blockId) {
|
|
1857
|
+
return {
|
|
1858
|
+
type: "remove-block",
|
|
1859
|
+
blockId
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
function createEditorReorderBlocksCommand(blockIds) {
|
|
1863
|
+
return {
|
|
1864
|
+
type: "reorder-blocks",
|
|
1865
|
+
blockIds
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
//#endregion
|
|
1870
|
+
//#region ../core/src/sensitive-collection.ts
|
|
1871
|
+
const SENSITIVE_COLLECTION_MODES = ["standard", "sensitive"];
|
|
1872
|
+
const SENSITIVE_COLLECTION_SURFACES = ["form", "booking_questions"];
|
|
1873
|
+
const SENSITIVE_COLLECTION_AUDIT_TARGET_TYPES = [
|
|
1874
|
+
"form",
|
|
1875
|
+
"appointment_service",
|
|
1876
|
+
"event_series",
|
|
1877
|
+
"course"
|
|
1878
|
+
];
|
|
1879
|
+
const SENSITIVE_DOWNSTREAM_CHANNELS = [
|
|
1880
|
+
"owner_notification_email",
|
|
1881
|
+
"customer_auto_reply",
|
|
1882
|
+
"webhook_payload",
|
|
1883
|
+
"webhook_delivery_log",
|
|
1884
|
+
"csv_export",
|
|
1885
|
+
"dashboard_submission_view",
|
|
1886
|
+
"dsar_export",
|
|
1887
|
+
"protected_record_snapshot",
|
|
1888
|
+
"audit_metadata"
|
|
1889
|
+
];
|
|
1890
|
+
const SENSITIVE_COLLECTION_CONFIRMATION_VERSION = 1;
|
|
1891
|
+
const SENSITIVE_COLLECTION_CONFIRMATION_COPY = [
|
|
1892
|
+
"I understand this form may collect sensitive client information.",
|
|
1893
|
+
"I understand Riverbank will restrict emails, webhooks, exports, and logs for these answers.",
|
|
1894
|
+
"I understand existing external copies made before enabling this setting cannot be automatically recalled."
|
|
1895
|
+
];
|
|
1896
|
+
const SENSITIVE_SUBMISSION_DASHBOARD_REMINDER_COPY = "This submission is marked sensitive. Only use or share these answers where necessary, and avoid copying them into emails or external systems unless you have a clear reason.";
|
|
1897
|
+
const SENSITIVE_SUBMISSION_DETAIL_REMINDER_COPY = "This submission is marked sensitive. Keep answers inside Riverbank unless there is a clear reason to copy them elsewhere.";
|
|
1898
|
+
const SENSITIVE_METADATA_ONLY_PAYLOAD_KEYS = [
|
|
1899
|
+
"submissionId",
|
|
1900
|
+
"siteId",
|
|
1901
|
+
"surfaceId",
|
|
1902
|
+
"surfaceTitle",
|
|
1903
|
+
"submittedAt",
|
|
1904
|
+
"dashboardUrl",
|
|
1905
|
+
"sensitiveCollectionMode"
|
|
1906
|
+
];
|
|
1907
|
+
const STANDARD_SENSITIVE_COLLECTION_CONFIG = {
|
|
1908
|
+
mode: "standard",
|
|
1909
|
+
enabledAt: null,
|
|
1910
|
+
enabledByUserId: null,
|
|
1911
|
+
confirmationVersion: null
|
|
1912
|
+
};
|
|
1913
|
+
function parseSensitiveCollectionMode(value) {
|
|
1914
|
+
return value === "standard" || value === "sensitive" ? value : null;
|
|
1915
|
+
}
|
|
1916
|
+
function parseSensitiveCollectionConfirmationVersion(value) {
|
|
1917
|
+
return value === SENSITIVE_COLLECTION_CONFIRMATION_VERSION ? SENSITIVE_COLLECTION_CONFIRMATION_VERSION : null;
|
|
1918
|
+
}
|
|
1919
|
+
function isSensitiveCollectionAdminRole(actorRole) {
|
|
1920
|
+
return actorRole === "site_owner" || actorRole === "site_admin";
|
|
1921
|
+
}
|
|
1922
|
+
function deriveEffectiveSensitiveCollectionMode(input) {
|
|
1923
|
+
return input.snapshotMode === "sensitive" || input.currentMode === "sensitive" ? "sensitive" : "standard";
|
|
1924
|
+
}
|
|
1925
|
+
function planSensitiveCollectionConfigChange(input) {
|
|
1926
|
+
const { current, requested, actorRole, siteProfile } = input;
|
|
1927
|
+
if (current.mode === "sensitive" && requested.mode === "sensitive") {
|
|
1928
|
+
return siteProfile === "sensitive_client_records" ? {
|
|
1929
|
+
kind: "noop_already_sensitive",
|
|
1930
|
+
current
|
|
1931
|
+
} : {
|
|
1932
|
+
kind: "noop_already_sensitive_grandfathered",
|
|
1933
|
+
current
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
if (current.mode === "standard" && requested.mode === "standard") {
|
|
1937
|
+
return {
|
|
1938
|
+
kind: "noop_standard",
|
|
1939
|
+
current
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
if (!isSensitiveCollectionAdminRole(actorRole)) {
|
|
1943
|
+
return { kind: "denied_actor_not_admin" };
|
|
1944
|
+
}
|
|
1945
|
+
if (requested.mode === "standard") {
|
|
1946
|
+
return {
|
|
1947
|
+
kind: "allowed_disable",
|
|
1948
|
+
next: STANDARD_SENSITIVE_COLLECTION_CONFIG
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
if (siteProfile !== "sensitive_client_records") {
|
|
1952
|
+
return { kind: "denied_site_profile_not_enabled" };
|
|
1953
|
+
}
|
|
1954
|
+
if (requested.confirmationVersion !== SENSITIVE_COLLECTION_CONFIRMATION_VERSION) {
|
|
1955
|
+
return {
|
|
1956
|
+
kind: "invalid_confirmation_version",
|
|
1957
|
+
expectedVersion: SENSITIVE_COLLECTION_CONFIRMATION_VERSION
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
return {
|
|
1961
|
+
kind: "allowed_enable",
|
|
1962
|
+
next: {
|
|
1963
|
+
mode: "sensitive",
|
|
1964
|
+
enabledAt: input.nowIso,
|
|
1965
|
+
enabledByUserId: input.actorUserId,
|
|
1966
|
+
confirmationVersion: requested.confirmationVersion
|
|
1967
|
+
}
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
function planSensitiveSubmissionTreatment(input) {
|
|
1971
|
+
const { mode, channel } = input;
|
|
1972
|
+
if (mode === "standard") {
|
|
1973
|
+
return {
|
|
1974
|
+
kind: "standard_payload",
|
|
1975
|
+
channel
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
switch (channel) {
|
|
1979
|
+
case "owner_notification_email":
|
|
1980
|
+
case "webhook_payload":
|
|
1981
|
+
case "webhook_delivery_log":
|
|
1982
|
+
case "audit_metadata": return {
|
|
1983
|
+
kind: "metadata_only",
|
|
1984
|
+
channel
|
|
1985
|
+
};
|
|
1986
|
+
case "customer_auto_reply": return {
|
|
1987
|
+
kind: "no_answer_echo",
|
|
1988
|
+
channel
|
|
1989
|
+
};
|
|
1990
|
+
case "csv_export": return {
|
|
1991
|
+
kind: "restricted_or_redacted",
|
|
1992
|
+
channel
|
|
1993
|
+
};
|
|
1994
|
+
case "dashboard_submission_view": return {
|
|
1995
|
+
kind: "authorized_with_reminder",
|
|
1996
|
+
channel
|
|
1997
|
+
};
|
|
1998
|
+
case "dsar_export": return {
|
|
1999
|
+
kind: "include_marked_sensitive",
|
|
2000
|
+
channel
|
|
2001
|
+
};
|
|
2002
|
+
case "protected_record_snapshot": return {
|
|
2003
|
+
kind: "protected_storage_required",
|
|
2004
|
+
channel
|
|
2005
|
+
};
|
|
2006
|
+
default: return assertNever$2(channel);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
function assertNever$2(value) {
|
|
2010
|
+
throw new Error(`Unhandled sensitive collection case: ${String(value)}`);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
//#endregion
|
|
2014
|
+
//#region ../core/src/participants.ts
|
|
2015
|
+
function unsafeAsParticipantEmail(value) {
|
|
2016
|
+
return value;
|
|
2017
|
+
}
|
|
2018
|
+
function unsafeAsParticipantSlotKey(value) {
|
|
2019
|
+
return value;
|
|
2020
|
+
}
|
|
2021
|
+
function unsafeAsParticipantCount(value) {
|
|
2022
|
+
return value;
|
|
2023
|
+
}
|
|
2024
|
+
function unsafeAsParticipantDisplayLabel(value) {
|
|
2025
|
+
return value;
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Canonicalizes an optional participant email for equality checks.
|
|
2029
|
+
*
|
|
2030
|
+
* This intentionally does not validate email shape; call parseParticipantEmail at
|
|
2031
|
+
* API/DB boundaries that require a syntactically usable address.
|
|
2032
|
+
*/
|
|
2033
|
+
function normalizeParticipantEmail(value) {
|
|
2034
|
+
const normalized = value?.trim().normalize("NFC").toLowerCase() ?? "";
|
|
2035
|
+
return normalized.length > 0 ? unsafeAsParticipantEmail(normalized) : null;
|
|
2036
|
+
}
|
|
2037
|
+
function parseParticipantEmail(value) {
|
|
2038
|
+
const normalized = normalizeParticipantEmail(value);
|
|
2039
|
+
if (normalized === null) {
|
|
2040
|
+
return err({ kind: "empty_email" });
|
|
2041
|
+
}
|
|
2042
|
+
if (!isEmailLike(normalized)) {
|
|
2043
|
+
return err({ kind: "invalid_email_shape" });
|
|
2044
|
+
}
|
|
2045
|
+
return ok(normalized);
|
|
2046
|
+
}
|
|
2047
|
+
function parseParticipantCount(value) {
|
|
2048
|
+
if (!Number.isInteger(value)) {
|
|
2049
|
+
return err({ kind: "non_integer_participant_count" });
|
|
2050
|
+
}
|
|
2051
|
+
if (value <= 0) {
|
|
2052
|
+
return err({ kind: "non_positive_participant_count" });
|
|
2053
|
+
}
|
|
2054
|
+
return ok(unsafeAsParticipantCount(value));
|
|
2055
|
+
}
|
|
2056
|
+
function provisionalEventGuestParticipantIdentity(input) {
|
|
2057
|
+
return {
|
|
2058
|
+
kind: "event_guest",
|
|
2059
|
+
eventAttendeeGuestId: input.eventAttendeeGuestId,
|
|
2060
|
+
slotKey: input.slotKey
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
const provisionalParticipantIdentityEncoders = { event_guest: (identity) => ({
|
|
2064
|
+
kind: identity.kind,
|
|
2065
|
+
source: "event_guest_backfill",
|
|
2066
|
+
eventAttendeeGuestId: identity.eventAttendeeGuestId,
|
|
2067
|
+
slotKey: identity.slotKey
|
|
2068
|
+
}) };
|
|
2069
|
+
function encodeProvisionalParticipantIdentity(identity) {
|
|
2070
|
+
return provisionalParticipantIdentityEncoders[identity.kind](identity);
|
|
2071
|
+
}
|
|
2072
|
+
function parseProvisionalParticipantIdentity(value) {
|
|
2073
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2074
|
+
return err({ kind: "not_an_object" });
|
|
2075
|
+
}
|
|
2076
|
+
const metadata = value;
|
|
2077
|
+
const kind = metadata.kind ?? metadata.source;
|
|
2078
|
+
if (kind !== "event_guest" && kind !== "event_guest_backfill") {
|
|
2079
|
+
return err({ kind: "unsupported_identity_kind" });
|
|
2080
|
+
}
|
|
2081
|
+
if (typeof metadata.eventAttendeeGuestId !== "string") {
|
|
2082
|
+
return err({ kind: "missing_event_attendee_guest_id" });
|
|
2083
|
+
}
|
|
2084
|
+
if (typeof metadata.slotKey !== "string") {
|
|
2085
|
+
return err({ kind: "missing_slot_key" });
|
|
2086
|
+
}
|
|
2087
|
+
return ok(provisionalEventGuestParticipantIdentity({
|
|
2088
|
+
eventAttendeeGuestId: unsafeAsEventAttendeeGuestId(metadata.eventAttendeeGuestId),
|
|
2089
|
+
slotKey: unsafeAsParticipantSlotKey(metadata.slotKey)
|
|
2090
|
+
}));
|
|
2091
|
+
}
|
|
2092
|
+
function deriveParticipantDisplayLabel(input) {
|
|
2093
|
+
const displayName = trimmedStringOrNull(input.displayName);
|
|
2094
|
+
if (displayName !== null) {
|
|
2095
|
+
return unsafeAsParticipantDisplayLabel(displayName);
|
|
2096
|
+
}
|
|
2097
|
+
if (input.emailNormalized !== null) {
|
|
2098
|
+
return unsafeAsParticipantDisplayLabel(input.emailNormalized);
|
|
2099
|
+
}
|
|
2100
|
+
return unsafeAsParticipantDisplayLabel("Participant");
|
|
2101
|
+
}
|
|
2102
|
+
function participantIdentityStateToContactFields(identityState) {
|
|
2103
|
+
switch (identityState.kind) {
|
|
2104
|
+
case "booker_supplied": return {
|
|
2105
|
+
identityState: "booker_supplied",
|
|
2106
|
+
email: null
|
|
2107
|
+
};
|
|
2108
|
+
case "contactable":
|
|
2109
|
+
case "portal_claimed": return {
|
|
2110
|
+
identityState: identityState.kind,
|
|
2111
|
+
email: identityState.email
|
|
2112
|
+
};
|
|
2113
|
+
case "staff_verified": return {
|
|
2114
|
+
identityState: "staff_verified",
|
|
2115
|
+
email: identityState.email
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
function resolveParticipantSelfServiceEligibility(input) {
|
|
2120
|
+
if (input.identityState.kind === "booker_supplied") {
|
|
2121
|
+
return { kind: "blocked_staff_managed_only" };
|
|
2122
|
+
}
|
|
2123
|
+
const email = input.identityState.email;
|
|
2124
|
+
if (email === null || input.participantId === null) {
|
|
2125
|
+
return { kind: "blocked_missing_email" };
|
|
2126
|
+
}
|
|
2127
|
+
return {
|
|
2128
|
+
kind: "eligible",
|
|
2129
|
+
participantId: input.participantId,
|
|
2130
|
+
email
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
function projectAppointmentParticipantSlot(input) {
|
|
2134
|
+
return buildSlot({
|
|
2135
|
+
slotKey: unsafeAsParticipantSlotKey(`appointment:${input.appointmentId}`),
|
|
2136
|
+
contact: input.customer,
|
|
2137
|
+
participation: {
|
|
2138
|
+
kind: "appointment",
|
|
2139
|
+
appointmentId: input.appointmentId
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
function projectCourseEnrollmentParticipantSlot(input) {
|
|
2144
|
+
return buildSlot({
|
|
2145
|
+
slotKey: unsafeAsParticipantSlotKey(`course_enrollment:${input.enrollmentId}`),
|
|
2146
|
+
contact: input.customer,
|
|
2147
|
+
participation: {
|
|
2148
|
+
kind: "course_enrollment",
|
|
2149
|
+
enrollmentId: input.enrollmentId
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
function projectSingleSubjectParticipantRoster(input) {
|
|
2154
|
+
const slot = projectSingleSubjectParticipantSlot(input);
|
|
2155
|
+
return {
|
|
2156
|
+
slots: [slot],
|
|
2157
|
+
issues: reconcileParticipantRoster({
|
|
2158
|
+
expectedCount: unsafeAsParticipantCount(1),
|
|
2159
|
+
slots: [slot]
|
|
2160
|
+
})
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
function buildSingleSubjectParticipantPersistenceIntent(input) {
|
|
2164
|
+
const slot = projectSingleSubjectParticipantSlot(input);
|
|
2165
|
+
const base = {
|
|
2166
|
+
customerId: input.customer.customerId,
|
|
2167
|
+
displayName: slot.displayName,
|
|
2168
|
+
phone: slot.phone,
|
|
2169
|
+
participation: slot.participation
|
|
2170
|
+
};
|
|
2171
|
+
if (slot.email === null) {
|
|
2172
|
+
return {
|
|
2173
|
+
kind: "provisional_single_subject_participant",
|
|
2174
|
+
...base
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
return {
|
|
2178
|
+
kind: "participant_subject",
|
|
2179
|
+
...base,
|
|
2180
|
+
email: slot.email
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
function projectSingleSubjectParticipantSlot(input) {
|
|
2184
|
+
switch (input.target.kind) {
|
|
2185
|
+
case "appointment": return projectAppointmentParticipantSlot({
|
|
2186
|
+
appointmentId: input.target.appointmentId,
|
|
2187
|
+
customer: input.customer
|
|
2188
|
+
});
|
|
2189
|
+
case "course_enrollment": return projectCourseEnrollmentParticipantSlot({
|
|
2190
|
+
enrollmentId: input.target.enrollmentId,
|
|
2191
|
+
customer: input.customer
|
|
2192
|
+
});
|
|
2193
|
+
default: return assertNever$1(input.target);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
function projectCourseSessionAttendanceParticipantSlot(input) {
|
|
2197
|
+
return {
|
|
2198
|
+
slotKey: unsafeAsParticipantSlotKey(`course_session_attendance:${input.attendeeId}`),
|
|
2199
|
+
participation: {
|
|
2200
|
+
kind: "course_session_attendance",
|
|
2201
|
+
attendeeId: input.attendeeId,
|
|
2202
|
+
enrollmentId: input.enrollmentId
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
function projectEventRegistrationParticipantRoster(input) {
|
|
2207
|
+
const bookerSlot = buildSlot({
|
|
2208
|
+
slotKey: unsafeAsParticipantSlotKey(`event_registration_booker:${input.attendeeId}`),
|
|
2209
|
+
contact: input.booker,
|
|
2210
|
+
participation: {
|
|
2211
|
+
kind: "event_registration_booker",
|
|
2212
|
+
attendeeId: input.attendeeId
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
const guestSlots = input.guests.map((guest) => buildSlot({
|
|
2216
|
+
slotKey: unsafeAsParticipantSlotKey(`event_guest:${guest.guestId}`),
|
|
2217
|
+
contact: guest,
|
|
2218
|
+
participation: {
|
|
2219
|
+
kind: "event_guest",
|
|
2220
|
+
attendeeId: input.attendeeId,
|
|
2221
|
+
guestId: guest.guestId
|
|
2222
|
+
}
|
|
2223
|
+
}));
|
|
2224
|
+
const slots = [bookerSlot, ...guestSlots];
|
|
2225
|
+
return {
|
|
2226
|
+
slots,
|
|
2227
|
+
issues: reconcileParticipantRoster({
|
|
2228
|
+
expectedCount: input.ticketsCount,
|
|
2229
|
+
slots
|
|
2230
|
+
})
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
function reconcileParticipantRoster(input) {
|
|
2234
|
+
const issues = [];
|
|
2235
|
+
if (input.slots.length < input.expectedCount) {
|
|
2236
|
+
issues.push({
|
|
2237
|
+
kind: "participant_count_below_ticket_count",
|
|
2238
|
+
expectedCount: input.expectedCount,
|
|
2239
|
+
actualCount: input.slots.length
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
if (input.slots.length > input.expectedCount) {
|
|
2243
|
+
issues.push({
|
|
2244
|
+
kind: "participant_count_above_ticket_count",
|
|
2245
|
+
expectedCount: input.expectedCount,
|
|
2246
|
+
actualCount: input.slots.length
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
issues.push(...findDuplicateParticipantEmails(input.slots));
|
|
2250
|
+
return issues;
|
|
2251
|
+
}
|
|
2252
|
+
function planParticipantSubjectResolution(input) {
|
|
2253
|
+
const existingParticipantIdByEmail = new Map(input.existingParticipantsByEmail.map((participant) => [participant.email, participant.participantId]));
|
|
2254
|
+
const duplicateSlotKeysByEmail = new Map(input.projection.issues.filter((issue) => issue.kind === "duplicate_email_in_booking_context").map((issue) => [issue.email, issue.slotKeys]));
|
|
2255
|
+
return input.projection.slots.map((slot) => {
|
|
2256
|
+
if (slot.email === null) {
|
|
2257
|
+
return {
|
|
2258
|
+
kind: "staff_managed_slot",
|
|
2259
|
+
slotKey: slot.slotKey
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
const duplicateSlotKeys = duplicateSlotKeysByEmail.get(slot.email);
|
|
2263
|
+
if (duplicateSlotKeys !== undefined) {
|
|
2264
|
+
return {
|
|
2265
|
+
kind: "blocked_duplicate_participant_in_booking",
|
|
2266
|
+
slotKey: slot.slotKey,
|
|
2267
|
+
email: slot.email,
|
|
2268
|
+
conflictingSlotKeys: duplicateSlotKeys
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
const participantId = existingParticipantIdByEmail.get(slot.email);
|
|
2272
|
+
if (participantId !== undefined) {
|
|
2273
|
+
return {
|
|
2274
|
+
kind: "reuse_participant_by_site_email",
|
|
2275
|
+
slotKey: slot.slotKey,
|
|
2276
|
+
email: slot.email,
|
|
2277
|
+
participantId
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
return {
|
|
2281
|
+
kind: "create_participant",
|
|
2282
|
+
slotKey: slot.slotKey,
|
|
2283
|
+
email: slot.email,
|
|
2284
|
+
displayName: slot.displayName,
|
|
2285
|
+
phone: slot.phone
|
|
2286
|
+
};
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
function buildSlot(input) {
|
|
2290
|
+
const email = normalizeParticipantEmail(input.contact.email);
|
|
2291
|
+
return {
|
|
2292
|
+
slotKey: input.slotKey,
|
|
2293
|
+
displayName: trimmedStringOrNull(input.contact.name),
|
|
2294
|
+
email,
|
|
2295
|
+
phone: trimmedStringOrNull(input.contact.phone),
|
|
2296
|
+
identityState: email === null ? { kind: "booker_supplied" } : {
|
|
2297
|
+
kind: "contactable",
|
|
2298
|
+
email
|
|
2299
|
+
},
|
|
2300
|
+
participation: input.participation
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
function findDuplicateParticipantEmails(slots) {
|
|
2304
|
+
const slotKeysByEmail = new Map();
|
|
2305
|
+
for (const slot of slots) {
|
|
2306
|
+
if (slot.email === null) {
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
const existing = slotKeysByEmail.get(slot.email) ?? [];
|
|
2310
|
+
existing.push(slot.slotKey);
|
|
2311
|
+
slotKeysByEmail.set(slot.email, existing);
|
|
2312
|
+
}
|
|
2313
|
+
return [...slotKeysByEmail.entries()].filter(([, slotKeys]) => slotKeys.length > 1).map(([email, slotKeys]) => ({
|
|
2314
|
+
kind: "duplicate_email_in_booking_context",
|
|
2315
|
+
email,
|
|
2316
|
+
slotKeys
|
|
2317
|
+
}));
|
|
2318
|
+
}
|
|
2319
|
+
function isEmailLike(value) {
|
|
2320
|
+
const atIndex = value.indexOf("@");
|
|
2321
|
+
return atIndex > 0 && atIndex === value.lastIndexOf("@") && atIndex < value.length - 1;
|
|
2322
|
+
}
|
|
2323
|
+
function assertNever$1(value) {
|
|
2324
|
+
throw new Error(`Unexpected participant variant: ${JSON.stringify(value)}`);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
//#endregion
|
|
2328
|
+
//#region ../core/src/participant-identity.ts
|
|
2329
|
+
function planPersonResolution(input) {
|
|
2330
|
+
switch (input.kind) {
|
|
2331
|
+
case "with_email":
|
|
2332
|
+
if (input.existingParticipantIdByEmail !== null) {
|
|
2333
|
+
return {
|
|
2334
|
+
kind: "reuse_existing_person_by_email",
|
|
2335
|
+
participantId: input.existingParticipantIdByEmail,
|
|
2336
|
+
email: input.email
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
return {
|
|
2340
|
+
kind: "create_contactable_person",
|
|
2341
|
+
seed: {
|
|
2342
|
+
displayName: input.displayName,
|
|
2343
|
+
email: input.email,
|
|
2344
|
+
phone: input.phone
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
case "without_email": return {
|
|
2348
|
+
kind: "create_provisional_person",
|
|
2349
|
+
seed: {
|
|
2350
|
+
displayName: input.displayName,
|
|
2351
|
+
phone: input.phone
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
default: return assertNever(input);
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
function planCustomerPersonResolution(input) {
|
|
2358
|
+
const resolution = planPersonResolution(toCustomerPersonResolutionInput(input));
|
|
2359
|
+
switch (resolution.kind) {
|
|
2360
|
+
case "reuse_existing_person_by_email": return {
|
|
2361
|
+
kind: "link_existing_person_by_email",
|
|
2362
|
+
customerId: input.customerId,
|
|
2363
|
+
participantId: resolution.participantId,
|
|
2364
|
+
email: resolution.email
|
|
2365
|
+
};
|
|
2366
|
+
case "create_contactable_person": return {
|
|
2367
|
+
kind: "create_contactable_person_for_customer",
|
|
2368
|
+
customerId: input.customerId,
|
|
2369
|
+
seed: resolution.seed
|
|
2370
|
+
};
|
|
2371
|
+
case "create_provisional_person": return {
|
|
2372
|
+
kind: "create_provisional_person_for_customer",
|
|
2373
|
+
customerId: input.customerId,
|
|
2374
|
+
seed: resolution.seed
|
|
2375
|
+
};
|
|
2376
|
+
default: return assertNever(resolution);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
function toCustomerPersonResolutionInput(input) {
|
|
2380
|
+
switch (input.kind) {
|
|
2381
|
+
case "with_email": return {
|
|
2382
|
+
kind: "with_email",
|
|
2383
|
+
displayName: input.displayName,
|
|
2384
|
+
email: input.email,
|
|
2385
|
+
phone: input.phone,
|
|
2386
|
+
existingParticipantIdByEmail: input.existingParticipantIdByEmail
|
|
2387
|
+
};
|
|
2388
|
+
case "without_email": return {
|
|
2389
|
+
kind: "without_email",
|
|
2390
|
+
displayName: input.displayName,
|
|
2391
|
+
phone: input.phone
|
|
2392
|
+
};
|
|
2393
|
+
default: return assertNever(input);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
function planParticipationPersonResolution(input) {
|
|
2397
|
+
switch (input.resolution.kind) {
|
|
2398
|
+
case "blocked_duplicate_participant_in_booking": return {
|
|
2399
|
+
kind: "blocked_duplicate_participant_in_booking",
|
|
2400
|
+
slotKey: input.resolution.slotKey,
|
|
2401
|
+
email: input.resolution.email,
|
|
2402
|
+
conflictingSlotKeys: input.resolution.conflictingSlotKeys
|
|
2403
|
+
};
|
|
2404
|
+
case "create_participant":
|
|
2405
|
+
case "reuse_participant_by_site_email":
|
|
2406
|
+
case "staff_managed_slot": return applyParticipationResolutionPlan({
|
|
2407
|
+
slotKey: input.resolution.slotKey,
|
|
2408
|
+
participation: input.slot.participation,
|
|
2409
|
+
resolution: planPersonResolution(toSlotPersonResolutionInput({
|
|
2410
|
+
slot: input.slot,
|
|
2411
|
+
resolution: input.resolution
|
|
2412
|
+
}))
|
|
2413
|
+
});
|
|
2414
|
+
default: return assertNever(input.resolution);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
function toSlotPersonResolutionInput(input) {
|
|
2418
|
+
switch (input.resolution.kind) {
|
|
2419
|
+
case "create_participant": return {
|
|
2420
|
+
kind: "with_email",
|
|
2421
|
+
displayName: input.resolution.displayName,
|
|
2422
|
+
email: input.resolution.email,
|
|
2423
|
+
phone: input.resolution.phone,
|
|
2424
|
+
existingParticipantIdByEmail: null
|
|
2425
|
+
};
|
|
2426
|
+
case "reuse_participant_by_site_email": return {
|
|
2427
|
+
kind: "with_email",
|
|
2428
|
+
displayName: input.slot.displayName,
|
|
2429
|
+
email: input.resolution.email,
|
|
2430
|
+
phone: input.slot.phone,
|
|
2431
|
+
existingParticipantIdByEmail: input.resolution.participantId
|
|
2432
|
+
};
|
|
2433
|
+
case "staff_managed_slot": return {
|
|
2434
|
+
kind: "without_email",
|
|
2435
|
+
displayName: input.slot.displayName,
|
|
2436
|
+
phone: input.slot.phone
|
|
2437
|
+
};
|
|
2438
|
+
default: return assertNever(input.resolution);
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
function applyParticipationResolutionPlan(input) {
|
|
2442
|
+
switch (input.resolution.kind) {
|
|
2443
|
+
case "reuse_existing_person_by_email": return {
|
|
2444
|
+
kind: "reuse_existing_person_for_slot",
|
|
2445
|
+
slotKey: input.slotKey,
|
|
2446
|
+
participantId: input.resolution.participantId,
|
|
2447
|
+
participation: input.participation
|
|
2448
|
+
};
|
|
2449
|
+
case "create_contactable_person": return {
|
|
2450
|
+
kind: "create_contactable_person_for_slot",
|
|
2451
|
+
slotKey: input.slotKey,
|
|
2452
|
+
seed: input.resolution.seed,
|
|
2453
|
+
participation: input.participation
|
|
2454
|
+
};
|
|
2455
|
+
case "create_provisional_person": return {
|
|
2456
|
+
kind: "create_provisional_person_for_slot",
|
|
2457
|
+
slotKey: input.slotKey,
|
|
2458
|
+
seed: input.resolution.seed,
|
|
2459
|
+
participation: input.participation
|
|
2460
|
+
};
|
|
2461
|
+
default: return assertNever(input.resolution);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
function planSingleSubjectParticipantMaterialization(input) {
|
|
2465
|
+
const resolution = planPersonResolution(toSingleSubjectPersonResolutionInput(input));
|
|
2466
|
+
switch (resolution.kind) {
|
|
2467
|
+
case "reuse_existing_person_by_email": return {
|
|
2468
|
+
kind: "reuse_existing_person_and_participation",
|
|
2469
|
+
customerId: input.customerId,
|
|
2470
|
+
participantId: resolution.participantId,
|
|
2471
|
+
participation: input.participation
|
|
2472
|
+
};
|
|
2473
|
+
case "create_contactable_person": return {
|
|
2474
|
+
kind: "create_contactable_person_and_participation",
|
|
2475
|
+
customerId: input.customerId,
|
|
2476
|
+
seed: resolution.seed,
|
|
2477
|
+
participation: input.participation
|
|
2478
|
+
};
|
|
2479
|
+
case "create_provisional_person": return {
|
|
2480
|
+
kind: "create_provisional_person_and_participation",
|
|
2481
|
+
customerId: input.customerId,
|
|
2482
|
+
seed: resolution.seed,
|
|
2483
|
+
participation: input.participation
|
|
2484
|
+
};
|
|
2485
|
+
default: return assertNever(resolution);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
function toSingleSubjectPersonResolutionInput(input) {
|
|
2489
|
+
switch (input.kind) {
|
|
2490
|
+
case "participant_subject": return {
|
|
2491
|
+
kind: "with_email",
|
|
2492
|
+
displayName: input.displayName,
|
|
2493
|
+
email: input.email,
|
|
2494
|
+
phone: input.phone,
|
|
2495
|
+
existingParticipantIdByEmail: input.existingParticipantIdByEmail
|
|
2496
|
+
};
|
|
2497
|
+
case "provisional_single_subject_participant": return {
|
|
2498
|
+
kind: "without_email",
|
|
2499
|
+
displayName: input.displayName,
|
|
2500
|
+
phone: input.phone
|
|
2501
|
+
};
|
|
2502
|
+
default: return assertNever(input);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
function planCleanupReadiness(evidence) {
|
|
2506
|
+
const blockers = collectCleanupReadinessBlockers(evidence);
|
|
2507
|
+
if (blockers.length === 0) {
|
|
2508
|
+
return {
|
|
2509
|
+
kind: "ready",
|
|
2510
|
+
evidence,
|
|
2511
|
+
blockers: []
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
return {
|
|
2515
|
+
kind: "blocked",
|
|
2516
|
+
evidence,
|
|
2517
|
+
blockers
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
function collectCleanupReadinessBlockers(evidence) {
|
|
2521
|
+
const blockers = [];
|
|
2522
|
+
if (evidence.legacyCustomerEmailCallSiteCount > 0) {
|
|
2523
|
+
blockers.push("legacy_customer_email_call_sites_remaining");
|
|
2524
|
+
}
|
|
2525
|
+
if (evidence.parityFailureSurfaces.length > 0) {
|
|
2526
|
+
blockers.push("parity_failures_remaining");
|
|
2527
|
+
}
|
|
2528
|
+
if (evidence.unresolvedHealthRowCount > 0) {
|
|
2529
|
+
blockers.push("unresolved_health_rows_remaining");
|
|
2530
|
+
}
|
|
2531
|
+
return blockers;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
//#endregion
|
|
2535
|
+
//#region ../core/src/public-url-validation.ts
|
|
2536
|
+
function buildPublicUrlValidationError(message, errorFactory) {
|
|
2537
|
+
return errorFactory !== undefined ? errorFactory(message) : new Error(message);
|
|
2538
|
+
}
|
|
2539
|
+
function parseIpv4(ip) {
|
|
2540
|
+
const parts = ip.split(".");
|
|
2541
|
+
if (parts.length !== 4) return null;
|
|
2542
|
+
const bytes = parts.map((p) => {
|
|
2543
|
+
if (!/^\d+$/.test(p)) return Number.NaN;
|
|
2544
|
+
const n = Number.parseInt(p, 10);
|
|
2545
|
+
return n >= 0 && n <= 255 ? n : Number.NaN;
|
|
2546
|
+
});
|
|
2547
|
+
if (bytes.some((b) => !Number.isFinite(b))) return null;
|
|
2548
|
+
return bytes;
|
|
2549
|
+
}
|
|
2550
|
+
function parseIpv6(ip) {
|
|
2551
|
+
const normalized = ip.toLowerCase();
|
|
2552
|
+
const zoneIndex = normalized.indexOf("%");
|
|
2553
|
+
const withoutZone = zoneIndex >= 0 ? normalized.slice(0, zoneIndex) : normalized;
|
|
2554
|
+
const expandSection = (section) => {
|
|
2555
|
+
if (!section) return [];
|
|
2556
|
+
const parts = section.split(":");
|
|
2557
|
+
const expanded$1 = [];
|
|
2558
|
+
for (const part of parts) {
|
|
2559
|
+
if (!part) return null;
|
|
2560
|
+
if (part.includes(".")) {
|
|
2561
|
+
const ipv4 = parseIpv4(part);
|
|
2562
|
+
if (ipv4 === null) return null;
|
|
2563
|
+
const [a, b, c, d] = ipv4;
|
|
2564
|
+
if (a === undefined || b === undefined || c === undefined || d === undefined) {
|
|
2565
|
+
return null;
|
|
2566
|
+
}
|
|
2567
|
+
expanded$1.push(a << 8 | b, c << 8 | d);
|
|
2568
|
+
continue;
|
|
2569
|
+
}
|
|
2570
|
+
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
|
|
2571
|
+
expanded$1.push(Number.parseInt(part, 16));
|
|
2572
|
+
}
|
|
2573
|
+
return expanded$1;
|
|
2574
|
+
};
|
|
2575
|
+
const doubleColonIndex = withoutZone.indexOf("::");
|
|
2576
|
+
if (doubleColonIndex !== withoutZone.lastIndexOf("::")) return null;
|
|
2577
|
+
if (doubleColonIndex >= 0) {
|
|
2578
|
+
const head = withoutZone.slice(0, doubleColonIndex);
|
|
2579
|
+
const tail = withoutZone.slice(doubleColonIndex + 2);
|
|
2580
|
+
const headParts = expandSection(head);
|
|
2581
|
+
const tailParts = expandSection(tail);
|
|
2582
|
+
if (headParts === null || tailParts === null) return null;
|
|
2583
|
+
const zerosToInsert = 8 - (headParts.length + tailParts.length);
|
|
2584
|
+
if (zerosToInsert < 1) return null;
|
|
2585
|
+
return [
|
|
2586
|
+
...headParts,
|
|
2587
|
+
...Array.from({ length: zerosToInsert }, () => 0),
|
|
2588
|
+
...tailParts
|
|
2589
|
+
];
|
|
2590
|
+
}
|
|
2591
|
+
const expanded = expandSection(withoutZone);
|
|
2592
|
+
if (expanded === null || expanded.length !== 8) return null;
|
|
2593
|
+
return expanded;
|
|
2594
|
+
}
|
|
2595
|
+
function isPrivateIpv4(ip) {
|
|
2596
|
+
const bytes = parseIpv4(ip);
|
|
2597
|
+
if (bytes === null) return true;
|
|
2598
|
+
const a = bytes[0];
|
|
2599
|
+
const b = bytes[1];
|
|
2600
|
+
if (a === undefined || b === undefined) return true;
|
|
2601
|
+
if (a === 0) return true;
|
|
2602
|
+
if (a === 10) return true;
|
|
2603
|
+
if (a === 127) return true;
|
|
2604
|
+
if (a === 169 && b === 254) return true;
|
|
2605
|
+
if (a === 172) {
|
|
2606
|
+
if (b >= 16 && b <= 31) return true;
|
|
2607
|
+
}
|
|
2608
|
+
if (a === 192 && b === 0 && bytes[2] === 0) return true;
|
|
2609
|
+
if (a === 192 && b === 0 && bytes[2] === 2) return true;
|
|
2610
|
+
if (a === 192 && b === 168) return true;
|
|
2611
|
+
if (a === 100) {
|
|
2612
|
+
if (b >= 64 && b <= 127) return true;
|
|
2613
|
+
}
|
|
2614
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
2615
|
+
if (a === 198 && b === 51 && bytes[2] === 100) return true;
|
|
2616
|
+
if (a === 203 && b === 0 && bytes[2] === 113) return true;
|
|
2617
|
+
if (a >= 224) return true;
|
|
2618
|
+
return false;
|
|
2619
|
+
}
|
|
2620
|
+
function isPrivateIpv6(ip) {
|
|
2621
|
+
const normalized = ip.toLowerCase();
|
|
2622
|
+
if (normalized === "::" || normalized === "::1") return true;
|
|
2623
|
+
if (normalized.startsWith("::ffff:")) {
|
|
2624
|
+
const maybeV4 = normalized.slice("::ffff:".length);
|
|
2625
|
+
if (parseIpv4(maybeV4) !== null) return isPrivateIpv4(maybeV4);
|
|
2626
|
+
return true;
|
|
2627
|
+
}
|
|
2628
|
+
const hextets = parseIpv6(normalized);
|
|
2629
|
+
if (hextets === null) return true;
|
|
2630
|
+
const first = hextets[0] ?? 0;
|
|
2631
|
+
const second = hextets[1] ?? 0;
|
|
2632
|
+
const third = hextets[2] ?? 0;
|
|
2633
|
+
const fourth = hextets[3] ?? 0;
|
|
2634
|
+
if ((first & 65024) === 64512) return true;
|
|
2635
|
+
if ((first & 65472) === 65152) return true;
|
|
2636
|
+
if (first === 256 && second === 0 && third === 0 && fourth === 0) return true;
|
|
2637
|
+
if (first === 8193 && second === 3512) return true;
|
|
2638
|
+
if (first === 8193 && (second & 65520) === 16) return true;
|
|
2639
|
+
if (first === 8194) return true;
|
|
2640
|
+
return false;
|
|
2641
|
+
}
|
|
2642
|
+
function isIpAddress(value) {
|
|
2643
|
+
return parseIpv4(value) !== null || parseIpv6(value) !== null;
|
|
2644
|
+
}
|
|
2645
|
+
function isPrivateIp(ip) {
|
|
2646
|
+
if (parseIpv4(ip) !== null) return isPrivateIpv4(ip);
|
|
2647
|
+
if (parseIpv6(ip) !== null) return isPrivateIpv6(ip);
|
|
2648
|
+
return true;
|
|
2649
|
+
}
|
|
2650
|
+
function assertAllowedPublicHostname(hostname, errorFactory) {
|
|
2651
|
+
const host = hostname.trim().toLowerCase().replace(/[。\uFF0E\uFF61]/gu, ".").replace(/\.+$/u, "");
|
|
2652
|
+
if (host.length === 0) throw buildPublicUrlValidationError("URL host is missing", errorFactory);
|
|
2653
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
2654
|
+
throw buildPublicUrlValidationError("Localhost hosts are not allowed", errorFactory);
|
|
2655
|
+
}
|
|
2656
|
+
if (/^[0-9.]+$/.test(host) && !isIpAddress(host)) {
|
|
2657
|
+
throw buildPublicUrlValidationError("Numeric hosts are not allowed", errorFactory);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
function assertAllowedPort(url, options) {
|
|
2661
|
+
const { portPolicy, errorFactory } = options;
|
|
2662
|
+
const port = url.port.length > 0 ? Number.parseInt(url.port, 10) : null;
|
|
2663
|
+
if (port !== null && (!Number.isFinite(port) || port <= 0 || port > 65535)) {
|
|
2664
|
+
throw buildPublicUrlValidationError("Invalid port", errorFactory);
|
|
2665
|
+
}
|
|
2666
|
+
if (portPolicy === "standard-only") {
|
|
2667
|
+
const allowedPort = url.protocol === "http:" ? 80 : 443;
|
|
2668
|
+
if (port !== null && port !== allowedPort) {
|
|
2669
|
+
throw buildPublicUrlValidationError("Non-standard ports are not allowed", errorFactory);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
function assertAllowedPublicUrl(raw, options) {
|
|
2674
|
+
let url;
|
|
2675
|
+
try {
|
|
2676
|
+
url = new URL(raw);
|
|
2677
|
+
} catch {
|
|
2678
|
+
throw buildPublicUrlValidationError("Invalid URL", options.errorFactory);
|
|
2679
|
+
}
|
|
2680
|
+
if (!options.allowedProtocols.includes(url.protocol)) {
|
|
2681
|
+
throw buildPublicUrlValidationError(`Only ${options.allowedProtocols.join("/")} URLs are allowed`, options.errorFactory);
|
|
2682
|
+
}
|
|
2683
|
+
if (url.username || url.password) {
|
|
2684
|
+
throw buildPublicUrlValidationError("Credentialed URLs are not allowed", options.errorFactory);
|
|
2685
|
+
}
|
|
2686
|
+
const normalizedHostname = url.hostname.trim().toLowerCase().replace(/[。\uFF0E\uFF61]/gu, ".").replace(/\.+$/u, "");
|
|
2687
|
+
if (normalizedHostname.length === 0) {
|
|
2688
|
+
throw buildPublicUrlValidationError("URL host is missing", options.errorFactory);
|
|
2689
|
+
}
|
|
2690
|
+
url.hostname = normalizedHostname;
|
|
2691
|
+
assertAllowedPublicHostname(url.hostname, options.errorFactory);
|
|
2692
|
+
assertAllowedPort(url, options);
|
|
2693
|
+
const hostnameForIpCheck = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
|
|
2694
|
+
if (isIpAddress(hostnameForIpCheck) && isPrivateIp(hostnameForIpCheck)) {
|
|
2695
|
+
throw buildPublicUrlValidationError("Private IPs are not allowed", options.errorFactory);
|
|
2696
|
+
}
|
|
2697
|
+
url.hash = "";
|
|
2698
|
+
return url;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
//#endregion
|
|
2702
|
+
//#region ../core/src/public-profile.ts
|
|
2703
|
+
const publicProfileWeekdays = [
|
|
2704
|
+
"monday",
|
|
2705
|
+
"tuesday",
|
|
2706
|
+
"wednesday",
|
|
2707
|
+
"thursday",
|
|
2708
|
+
"friday",
|
|
2709
|
+
"saturday",
|
|
2710
|
+
"sunday"
|
|
2711
|
+
];
|
|
2712
|
+
const knownSocialPlatforms = [
|
|
2713
|
+
"facebook",
|
|
2714
|
+
"instagram",
|
|
2715
|
+
"linkedin",
|
|
2716
|
+
"tiktok",
|
|
2717
|
+
"x",
|
|
2718
|
+
"youtube"
|
|
2719
|
+
];
|
|
2720
|
+
function isRecord$5(value) {
|
|
2721
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2722
|
+
}
|
|
2723
|
+
function trimmedOptionalString(value) {
|
|
2724
|
+
if (value === null || value === undefined) return null;
|
|
2725
|
+
if (typeof value !== "string") return undefined;
|
|
2726
|
+
const trimmed = value.trim();
|
|
2727
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
2728
|
+
}
|
|
2729
|
+
function parsePublicContactDetails(value) {
|
|
2730
|
+
if (!isRecord$5(value)) {
|
|
2731
|
+
return err({
|
|
2732
|
+
code: "invalid_shape",
|
|
2733
|
+
path: "contact",
|
|
2734
|
+
message: "Contact details must be an object."
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
const phone = trimmedOptionalString(value.phone);
|
|
2738
|
+
const email = trimmedOptionalString(value.email);
|
|
2739
|
+
if (phone === undefined || email === undefined) {
|
|
2740
|
+
return err({
|
|
2741
|
+
code: "invalid_shape",
|
|
2742
|
+
path: "contact",
|
|
2743
|
+
message: "Phone and email must be text values."
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
if (phone === null && email === null) {
|
|
2747
|
+
return err({
|
|
2748
|
+
code: "contact_method_required",
|
|
2749
|
+
path: "contact",
|
|
2750
|
+
message: "Add a phone number or email address."
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
if (email !== null && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)) {
|
|
2754
|
+
return err({
|
|
2755
|
+
code: "invalid_email",
|
|
2756
|
+
path: "contact.email",
|
|
2757
|
+
message: "Enter a valid email address."
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
if (phone !== null && !/^\+?[0-9][0-9 ().-]{5,30}$/u.test(phone)) {
|
|
2761
|
+
return err({
|
|
2762
|
+
code: "invalid_phone",
|
|
2763
|
+
path: "contact.phone",
|
|
2764
|
+
message: "Enter a valid phone number."
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
if (phone !== null && email !== null) {
|
|
2768
|
+
return ok({
|
|
2769
|
+
kind: "phone-and-email",
|
|
2770
|
+
phone,
|
|
2771
|
+
email
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
if (phone !== null) return ok({
|
|
2775
|
+
kind: "phone",
|
|
2776
|
+
phone,
|
|
2777
|
+
email: null
|
|
2778
|
+
});
|
|
2779
|
+
if (email !== null) return ok({
|
|
2780
|
+
kind: "email",
|
|
2781
|
+
phone: null,
|
|
2782
|
+
email
|
|
2783
|
+
});
|
|
2784
|
+
return err({
|
|
2785
|
+
code: "contact_method_required",
|
|
2786
|
+
path: "contact",
|
|
2787
|
+
message: "Add a phone number or email address."
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
function parsePublicProfileTimeZone(value) {
|
|
2791
|
+
const timeZone = trimmedOptionalString(value);
|
|
2792
|
+
if (timeZone === null || timeZone === undefined) {
|
|
2793
|
+
return err({
|
|
2794
|
+
code: "invalid_timezone",
|
|
2795
|
+
path: "openingHours.timeZone",
|
|
2796
|
+
message: "Choose a timezone."
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
try {
|
|
2800
|
+
new Intl.DateTimeFormat("en-GB", { timeZone }).format(new Date(0));
|
|
2801
|
+
return ok(timeZone);
|
|
2802
|
+
} catch {
|
|
2803
|
+
return err({
|
|
2804
|
+
code: "invalid_timezone",
|
|
2805
|
+
path: "openingHours.timeZone",
|
|
2806
|
+
message: "Choose a valid IANA timezone."
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
function parseOpeningHoursTime(value, path) {
|
|
2811
|
+
if (typeof value !== "string" || !/^(?:[01]\d|2[0-3]):[0-5]\d$/u.test(value)) {
|
|
2812
|
+
return err({
|
|
2813
|
+
code: "invalid_time",
|
|
2814
|
+
path,
|
|
2815
|
+
message: "Use a 24-hour time such as 09:00."
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
return ok(value);
|
|
2819
|
+
}
|
|
2820
|
+
function minutesSinceMidnight(value) {
|
|
2821
|
+
const [hours = "0", minutes = "0"] = value.split(":");
|
|
2822
|
+
return Number(hours) * 60 + Number(minutes);
|
|
2823
|
+
}
|
|
2824
|
+
function parseOpeningHoursDay(value, expectedWeekday, index) {
|
|
2825
|
+
const path = `openingHours.days.${index}`;
|
|
2826
|
+
if (!isRecord$5(value) || value.weekday !== expectedWeekday) {
|
|
2827
|
+
return err({
|
|
2828
|
+
code: "invalid_weekdays",
|
|
2829
|
+
path,
|
|
2830
|
+
message: `Expected ${expectedWeekday} in calendar order.`
|
|
2831
|
+
});
|
|
2832
|
+
}
|
|
2833
|
+
if (value.kind === "closed") return ok({
|
|
2834
|
+
kind: "closed",
|
|
2835
|
+
weekday: expectedWeekday
|
|
2836
|
+
});
|
|
2837
|
+
if (value.kind !== "open" || !Array.isArray(value.intervals) || value.intervals.length === 0) {
|
|
2838
|
+
return err({
|
|
2839
|
+
code: "invalid_interval",
|
|
2840
|
+
path: `${path}.intervals`,
|
|
2841
|
+
message: "Open days need at least one opening interval."
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
const intervals = [];
|
|
2845
|
+
for (const [intervalIndex, rawInterval] of value.intervals.entries()) {
|
|
2846
|
+
const intervalPath = `${path}.intervals.${intervalIndex}`;
|
|
2847
|
+
if (!isRecord$5(rawInterval)) {
|
|
2848
|
+
return err({
|
|
2849
|
+
code: "invalid_interval",
|
|
2850
|
+
path: intervalPath,
|
|
2851
|
+
message: "Opening interval is invalid."
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
const opensAt = parseOpeningHoursTime(rawInterval.opensAt, `${intervalPath}.opensAt`);
|
|
2855
|
+
if (!opensAt.ok) return opensAt;
|
|
2856
|
+
const closesAt = parseOpeningHoursTime(rawInterval.closesAt, `${intervalPath}.closesAt`);
|
|
2857
|
+
if (!closesAt.ok) return closesAt;
|
|
2858
|
+
if (minutesSinceMidnight(opensAt.value) >= minutesSinceMidnight(closesAt.value)) {
|
|
2859
|
+
return err({
|
|
2860
|
+
code: "invalid_interval",
|
|
2861
|
+
path: intervalPath,
|
|
2862
|
+
message: "Closing time must be later than opening time on the same day."
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
intervals.push({
|
|
2866
|
+
opensAt: opensAt.value,
|
|
2867
|
+
closesAt: closesAt.value
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
intervals.sort((left, right) => minutesSinceMidnight(left.opensAt) - minutesSinceMidnight(right.opensAt));
|
|
2871
|
+
for (let intervalIndex = 1; intervalIndex < intervals.length; intervalIndex += 1) {
|
|
2872
|
+
const previous = intervals[intervalIndex - 1];
|
|
2873
|
+
const current = intervals[intervalIndex];
|
|
2874
|
+
if (previous !== undefined && current !== undefined && minutesSinceMidnight(current.opensAt) < minutesSinceMidnight(previous.closesAt)) {
|
|
2875
|
+
return err({
|
|
2876
|
+
code: "overlapping_intervals",
|
|
2877
|
+
path: `${path}.intervals.${intervalIndex}`,
|
|
2878
|
+
message: "Opening intervals cannot overlap."
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
const [first, ...rest] = intervals;
|
|
2883
|
+
if (first === undefined) {
|
|
2884
|
+
return err({
|
|
2885
|
+
code: "invalid_interval",
|
|
2886
|
+
path,
|
|
2887
|
+
message: "Open days need an opening interval."
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
return ok({
|
|
2891
|
+
kind: "open",
|
|
2892
|
+
weekday: expectedWeekday,
|
|
2893
|
+
intervals: [first, ...rest]
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
function parseWeeklyOpeningHours(value) {
|
|
2897
|
+
if (!isRecord$5(value) || !Array.isArray(value.days) || value.days.length !== publicProfileWeekdays.length) {
|
|
2898
|
+
return err({
|
|
2899
|
+
code: "invalid_weekdays",
|
|
2900
|
+
path: "openingHours.days",
|
|
2901
|
+
message: "Opening hours must include all seven weekdays."
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
const timeZone = parsePublicProfileTimeZone(value.timeZone);
|
|
2905
|
+
if (!timeZone.ok) return timeZone;
|
|
2906
|
+
const days = [];
|
|
2907
|
+
for (const [index, weekday] of publicProfileWeekdays.entries()) {
|
|
2908
|
+
const day = parseOpeningHoursDay(value.days[index], weekday, index);
|
|
2909
|
+
if (!day.ok) return day;
|
|
2910
|
+
days.push(day.value);
|
|
2911
|
+
}
|
|
2912
|
+
const [monday, tuesday, wednesday, thursday, friday, saturday, sunday] = days;
|
|
2913
|
+
if (monday === undefined || tuesday === undefined || wednesday === undefined || thursday === undefined || friday === undefined || saturday === undefined || sunday === undefined) {
|
|
2914
|
+
return err({
|
|
2915
|
+
code: "invalid_weekdays",
|
|
2916
|
+
path: "openingHours.days",
|
|
2917
|
+
message: "Opening hours are incomplete."
|
|
2918
|
+
});
|
|
2919
|
+
}
|
|
2920
|
+
return ok({
|
|
2921
|
+
timeZone: timeZone.value,
|
|
2922
|
+
days: [
|
|
2923
|
+
monday,
|
|
2924
|
+
tuesday,
|
|
2925
|
+
wednesday,
|
|
2926
|
+
thursday,
|
|
2927
|
+
friday,
|
|
2928
|
+
saturday,
|
|
2929
|
+
sunday
|
|
2930
|
+
]
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
function parseSocialLinkId(value, path) {
|
|
2934
|
+
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9_-]{0,63}$/u.test(value)) {
|
|
2935
|
+
return err({
|
|
2936
|
+
code: "invalid_social_link",
|
|
2937
|
+
path,
|
|
2938
|
+
message: "Social link ID is invalid."
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
return ok(value);
|
|
2942
|
+
}
|
|
2943
|
+
function parseSocialUrl(value, path) {
|
|
2944
|
+
if (typeof value !== "string") {
|
|
2945
|
+
return err({
|
|
2946
|
+
code: "invalid_social_link",
|
|
2947
|
+
path,
|
|
2948
|
+
message: "Enter a valid public web address."
|
|
2949
|
+
});
|
|
2950
|
+
}
|
|
2951
|
+
try {
|
|
2952
|
+
const url = assertAllowedPublicUrl(value.trim(), {
|
|
2953
|
+
allowedProtocols: ["https:", "http:"],
|
|
2954
|
+
portPolicy: "standard-only"
|
|
2955
|
+
});
|
|
2956
|
+
return ok(url.toString());
|
|
2957
|
+
} catch {
|
|
2958
|
+
return err({
|
|
2959
|
+
code: "invalid_social_link",
|
|
2960
|
+
path,
|
|
2961
|
+
message: "Enter a valid public web address."
|
|
2962
|
+
});
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
function parsePublicSocialLinks(value) {
|
|
2966
|
+
if (!Array.isArray(value)) {
|
|
2967
|
+
return err({
|
|
2968
|
+
code: "invalid_shape",
|
|
2969
|
+
path: "socialLinks",
|
|
2970
|
+
message: "Social links must be a list."
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
const links = [];
|
|
2974
|
+
const ids = new Set();
|
|
2975
|
+
for (const [index, rawLink] of value.entries()) {
|
|
2976
|
+
const path = `socialLinks.${index}`;
|
|
2977
|
+
if (!isRecord$5(rawLink)) {
|
|
2978
|
+
return err({
|
|
2979
|
+
code: "invalid_social_link",
|
|
2980
|
+
path,
|
|
2981
|
+
message: "Social link is invalid."
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
const id = parseSocialLinkId(rawLink.id, `${path}.id`);
|
|
2985
|
+
if (!id.ok) return id;
|
|
2986
|
+
if (ids.has(id.value)) {
|
|
2987
|
+
return err({
|
|
2988
|
+
code: "duplicate_social_link_id",
|
|
2989
|
+
path: `${path}.id`,
|
|
2990
|
+
message: "Social link IDs must be unique."
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
ids.add(id.value);
|
|
2994
|
+
const url = parseSocialUrl(rawLink.url, `${path}.url`);
|
|
2995
|
+
if (!url.ok) return url;
|
|
2996
|
+
if (rawLink.kind === "known" && knownSocialPlatforms.includes(rawLink.platform)) {
|
|
2997
|
+
links.push({
|
|
2998
|
+
id: id.value,
|
|
2999
|
+
kind: "known",
|
|
3000
|
+
platform: rawLink.platform,
|
|
3001
|
+
url: url.value
|
|
3002
|
+
});
|
|
3003
|
+
continue;
|
|
3004
|
+
}
|
|
3005
|
+
const label = trimmedOptionalString(rawLink.label);
|
|
3006
|
+
if (rawLink.kind === "custom" && label !== null && label !== undefined) {
|
|
3007
|
+
links.push({
|
|
3008
|
+
id: id.value,
|
|
3009
|
+
kind: "custom",
|
|
3010
|
+
label,
|
|
3011
|
+
url: url.value
|
|
3012
|
+
});
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
return err({
|
|
3016
|
+
code: "invalid_social_link",
|
|
3017
|
+
path,
|
|
3018
|
+
message: "Choose a known platform or add a custom label."
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
return ok(links);
|
|
3022
|
+
}
|
|
3023
|
+
function parsePublicProfile(value) {
|
|
3024
|
+
if (!isRecord$5(value)) {
|
|
3025
|
+
return err({
|
|
3026
|
+
code: "invalid_shape",
|
|
3027
|
+
path: "",
|
|
3028
|
+
message: "Public profile must be an object."
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
const contact = value.contact === null ? ok(null) : parsePublicContactDetails(value.contact);
|
|
3032
|
+
if (!contact.ok) return contact;
|
|
3033
|
+
const openingHours = value.openingHours === null ? ok(null) : parseWeeklyOpeningHours(value.openingHours);
|
|
3034
|
+
if (!openingHours.ok) return openingHours;
|
|
3035
|
+
const socialLinks = parsePublicSocialLinks(value.socialLinks);
|
|
3036
|
+
if (!socialLinks.ok) return socialLinks;
|
|
3037
|
+
return ok({
|
|
3038
|
+
contact: contact.value,
|
|
3039
|
+
openingHours: openingHours.value,
|
|
3040
|
+
socialLinks: socialLinks.value
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
function emptyPublicProfile() {
|
|
3044
|
+
return {
|
|
3045
|
+
contact: null,
|
|
3046
|
+
openingHours: null,
|
|
3047
|
+
socialLinks: []
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
//#endregion
|
|
3052
|
+
//#region ../core/src/rich-text.ts
|
|
3053
|
+
function isRecord$4(value) {
|
|
3054
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3055
|
+
}
|
|
3056
|
+
function hasOnlyKeys(value, allowed) {
|
|
3057
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
3058
|
+
}
|
|
3059
|
+
const rootKeys = new Set(["type", "content"]);
|
|
3060
|
+
const nodeKeys = new Set([
|
|
3061
|
+
"type",
|
|
3062
|
+
"content",
|
|
3063
|
+
"text",
|
|
3064
|
+
"attrs",
|
|
3065
|
+
"marks"
|
|
3066
|
+
]);
|
|
3067
|
+
const markKeys = new Set(["type", "attrs"]);
|
|
3068
|
+
function isDenseArrayOf(value, predicate) {
|
|
3069
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
3070
|
+
if (!(index in value) || !predicate(value[index])) return false;
|
|
3071
|
+
}
|
|
3072
|
+
return true;
|
|
3073
|
+
}
|
|
3074
|
+
function isTipTapMark(value) {
|
|
3075
|
+
return isRecord$4(value) && hasOnlyKeys(value, markKeys) && typeof value.type === "string" && value.type.length > 0 && (value.attrs === undefined || isRecord$4(value.attrs));
|
|
3076
|
+
}
|
|
3077
|
+
function isTipTapNode(value) {
|
|
3078
|
+
if (!isRecord$4(value) || !hasOnlyKeys(value, nodeKeys) || typeof value.type !== "string" || value.type.length === 0 || value.type === "doc" || value.attrs !== undefined && !isRecord$4(value.attrs) || value.marks !== undefined && (!Array.isArray(value.marks) || !isDenseArrayOf(value.marks, isTipTapMark)) || value.content !== undefined && (!Array.isArray(value.content) || !isDenseArrayOf(value.content, isTipTapNode))) {
|
|
3079
|
+
return false;
|
|
3080
|
+
}
|
|
3081
|
+
if (value.type === "text") {
|
|
3082
|
+
return typeof value.text === "string" && value.text.length > 0 && value.content === undefined;
|
|
3083
|
+
}
|
|
3084
|
+
return value.text === undefined;
|
|
3085
|
+
}
|
|
3086
|
+
function isCanonicalRichTextDoc(value) {
|
|
3087
|
+
return isRecord$4(value) && hasOnlyKeys(value, rootKeys) && value.type === "doc" && Array.isArray(value.content) && isDenseArrayOf(value.content, isTipTapNode);
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
//#endregion
|
|
3091
|
+
//#region ../core/src/newsletter-signup-destination.ts
|
|
3092
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
3093
|
+
const MAILCHIMP_AUDIENCE_ID_PATTERN = /^[A-Za-z0-9_-]{1,100}$/u;
|
|
3094
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u;
|
|
3095
|
+
function isRecord$3(value) {
|
|
3096
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3097
|
+
}
|
|
3098
|
+
function parseTrimmedString(value) {
|
|
3099
|
+
if (typeof value !== "string") return null;
|
|
3100
|
+
const trimmed = value.trim();
|
|
3101
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
3102
|
+
}
|
|
3103
|
+
function parseNewsletterListId(value) {
|
|
3104
|
+
const trimmed = parseTrimmedString(value);
|
|
3105
|
+
return trimmed !== null && UUID_PATTERN.test(trimmed) ? trimmed : null;
|
|
3106
|
+
}
|
|
3107
|
+
function parseMailchimpConnectionId(value) {
|
|
3108
|
+
const trimmed = parseTrimmedString(value);
|
|
3109
|
+
return trimmed !== null && UUID_PATTERN.test(trimmed) ? trimmed : null;
|
|
3110
|
+
}
|
|
3111
|
+
function parseMailchimpAudienceId(value) {
|
|
3112
|
+
const trimmed = parseTrimmedString(value);
|
|
3113
|
+
return trimmed !== null && MAILCHIMP_AUDIENCE_ID_PATTERN.test(trimmed) ? trimmed : null;
|
|
3114
|
+
}
|
|
3115
|
+
function parseMailchimpTagName(value) {
|
|
3116
|
+
const trimmed = parseTrimmedString(value);
|
|
3117
|
+
return trimmed !== null && trimmed.length <= 100 && !CONTROL_CHARACTER_PATTERN.test(trimmed) ? trimmed : null;
|
|
3118
|
+
}
|
|
3119
|
+
function parseNewsletterSignupDestinationProvider(value) {
|
|
3120
|
+
return value === "riverbank" || value === "mailchimp" ? value : null;
|
|
3121
|
+
}
|
|
3122
|
+
function parseNewsletterSignupAuthoringDestinationValue(value) {
|
|
3123
|
+
if (!isRecord$3(value) || typeof value.tag !== "string") {
|
|
3124
|
+
return err({ kind: "invalid-destination-shape" });
|
|
3125
|
+
}
|
|
3126
|
+
switch (value.tag) {
|
|
3127
|
+
case "riverbank_list": {
|
|
3128
|
+
const listId = parseNewsletterListId(value.listId);
|
|
3129
|
+
return listId === null ? err({ kind: "invalid-newsletter-list-id" }) : ok({
|
|
3130
|
+
tag: "riverbank_list",
|
|
3131
|
+
listId
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
case "mailchimp_audience": {
|
|
3135
|
+
const connectionId = parseMailchimpConnectionId(value.connectionId);
|
|
3136
|
+
if (connectionId === null) {
|
|
3137
|
+
return err({ kind: "invalid-mailchimp-connection-id" });
|
|
3138
|
+
}
|
|
3139
|
+
const audienceId = parseMailchimpAudienceId(value.audienceId);
|
|
3140
|
+
if (audienceId === null) {
|
|
3141
|
+
return err({ kind: "invalid-mailchimp-audience-id" });
|
|
3142
|
+
}
|
|
3143
|
+
const rawTags = value.tags ?? [];
|
|
3144
|
+
if (!Array.isArray(rawTags)) {
|
|
3145
|
+
return err({ kind: "invalid-mailchimp-tags" });
|
|
3146
|
+
}
|
|
3147
|
+
const tags = [];
|
|
3148
|
+
for (const valueTag of rawTags) {
|
|
3149
|
+
const tag = parseMailchimpTagName(valueTag);
|
|
3150
|
+
if (tag === null) return err({ kind: "invalid-mailchimp-tags" });
|
|
3151
|
+
tags.push(tag);
|
|
3152
|
+
}
|
|
3153
|
+
return ok({
|
|
3154
|
+
tag: "mailchimp_audience",
|
|
3155
|
+
connectionId,
|
|
3156
|
+
audienceId,
|
|
3157
|
+
tags
|
|
3158
|
+
});
|
|
3159
|
+
}
|
|
3160
|
+
default: return err({
|
|
3161
|
+
kind: "invalid-destination-tag",
|
|
3162
|
+
value: value.tag
|
|
3163
|
+
});
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
//#endregion
|
|
3168
|
+
//#region ../core/src/footer-engagement.ts
|
|
3169
|
+
function isRecord$2(value) {
|
|
3170
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3171
|
+
}
|
|
3172
|
+
function invalid(path, message) {
|
|
3173
|
+
return {
|
|
3174
|
+
code: "invalid_section",
|
|
3175
|
+
path,
|
|
3176
|
+
message
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
function parseBoundedText(value, path, options) {
|
|
3180
|
+
if (options.optional === true && (value === null || value === undefined)) {
|
|
3181
|
+
return ok(null);
|
|
3182
|
+
}
|
|
3183
|
+
if (typeof value !== "string") {
|
|
3184
|
+
return err(invalid(path, "Footer engagement copy must be text."));
|
|
3185
|
+
}
|
|
3186
|
+
const text = value.trim();
|
|
3187
|
+
if (text.length === 0) {
|
|
3188
|
+
return options.optional === true ? ok(null) : err(invalid(path, "Footer engagement copy cannot be empty."));
|
|
3189
|
+
}
|
|
3190
|
+
return text.length <= options.maxLength ? ok(text) : err(invalid(path, `Footer engagement copy must be ${options.maxLength} characters or fewer.`));
|
|
3191
|
+
}
|
|
3192
|
+
function parseTreatment(value, path) {
|
|
3193
|
+
return value === "integrated" || value === "separate-row" ? ok(value) : err(invalid(path, "Footer engagement treatment is invalid."));
|
|
3194
|
+
}
|
|
3195
|
+
function parseEngagementBase(value, base, path) {
|
|
3196
|
+
const heading = parseBoundedText(value.heading, `${path}.heading`, { maxLength: 80 });
|
|
3197
|
+
if (!heading.ok) return heading;
|
|
3198
|
+
if (heading.value === null) {
|
|
3199
|
+
return err(invalid(`${path}.heading`, "Footer engagement heading cannot be empty."));
|
|
3200
|
+
}
|
|
3201
|
+
const treatment = parseTreatment(value.treatment, `${path}.treatment`);
|
|
3202
|
+
if (!treatment.ok) return treatment;
|
|
3203
|
+
if (!isRecord$2(value.source)) {
|
|
3204
|
+
return err(invalid(`${path}.source`, "Footer engagement source is invalid."));
|
|
3205
|
+
}
|
|
3206
|
+
const intro = parseBoundedText(value.source.intro, `${path}.source.intro`, {
|
|
3207
|
+
maxLength: 240,
|
|
3208
|
+
optional: true
|
|
3209
|
+
});
|
|
3210
|
+
return intro.ok ? ok({
|
|
3211
|
+
...base,
|
|
3212
|
+
heading: heading.value,
|
|
3213
|
+
treatment: treatment.value,
|
|
3214
|
+
intro: intro.value
|
|
3215
|
+
}) : intro;
|
|
3216
|
+
}
|
|
3217
|
+
function parseStandardContact(source, base, path) {
|
|
3218
|
+
const formId = parseFormId(source.formId);
|
|
3219
|
+
const { intro, ...section } = base;
|
|
3220
|
+
return formId.ok ? ok({
|
|
3221
|
+
...section,
|
|
3222
|
+
kind: "engagement",
|
|
3223
|
+
source: {
|
|
3224
|
+
kind: "standard-contact",
|
|
3225
|
+
formId: formId.value,
|
|
3226
|
+
intro
|
|
3227
|
+
}
|
|
3228
|
+
}) : err(invalid(`${path}.formId`, "Footer engagement form ID is invalid."));
|
|
3229
|
+
}
|
|
3230
|
+
function parseMailingList(source, base, path) {
|
|
3231
|
+
const { intro, ...section } = base;
|
|
3232
|
+
const destination = parseNewsletterSignupAuthoringDestinationValue(source.destination);
|
|
3233
|
+
if (!destination.ok) {
|
|
3234
|
+
return err(invalid(`${path}.destination`, "Footer mailing-list destination is invalid."));
|
|
3235
|
+
}
|
|
3236
|
+
const buttonLabel = parseBoundedText(source.buttonLabel, `${path}.buttonLabel`, { maxLength: 40 });
|
|
3237
|
+
if (!buttonLabel.ok) return buttonLabel;
|
|
3238
|
+
const successMessage = parseBoundedText(source.successMessage, `${path}.successMessage`, { maxLength: 160 });
|
|
3239
|
+
if (!successMessage.ok) return successMessage;
|
|
3240
|
+
const privacyNote = parseBoundedText(source.privacyNote, `${path}.privacyNote`, {
|
|
3241
|
+
maxLength: 240,
|
|
3242
|
+
optional: true
|
|
3243
|
+
});
|
|
3244
|
+
if (!privacyNote.ok) return privacyNote;
|
|
3245
|
+
if (buttonLabel.value === null || successMessage.value === null) {
|
|
3246
|
+
return err(invalid(path, "Footer mailing-list copy cannot be empty."));
|
|
3247
|
+
}
|
|
3248
|
+
if (source.fieldPreset !== "emailOnly") {
|
|
3249
|
+
return err(invalid(`${path}.fieldPreset`, "Footer mailing-list signup must be email-only."));
|
|
3250
|
+
}
|
|
3251
|
+
return ok({
|
|
3252
|
+
...section,
|
|
3253
|
+
kind: "engagement",
|
|
3254
|
+
source: {
|
|
3255
|
+
kind: "mailing-list",
|
|
3256
|
+
destination: destination.value,
|
|
3257
|
+
intro,
|
|
3258
|
+
buttonLabel: buttonLabel.value,
|
|
3259
|
+
successMessage: successMessage.value,
|
|
3260
|
+
privacyNote: privacyNote.value,
|
|
3261
|
+
fieldPreset: "emailOnly"
|
|
3262
|
+
}
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
function parseFooterEngagementSectionValue(value, sectionBase, path) {
|
|
3266
|
+
const base = parseEngagementBase(value, sectionBase, path);
|
|
3267
|
+
if (!base.ok) return base;
|
|
3268
|
+
const source = value.source;
|
|
3269
|
+
if (!isRecord$2(source)) {
|
|
3270
|
+
return err(invalid(`${path}.source`, "Footer engagement source is invalid."));
|
|
3271
|
+
}
|
|
3272
|
+
switch (source.kind) {
|
|
3273
|
+
case "standard-contact": return parseStandardContact(source, base.value, `${path}.source`);
|
|
3274
|
+
case "mailing-list": return parseMailingList(source, base.value, `${path}.source`);
|
|
3275
|
+
default: return err(invalid(`${path}.source.kind`, "Footer engagement source kind is invalid."));
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
//#endregion
|
|
3280
|
+
//#region ../core/src/footer-arrangement.ts
|
|
3281
|
+
const sectionKinds = [
|
|
3282
|
+
"brand",
|
|
3283
|
+
"supporting-text",
|
|
3284
|
+
"social-links",
|
|
3285
|
+
"navigation",
|
|
3286
|
+
"business-address",
|
|
3287
|
+
"contact",
|
|
3288
|
+
"opening-hours",
|
|
3289
|
+
"engagement",
|
|
3290
|
+
"legal-navigation",
|
|
3291
|
+
"legal"
|
|
3292
|
+
];
|
|
3293
|
+
const sectionKindOrder = new Map(sectionKinds.map((kind, index) => [kind, index]));
|
|
3294
|
+
const visualGroupOrder = [
|
|
3295
|
+
"identity",
|
|
3296
|
+
"main-navigation",
|
|
3297
|
+
"visit",
|
|
3298
|
+
"engagement",
|
|
3299
|
+
"legal-ending"
|
|
3300
|
+
];
|
|
3301
|
+
function groupFor(kind) {
|
|
3302
|
+
switch (kind) {
|
|
3303
|
+
case "brand":
|
|
3304
|
+
case "supporting-text":
|
|
3305
|
+
case "social-links": return "identity";
|
|
3306
|
+
case "navigation": return "main-navigation";
|
|
3307
|
+
case "business-address":
|
|
3308
|
+
case "contact":
|
|
3309
|
+
case "opening-hours": return "visit";
|
|
3310
|
+
case "engagement": return "engagement";
|
|
3311
|
+
case "legal-navigation":
|
|
3312
|
+
case "legal": return "legal-ending";
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
function buildGroups(sections, navigationLayout) {
|
|
3316
|
+
const groups = new Map();
|
|
3317
|
+
for (const section of sections) {
|
|
3318
|
+
const kind = groupFor(section.kind);
|
|
3319
|
+
groups.set(kind, [...groups.get(kind) ?? [], section]);
|
|
3320
|
+
}
|
|
3321
|
+
return visualGroupOrder.flatMap((kind) => {
|
|
3322
|
+
const groupSections = groups.get(kind);
|
|
3323
|
+
return groupSections === undefined || groupSections.length === 0 ? [] : [{
|
|
3324
|
+
kind,
|
|
3325
|
+
width: kind === "main-navigation" && navigationLayout === "inline-wrap" ? "full-row" : "column",
|
|
3326
|
+
sections: [...groupSections].sort((left, right) => (sectionKindOrder.get(left.kind) ?? Number.MAX_SAFE_INTEGER) - (sectionKindOrder.get(right.kind) ?? Number.MAX_SAFE_INTEGER))
|
|
3327
|
+
}];
|
|
3328
|
+
});
|
|
3329
|
+
}
|
|
3330
|
+
function membershipKey(sections) {
|
|
3331
|
+
const tokens = sections.map((section) => ({
|
|
3332
|
+
rank: sectionKindOrder.get(section.kind) ?? Number.MAX_SAFE_INTEGER,
|
|
3333
|
+
value: section.kind === "engagement" ? `engagement:${section.treatment}` : section.kind
|
|
3334
|
+
}));
|
|
3335
|
+
tokens.sort((left, right) => left.rank - right.rank);
|
|
3336
|
+
return tokens.map(({ value }) => value).join("+") || "empty";
|
|
3337
|
+
}
|
|
3338
|
+
function planFooterArrangement(renderableSections, policy) {
|
|
3339
|
+
const selectedSections = renderableSections.map(({ section }) => section);
|
|
3340
|
+
const groups = buildGroups(selectedSections, policy.navigationLayout);
|
|
3341
|
+
const engagement = selectedSections.find((section) => section.kind === "engagement");
|
|
3342
|
+
const separateEngagement = engagement?.treatment === "separate-row" ? groups.filter((group) => group.kind === "engagement") : [];
|
|
3343
|
+
const layoutGroups = separateEngagement.length === 0 ? groups : groups.filter((group) => group.kind !== "engagement");
|
|
3344
|
+
const legalGroups = layoutGroups.filter((group) => group.kind === "legal-ending");
|
|
3345
|
+
const unsortedMainGroups = policy.kind === "separate-legal-region" ? layoutGroups.filter((group) => group.kind !== "legal-ending") : layoutGroups;
|
|
3346
|
+
const mainGroups = [...unsortedMainGroups.filter((group) => group.width === "column"), ...unsortedMainGroups.filter((group) => group.width === "full-row")];
|
|
3347
|
+
const regions = [];
|
|
3348
|
+
if (separateEngagement.length > 0) {
|
|
3349
|
+
regions.push({
|
|
3350
|
+
kind: "engagement-row",
|
|
3351
|
+
columns: 1,
|
|
3352
|
+
groups: separateEngagement
|
|
3353
|
+
});
|
|
3354
|
+
}
|
|
3355
|
+
if (mainGroups.length > 0) {
|
|
3356
|
+
regions.push({
|
|
3357
|
+
kind: "main",
|
|
3358
|
+
columns: Math.max(1, Math.min(mainGroups.filter((group) => group.width === "column").length, 5)),
|
|
3359
|
+
groups: mainGroups
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
if (policy.kind === "separate-legal-region" && legalGroups.length > 0) {
|
|
3363
|
+
regions.push({
|
|
3364
|
+
kind: "bottom",
|
|
3365
|
+
columns: 1,
|
|
3366
|
+
groups: legalGroups
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3369
|
+
return {
|
|
3370
|
+
membershipKey: membershipKey(selectedSections),
|
|
3371
|
+
regions,
|
|
3372
|
+
mobileGroups: separateEngagement.length === 0 ? groups : [...separateEngagement, ...layoutGroups]
|
|
3373
|
+
};
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
//#endregion
|
|
3377
|
+
//#region ../core/src/footer-composition.ts
|
|
3378
|
+
function unsafeAsFooterModuleId(value) {
|
|
3379
|
+
return value;
|
|
3380
|
+
}
|
|
3381
|
+
function parseFooterModuleId(value) {
|
|
3382
|
+
return parseSectionId(value, "sectionId");
|
|
3383
|
+
}
|
|
3384
|
+
function isRecord$1(value) {
|
|
3385
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3386
|
+
}
|
|
3387
|
+
function isRichText(value) {
|
|
3388
|
+
return isCanonicalRichTextDoc(value);
|
|
3389
|
+
}
|
|
3390
|
+
function parseSectionId(value, path) {
|
|
3391
|
+
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9_-]{0,63}$/u.test(value)) {
|
|
3392
|
+
return err({
|
|
3393
|
+
code: "invalid_section",
|
|
3394
|
+
path,
|
|
3395
|
+
message: "Footer section ID is invalid."
|
|
3396
|
+
});
|
|
3397
|
+
}
|
|
3398
|
+
return ok(unsafeAsFooterModuleId(value));
|
|
3399
|
+
}
|
|
3400
|
+
function parseHeading(value, path) {
|
|
3401
|
+
if (value === null || value === undefined) return ok(null);
|
|
3402
|
+
if (typeof value !== "string") {
|
|
3403
|
+
return err({
|
|
3404
|
+
code: "invalid_section",
|
|
3405
|
+
path,
|
|
3406
|
+
message: "Footer heading must be text."
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
const heading = value.trim();
|
|
3410
|
+
return ok(heading.length > 0 ? heading : null);
|
|
3411
|
+
}
|
|
3412
|
+
function parseFooterSection(value, index) {
|
|
3413
|
+
const path = `sections.${index}`;
|
|
3414
|
+
if (!isRecord$1(value)) {
|
|
3415
|
+
return err({
|
|
3416
|
+
code: "invalid_section",
|
|
3417
|
+
path,
|
|
3418
|
+
message: "Footer section must be an object."
|
|
3419
|
+
});
|
|
3420
|
+
}
|
|
3421
|
+
const id = parseSectionId(value.id, `${path}.id`);
|
|
3422
|
+
if (!id.ok) return id;
|
|
3423
|
+
const heading = parseHeading(value.heading, `${path}.heading`);
|
|
3424
|
+
if (!heading.ok) return heading;
|
|
3425
|
+
if (typeof value.selected !== "boolean") {
|
|
3426
|
+
return err({
|
|
3427
|
+
code: "invalid_section",
|
|
3428
|
+
path: `${path}.selected`,
|
|
3429
|
+
message: "Footer selection intent is missing."
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
const base = {
|
|
3433
|
+
id: id.value,
|
|
3434
|
+
selected: value.selected
|
|
3435
|
+
};
|
|
3436
|
+
switch (value.kind) {
|
|
3437
|
+
case "brand":
|
|
3438
|
+
if (typeof value.showLogo === "boolean" && typeof value.showTitle === "boolean") {
|
|
3439
|
+
return ok({
|
|
3440
|
+
...base,
|
|
3441
|
+
kind: "brand",
|
|
3442
|
+
heading: heading.value,
|
|
3443
|
+
showLogo: value.showLogo,
|
|
3444
|
+
showTitle: value.showTitle
|
|
3445
|
+
});
|
|
3446
|
+
}
|
|
3447
|
+
break;
|
|
3448
|
+
case "navigation":
|
|
3449
|
+
if (isRecord$1(value.source) && value.source.kind === "menu-role" && value.source.role === "main") {
|
|
3450
|
+
return ok({
|
|
3451
|
+
...base,
|
|
3452
|
+
kind: "navigation",
|
|
3453
|
+
heading: heading.value,
|
|
3454
|
+
source: {
|
|
3455
|
+
kind: "menu-role",
|
|
3456
|
+
role: "main"
|
|
3457
|
+
}
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
break;
|
|
3461
|
+
case "legal-navigation":
|
|
3462
|
+
if (isRecord$1(value.source) && value.source.kind === "menu-role" && value.source.role === "legal-policy") {
|
|
3463
|
+
return ok({
|
|
3464
|
+
...base,
|
|
3465
|
+
kind: "legal-navigation",
|
|
3466
|
+
heading: heading.value,
|
|
3467
|
+
source: {
|
|
3468
|
+
kind: "menu-role",
|
|
3469
|
+
role: "legal-policy"
|
|
3470
|
+
}
|
|
3471
|
+
});
|
|
3472
|
+
}
|
|
3473
|
+
break;
|
|
3474
|
+
case "business-address":
|
|
3475
|
+
if (isRecord$1(value.source) && value.source.kind === "business-address") {
|
|
3476
|
+
return ok({
|
|
3477
|
+
...base,
|
|
3478
|
+
kind: "business-address",
|
|
3479
|
+
heading: heading.value,
|
|
3480
|
+
source: { kind: "business-address" }
|
|
3481
|
+
});
|
|
3482
|
+
}
|
|
3483
|
+
break;
|
|
3484
|
+
case "contact":
|
|
3485
|
+
case "opening-hours":
|
|
3486
|
+
case "social-links":
|
|
3487
|
+
if (isRecord$1(value.source) && value.source.kind === "public-profile") {
|
|
3488
|
+
return ok({
|
|
3489
|
+
...base,
|
|
3490
|
+
kind: value.kind,
|
|
3491
|
+
heading: heading.value,
|
|
3492
|
+
source: { kind: "public-profile" }
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
break;
|
|
3496
|
+
case "supporting-text":
|
|
3497
|
+
case "legal":
|
|
3498
|
+
if (value.content === null || isRichText(value.content)) {
|
|
3499
|
+
return ok({
|
|
3500
|
+
...base,
|
|
3501
|
+
kind: value.kind,
|
|
3502
|
+
heading: heading.value,
|
|
3503
|
+
content: value.content
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
break;
|
|
3507
|
+
case "engagement": return parseFooterEngagementSectionValue(value, base, path);
|
|
3508
|
+
}
|
|
3509
|
+
return err({
|
|
3510
|
+
code: "invalid_section",
|
|
3511
|
+
path,
|
|
3512
|
+
message: "Footer section does not match its declared kind."
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
function validateFooterComposition(composition) {
|
|
3516
|
+
const sectionIds = new Set();
|
|
3517
|
+
const kinds = new Set();
|
|
3518
|
+
for (const [index, section] of composition.sections.entries()) {
|
|
3519
|
+
if (sectionIds.has(section.id)) {
|
|
3520
|
+
return err({
|
|
3521
|
+
code: "duplicate_section_id",
|
|
3522
|
+
path: `sections.${index}.id`,
|
|
3523
|
+
message: "Footer section IDs must be unique."
|
|
3524
|
+
});
|
|
3525
|
+
}
|
|
3526
|
+
sectionIds.add(section.id);
|
|
3527
|
+
if (kinds.has(section.kind)) {
|
|
3528
|
+
return err({
|
|
3529
|
+
code: "duplicate_section_kind",
|
|
3530
|
+
path: `sections.${index}.kind`,
|
|
3531
|
+
message: "Each footer section kind can appear only once."
|
|
3532
|
+
});
|
|
3533
|
+
}
|
|
3534
|
+
kinds.add(section.kind);
|
|
3535
|
+
}
|
|
3536
|
+
return ok(composition);
|
|
3537
|
+
}
|
|
3538
|
+
function parseFooterCompositionV1(value) {
|
|
3539
|
+
if (!isRecord$1(value)) {
|
|
3540
|
+
return err({
|
|
3541
|
+
code: "invalid_shape",
|
|
3542
|
+
path: "",
|
|
3543
|
+
message: "Footer composition must be an object."
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
if (value.schemaVersion !== 1) {
|
|
3547
|
+
return err({
|
|
3548
|
+
code: "unsupported_schema_version",
|
|
3549
|
+
path: "schemaVersion",
|
|
3550
|
+
message: "Footer composition version is not supported."
|
|
3551
|
+
});
|
|
3552
|
+
}
|
|
3553
|
+
if (!Array.isArray(value.sections)) {
|
|
3554
|
+
return err({
|
|
3555
|
+
code: "invalid_shape",
|
|
3556
|
+
path: "sections",
|
|
3557
|
+
message: "Footer sections are missing."
|
|
3558
|
+
});
|
|
3559
|
+
}
|
|
3560
|
+
const sections = [];
|
|
3561
|
+
for (const [index, rawSection] of value.sections.entries()) {
|
|
3562
|
+
const section = parseFooterSection(rawSection, index);
|
|
3563
|
+
if (!section.ok) return section;
|
|
3564
|
+
sections.push(section.value);
|
|
3565
|
+
}
|
|
3566
|
+
return validateFooterComposition({
|
|
3567
|
+
schemaVersion: 1,
|
|
3568
|
+
sections
|
|
3569
|
+
});
|
|
3570
|
+
}
|
|
3571
|
+
function parseLegacyPlacementSection(value, index, selectedIds) {
|
|
3572
|
+
if (!isRecord$1(value)) {
|
|
3573
|
+
return err({
|
|
3574
|
+
code: "invalid_section",
|
|
3575
|
+
path: `modules.${index}`,
|
|
3576
|
+
message: "Footer module must be an object."
|
|
3577
|
+
});
|
|
3578
|
+
}
|
|
3579
|
+
const selected = typeof value.id === "string" && selectedIds.has(value.id);
|
|
3580
|
+
const source = isRecord$1(value.source) ? value.source : null;
|
|
3581
|
+
const mapped = value.kind === "navigation" && source?.kind === "menu-role" && source.role === "footer" ? {
|
|
3582
|
+
...value,
|
|
3583
|
+
source: {
|
|
3584
|
+
kind: "menu-role",
|
|
3585
|
+
role: "main"
|
|
3586
|
+
},
|
|
3587
|
+
selected
|
|
3588
|
+
} : {
|
|
3589
|
+
...value,
|
|
3590
|
+
selected
|
|
3591
|
+
};
|
|
3592
|
+
return parseFooterSection(mapped, index);
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Bounded pre-production upgrader for the placement-shaped V1 that never reached production.
|
|
3596
|
+
* It is intentionally not called by the runtime parser.
|
|
3597
|
+
*/
|
|
3598
|
+
function upgradePlacementFooterCompositionV1(value) {
|
|
3599
|
+
if (!isRecord$1(value) || value.schemaVersion !== 1 || !Array.isArray(value.modules) || !Array.isArray(value.placements)) {
|
|
3600
|
+
return err({
|
|
3601
|
+
code: "invalid_shape",
|
|
3602
|
+
path: "",
|
|
3603
|
+
message: "Placement-based footer composition is invalid."
|
|
3604
|
+
});
|
|
3605
|
+
}
|
|
3606
|
+
const selectedIds = new Set();
|
|
3607
|
+
for (const [index, placement] of value.placements.entries()) {
|
|
3608
|
+
if (!isRecord$1(placement) || typeof placement.moduleId !== "string") {
|
|
3609
|
+
return err({
|
|
3610
|
+
code: "invalid_shape",
|
|
3611
|
+
path: `placements.${index}`,
|
|
3612
|
+
message: "Footer placement is invalid."
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
3615
|
+
if (selectedIds.has(placement.moduleId)) {
|
|
3616
|
+
return err({
|
|
3617
|
+
code: "invalid_shape",
|
|
3618
|
+
path: `placements.${index}.moduleId`,
|
|
3619
|
+
message: "A legacy footer module can only be placed once."
|
|
3620
|
+
});
|
|
3621
|
+
}
|
|
3622
|
+
selectedIds.add(placement.moduleId);
|
|
3623
|
+
}
|
|
3624
|
+
const sections = [];
|
|
3625
|
+
for (const [index, rawModule] of value.modules.entries()) {
|
|
3626
|
+
const section = parseLegacyPlacementSection(rawModule, index, selectedIds);
|
|
3627
|
+
if (!section.ok) return section;
|
|
3628
|
+
sections.push(section.value);
|
|
3629
|
+
}
|
|
3630
|
+
const sectionIds = new Set(sections.map((section) => section.id));
|
|
3631
|
+
const unknownPlacedId = [...selectedIds].find((sectionId) => !sectionIds.has(sectionId));
|
|
3632
|
+
if (unknownPlacedId !== undefined) {
|
|
3633
|
+
return err({
|
|
3634
|
+
code: "unknown_section",
|
|
3635
|
+
path: "placements",
|
|
3636
|
+
message: `Legacy placement references unknown section ${unknownPlacedId}.`
|
|
3637
|
+
});
|
|
3638
|
+
}
|
|
3639
|
+
return validateFooterComposition({
|
|
3640
|
+
schemaVersion: 1,
|
|
3641
|
+
sections
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
function setFooterSectionSelected(composition, sectionId, selected) {
|
|
3645
|
+
if (!composition.sections.some((section) => section.id === sectionId)) {
|
|
3646
|
+
return err({
|
|
3647
|
+
code: "unknown_section",
|
|
3648
|
+
path: "sectionId",
|
|
3649
|
+
message: "Footer section was not found."
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
return ok({
|
|
3653
|
+
...composition,
|
|
3654
|
+
sections: composition.sections.map((section) => section.id === sectionId ? {
|
|
3655
|
+
...section,
|
|
3656
|
+
selected
|
|
3657
|
+
} : section)
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
function parseFooterEngagementSection(value) {
|
|
3661
|
+
const parsed = parseFooterSection(value, 0);
|
|
3662
|
+
if (!parsed.ok) return parsed;
|
|
3663
|
+
return parsed.value.kind === "engagement" ? ok(parsed.value) : err({
|
|
3664
|
+
code: "invalid_section",
|
|
3665
|
+
path: "sections.0.kind",
|
|
3666
|
+
message: "Footer section must be an engagement section."
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
function upsertFooterEngagementSection(composition, section) {
|
|
3670
|
+
const hasEngagement = composition.sections.some((candidate) => candidate.kind === "engagement");
|
|
3671
|
+
return validateFooterComposition({
|
|
3672
|
+
...composition,
|
|
3673
|
+
sections: hasEngagement ? composition.sections.map((candidate) => candidate.kind === "engagement" ? section : candidate) : [...composition.sections, section]
|
|
3674
|
+
});
|
|
3675
|
+
}
|
|
3676
|
+
function removeFooterEngagementSection(composition) {
|
|
3677
|
+
return {
|
|
3678
|
+
...composition,
|
|
3679
|
+
sections: composition.sections.filter((section) => section.kind !== "engagement")
|
|
3680
|
+
};
|
|
3681
|
+
}
|
|
3682
|
+
function repairTargetFor(section) {
|
|
3683
|
+
switch (section.kind) {
|
|
3684
|
+
case "brand": return { kind: "site-identity" };
|
|
3685
|
+
case "navigation": return {
|
|
3686
|
+
kind: "navigation",
|
|
3687
|
+
role: "main"
|
|
3688
|
+
};
|
|
3689
|
+
case "legal-navigation": return {
|
|
3690
|
+
kind: "navigation",
|
|
3691
|
+
role: "legal-policy"
|
|
3692
|
+
};
|
|
3693
|
+
case "business-address": return { kind: "business-address" };
|
|
3694
|
+
case "engagement": return {
|
|
3695
|
+
kind: "engagement-source",
|
|
3696
|
+
source: section.source.kind
|
|
3697
|
+
};
|
|
3698
|
+
case "contact":
|
|
3699
|
+
case "opening-hours":
|
|
3700
|
+
case "social-links": return {
|
|
3701
|
+
kind: "public-profile",
|
|
3702
|
+
section: section.kind
|
|
3703
|
+
};
|
|
3704
|
+
case "supporting-text":
|
|
3705
|
+
case "legal": return {
|
|
3706
|
+
kind: "footer-section",
|
|
3707
|
+
sectionId: section.id
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
function resolveFooterSections(composition, availability) {
|
|
3712
|
+
return composition.sections.filter((section) => section.selected).map((section) => isSectionRenderable(section, availability) ? {
|
|
3713
|
+
kind: "renderable",
|
|
3714
|
+
section
|
|
3715
|
+
} : {
|
|
3716
|
+
kind: "missing-source",
|
|
3717
|
+
section,
|
|
3718
|
+
repairTarget: repairTargetFor(section)
|
|
3719
|
+
});
|
|
3720
|
+
}
|
|
3721
|
+
function isSectionRenderable(section, availability) {
|
|
3722
|
+
switch (section.kind) {
|
|
3723
|
+
case "brand": return section.showLogo && availability.hasLogo || section.showTitle;
|
|
3724
|
+
case "navigation": return availability.hasMainNavigation;
|
|
3725
|
+
case "legal-navigation": return availability.hasLegalPolicyNavigation;
|
|
3726
|
+
case "business-address": return availability.hasBusinessAddress;
|
|
3727
|
+
case "contact": return availability.hasContact;
|
|
3728
|
+
case "opening-hours": return availability.hasOpeningHours;
|
|
3729
|
+
case "social-links": return availability.hasSocialLinks;
|
|
3730
|
+
case "engagement": return availability.hasEngagementSource;
|
|
3731
|
+
case "supporting-text":
|
|
3732
|
+
case "legal": return section.content !== null;
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
function deriveFooterEditorOverview(composition) {
|
|
3736
|
+
return {
|
|
3737
|
+
selected: composition.sections.filter((section) => section.selected).map((section) => ({
|
|
3738
|
+
section,
|
|
3739
|
+
status: "selected"
|
|
3740
|
+
})),
|
|
3741
|
+
available: composition.sections.filter((section) => !section.selected).map((section) => ({
|
|
3742
|
+
section,
|
|
3743
|
+
status: "available"
|
|
3744
|
+
}))
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
const defaultSections = (legacy, options) => {
|
|
3748
|
+
const footerVisible = options.footerVisible ?? true;
|
|
3749
|
+
return [
|
|
3750
|
+
{
|
|
3751
|
+
id: unsafeAsFooterModuleId("brand"),
|
|
3752
|
+
kind: "brand",
|
|
3753
|
+
heading: null,
|
|
3754
|
+
selected: footerVisible,
|
|
3755
|
+
showLogo: options.brand?.showLogo ?? true,
|
|
3756
|
+
showTitle: options.brand?.showTitle ?? true
|
|
3757
|
+
},
|
|
3758
|
+
{
|
|
3759
|
+
id: unsafeAsFooterModuleId("navigation"),
|
|
3760
|
+
kind: "navigation",
|
|
3761
|
+
heading: null,
|
|
3762
|
+
selected: footerVisible,
|
|
3763
|
+
source: {
|
|
3764
|
+
kind: "menu-role",
|
|
3765
|
+
role: "main"
|
|
3766
|
+
}
|
|
3767
|
+
},
|
|
3768
|
+
{
|
|
3769
|
+
id: unsafeAsFooterModuleId("legal-navigation"),
|
|
3770
|
+
kind: "legal-navigation",
|
|
3771
|
+
heading: null,
|
|
3772
|
+
selected: false,
|
|
3773
|
+
source: {
|
|
3774
|
+
kind: "menu-role",
|
|
3775
|
+
role: "legal-policy"
|
|
3776
|
+
}
|
|
3777
|
+
},
|
|
3778
|
+
{
|
|
3779
|
+
id: unsafeAsFooterModuleId("business-address"),
|
|
3780
|
+
kind: "business-address",
|
|
3781
|
+
heading: null,
|
|
3782
|
+
selected: footerVisible && legacy.showBusinessAddress === true,
|
|
3783
|
+
source: { kind: "business-address" }
|
|
3784
|
+
},
|
|
3785
|
+
{
|
|
3786
|
+
id: unsafeAsFooterModuleId("contact"),
|
|
3787
|
+
kind: "contact",
|
|
3788
|
+
heading: null,
|
|
3789
|
+
selected: false,
|
|
3790
|
+
source: { kind: "public-profile" }
|
|
3791
|
+
},
|
|
3792
|
+
{
|
|
3793
|
+
id: unsafeAsFooterModuleId("opening-hours"),
|
|
3794
|
+
kind: "opening-hours",
|
|
3795
|
+
heading: null,
|
|
3796
|
+
selected: false,
|
|
3797
|
+
source: { kind: "public-profile" }
|
|
3798
|
+
},
|
|
3799
|
+
{
|
|
3800
|
+
id: unsafeAsFooterModuleId("social-links"),
|
|
3801
|
+
kind: "social-links",
|
|
3802
|
+
heading: null,
|
|
3803
|
+
selected: false,
|
|
3804
|
+
source: { kind: "public-profile" }
|
|
3805
|
+
},
|
|
3806
|
+
{
|
|
3807
|
+
id: unsafeAsFooterModuleId("supporting-text"),
|
|
3808
|
+
kind: "supporting-text",
|
|
3809
|
+
heading: null,
|
|
3810
|
+
selected: footerVisible && isRichText(legacy.bodyText),
|
|
3811
|
+
content: isRichText(legacy.bodyText) ? legacy.bodyText : null
|
|
3812
|
+
},
|
|
3813
|
+
{
|
|
3814
|
+
id: unsafeAsFooterModuleId("legal"),
|
|
3815
|
+
kind: "legal",
|
|
3816
|
+
heading: null,
|
|
3817
|
+
selected: footerVisible && isRichText(legacy.bottomText),
|
|
3818
|
+
content: isRichText(legacy.bottomText) ? legacy.bottomText : null
|
|
3819
|
+
}
|
|
3820
|
+
];
|
|
3821
|
+
};
|
|
3822
|
+
function normalizeLegacyFooterContentToV1(value, options = {}) {
|
|
3823
|
+
if (!isRecord$1(value)) {
|
|
3824
|
+
return err({
|
|
3825
|
+
code: "invalid_shape",
|
|
3826
|
+
path: "",
|
|
3827
|
+
message: "Legacy footer content must be an object."
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
return validateFooterComposition({
|
|
3831
|
+
schemaVersion: 1,
|
|
3832
|
+
sections: defaultSections(value, options)
|
|
3833
|
+
});
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
//#endregion
|
|
3837
|
+
//#region ../core/src/footer-navigation-layout.ts
|
|
3838
|
+
const footerNavigationLayouts = ["stack", "inline-wrap"];
|
|
3839
|
+
const defaultFooterNavigationLayout = "inline-wrap";
|
|
3840
|
+
function normalizeFooterNavigationLayout(value) {
|
|
3841
|
+
return value === "stack" ? "stack" : defaultFooterNavigationLayout;
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
//#endregion
|
|
3845
|
+
//#region ../core/src/number-input.ts
|
|
3846
|
+
/**
|
|
3847
|
+
* Numeric form-input parsing helpers with preserve-prev semantics on
|
|
3848
|
+
* NaN/empty/negative. Use these in `NumberInput.onChange` so empty input
|
|
3849
|
+
* doesn't silently rewrite the user's value to 0.
|
|
3850
|
+
*
|
|
3851
|
+
* The caller skips the dispatch when `null` is returned; the input box
|
|
3852
|
+
* keeps showing whatever the user typed (it's an uncontrolled native
|
|
3853
|
+
* input from React's perspective until the next state change), and the
|
|
3854
|
+
* dirty/saved state stays consistent.
|
|
3855
|
+
*/
|
|
3856
|
+
/**
|
|
3857
|
+
* Parse a `valueAsNumber` (from `<input type="number">`) into a finite
|
|
3858
|
+
* non-negative integer, or `null` if the value is empty / NaN / negative.
|
|
3859
|
+
* Fractional input is `Math.trunc`'d to an integer.
|
|
3860
|
+
*/
|
|
3861
|
+
function parseFiniteNonNegativeInt(value) {
|
|
3862
|
+
if (!Number.isFinite(value)) return null;
|
|
3863
|
+
if (value < 0) return null;
|
|
3864
|
+
return Math.trunc(value);
|
|
3865
|
+
}
|
|
3866
|
+
/**
|
|
3867
|
+
* Parse a `valueAsNumber` into an integer bounded inclusive by `[min, max]`.
|
|
3868
|
+
* Returns `null` for out-of-range or non-finite input — same preserve-prev
|
|
3869
|
+
* pattern as `parseFiniteNonNegativeInt`.
|
|
3870
|
+
*/
|
|
3871
|
+
function parseBoundedInt(value, min, max) {
|
|
3872
|
+
if (!Number.isFinite(value)) return null;
|
|
3873
|
+
const truncated = Math.trunc(value);
|
|
3874
|
+
if (truncated < min) return null;
|
|
3875
|
+
if (truncated > max) return null;
|
|
3876
|
+
return truncated;
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3879
|
+
//#endregion
|
|
3880
|
+
//#region ../core/src/field-errors.ts
|
|
3881
|
+
function clearFieldError(errors, field) {
|
|
3882
|
+
const error = errors[field];
|
|
3883
|
+
if (error === undefined || error.length === 0) {
|
|
3884
|
+
return errors;
|
|
3885
|
+
}
|
|
3886
|
+
const nextErrors = { ...errors };
|
|
3887
|
+
delete nextErrors[field];
|
|
3888
|
+
return nextErrors;
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
//#endregion
|
|
3892
|
+
//#region ../core/src/form-submission-preview.ts
|
|
3893
|
+
const FORM_SUBMISSION_PSEUDO_FIELD_ID = "submission";
|
|
3894
|
+
const HIDDEN_FIELD_IDS = new Set(["form_id", "formid"]);
|
|
3895
|
+
const PRIORITY_IDS = new Set([
|
|
3896
|
+
"name",
|
|
3897
|
+
"full_name",
|
|
3898
|
+
"fullname",
|
|
3899
|
+
"first_name",
|
|
3900
|
+
"firstname",
|
|
3901
|
+
"last_name",
|
|
3902
|
+
"lastname",
|
|
3903
|
+
"email",
|
|
3904
|
+
"email_address",
|
|
3905
|
+
"emailaddress"
|
|
3906
|
+
]);
|
|
3907
|
+
const MAX_COLUMNS = 5;
|
|
3908
|
+
function isRecord(value) {
|
|
3909
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3910
|
+
}
|
|
3911
|
+
function isSchemaField(value) {
|
|
3912
|
+
return isRecord(value) && typeof value.id === "string";
|
|
3913
|
+
}
|
|
3914
|
+
function selectSubmissionPreviewFields(schemaFields, submissions) {
|
|
3915
|
+
const fields = Array.isArray(schemaFields) ? schemaFields.filter(isSchemaField) : [];
|
|
3916
|
+
if (!fields.length) {
|
|
3917
|
+
const fallbackKeys = collectFallbackKeys(submissions);
|
|
3918
|
+
return fallbackKeys.slice(0, MAX_COLUMNS).map((key) => ({
|
|
3919
|
+
id: key,
|
|
3920
|
+
label: humanizeSubmissionFieldKey(key),
|
|
3921
|
+
type: "text"
|
|
3922
|
+
}));
|
|
3923
|
+
}
|
|
3924
|
+
const scored = fields.map((field, index) => {
|
|
3925
|
+
const id = trimmedStringOrNull(field.id);
|
|
3926
|
+
if (id === null) return null;
|
|
3927
|
+
const type = (field.type ?? "text").toString().toLowerCase();
|
|
3928
|
+
if (shouldHideInPreview(type)) return null;
|
|
3929
|
+
const label = trimmedStringOrNull(field.label?.toString()) ?? humanizeSubmissionFieldKey(id);
|
|
3930
|
+
const normalizedId = id.toLowerCase();
|
|
3931
|
+
const normalizedLabel = label.toLowerCase();
|
|
3932
|
+
let score = 0;
|
|
3933
|
+
if (PRIORITY_IDS.has(normalizedId)) score += 500;
|
|
3934
|
+
if (normalizedId.includes("name") || normalizedLabel.includes("name")) score += 550;
|
|
3935
|
+
if (normalizedId.includes("email") || normalizedLabel.includes("email") || type === "email") score += 450;
|
|
3936
|
+
if (normalizedId.includes("phone") || normalizedLabel.includes("phone")) score += 250;
|
|
3937
|
+
if (normalizedId.includes("company") || normalizedLabel.includes("company")) score += 150;
|
|
3938
|
+
if (type === "select" || type === "checkbox" || type === "radio") score += 120;
|
|
3939
|
+
if (field.required === true) score += 60;
|
|
3940
|
+
if (hasValueInSubmissions(submissions, id)) {
|
|
3941
|
+
score += 80;
|
|
3942
|
+
} else {
|
|
3943
|
+
score -= 40;
|
|
3944
|
+
}
|
|
3945
|
+
if ([
|
|
3946
|
+
"richtext",
|
|
3947
|
+
"textarea",
|
|
3948
|
+
"longtext"
|
|
3949
|
+
].includes(type)) {
|
|
3950
|
+
score -= 100;
|
|
3951
|
+
}
|
|
3952
|
+
return {
|
|
3953
|
+
id,
|
|
3954
|
+
label,
|
|
3955
|
+
type,
|
|
3956
|
+
score,
|
|
3957
|
+
index
|
|
3958
|
+
};
|
|
3959
|
+
}).filter((value) => value !== null);
|
|
3960
|
+
scored.sort((a, b) => {
|
|
3961
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
3962
|
+
return a.index - b.index;
|
|
3963
|
+
});
|
|
3964
|
+
return scored.slice(0, MAX_COLUMNS).map((item) => ({
|
|
3965
|
+
id: item.id,
|
|
3966
|
+
label: item.label,
|
|
3967
|
+
type: item.type
|
|
3968
|
+
}));
|
|
3969
|
+
}
|
|
3970
|
+
function shouldHideInPreview(type) {
|
|
3971
|
+
return [
|
|
3972
|
+
"textarea",
|
|
3973
|
+
"richtext",
|
|
3974
|
+
"markdown",
|
|
3975
|
+
"longtext",
|
|
3976
|
+
"html"
|
|
3977
|
+
].includes(type.toLowerCase());
|
|
3978
|
+
}
|
|
3979
|
+
function hasValueInSubmissions(submissions, key) {
|
|
3980
|
+
return submissions.some((row) => valueIsMeaningful(row.dataJson?.[key]));
|
|
3981
|
+
}
|
|
3982
|
+
function collectFallbackKeys(submissions) {
|
|
3983
|
+
const counts = new Map();
|
|
3984
|
+
submissions.forEach((row) => {
|
|
3985
|
+
const data = row.dataJson;
|
|
3986
|
+
if (data === null) return;
|
|
3987
|
+
Object.keys(data).forEach((key) => {
|
|
3988
|
+
if (HIDDEN_FIELD_IDS.has(key.toLowerCase())) return;
|
|
3989
|
+
if (!counts.has(key)) counts.set(key, 0);
|
|
3990
|
+
counts.set(key, (counts.get(key) ?? 0) + (valueIsMeaningful(data[key]) ? 1 : 0));
|
|
3991
|
+
});
|
|
3992
|
+
});
|
|
3993
|
+
return Array.from(counts.entries()).sort((a, b) => {
|
|
3994
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
3995
|
+
return a[0].localeCompare(b[0]);
|
|
3996
|
+
}).map(([key]) => key);
|
|
3997
|
+
}
|
|
3998
|
+
function humanizeSubmissionFieldKey(input) {
|
|
3999
|
+
return input.replace(/[_\-]+/g, " ").replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim().replace(/^\w/, (char) => char.toUpperCase());
|
|
4000
|
+
}
|
|
4001
|
+
function valueIsMeaningful(value) {
|
|
4002
|
+
if (value === null || value === undefined) return false;
|
|
4003
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
4004
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
4005
|
+
if (isRecord(value)) return Object.keys(value).length > 0;
|
|
4006
|
+
return true;
|
|
4007
|
+
}
|
|
4008
|
+
function orderSubmissionFields(schemaFields, data) {
|
|
4009
|
+
const result = [];
|
|
4010
|
+
const seen = new Set();
|
|
4011
|
+
if (schemaFields !== undefined) {
|
|
4012
|
+
schemaFields.forEach((field) => {
|
|
4013
|
+
const id = trimmedStringOrNull(field.id);
|
|
4014
|
+
if (id === null) return;
|
|
4015
|
+
if (HIDDEN_FIELD_IDS.has(id.toLowerCase())) return;
|
|
4016
|
+
seen.add(id);
|
|
4017
|
+
result.push({
|
|
4018
|
+
id,
|
|
4019
|
+
label: trimmedStringOrNull(field.label?.toString()) ?? humanizeSubmissionFieldKey(id),
|
|
4020
|
+
value: data?.[id]
|
|
4021
|
+
});
|
|
4022
|
+
});
|
|
4023
|
+
}
|
|
4024
|
+
if (data !== null) {
|
|
4025
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
4026
|
+
if (seen.has(key)) return;
|
|
4027
|
+
if (HIDDEN_FIELD_IDS.has(key.toLowerCase())) return;
|
|
4028
|
+
result.push({
|
|
4029
|
+
id: key,
|
|
4030
|
+
label: humanizeSubmissionFieldKey(key),
|
|
4031
|
+
value
|
|
4032
|
+
});
|
|
4033
|
+
});
|
|
4034
|
+
}
|
|
4035
|
+
if (result.length === 0) {
|
|
4036
|
+
result.push({
|
|
4037
|
+
id: FORM_SUBMISSION_PSEUDO_FIELD_ID,
|
|
4038
|
+
label: "Submission",
|
|
4039
|
+
value: data
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
return result;
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
//#endregion
|
|
4046
|
+
//#region ../core/src/csv-cells.ts
|
|
4047
|
+
const csvEmptyCell = () => ({ kind: "empty" });
|
|
4048
|
+
const csvTextCell = (value) => value == null || value === "" ? csvEmptyCell() : {
|
|
4049
|
+
kind: "text",
|
|
4050
|
+
value
|
|
4051
|
+
};
|
|
4052
|
+
const csvIntegerCell = (value) => value == null || !Number.isFinite(value) ? csvEmptyCell() : {
|
|
4053
|
+
kind: "integer",
|
|
4054
|
+
value
|
|
4055
|
+
};
|
|
4056
|
+
const csvDecimalCell = (value, decimals) => value == null || !Number.isFinite(value) ? csvEmptyCell() : {
|
|
4057
|
+
kind: "decimal",
|
|
4058
|
+
value,
|
|
4059
|
+
decimals
|
|
4060
|
+
};
|
|
4061
|
+
const csvMoneyCentsCell = (cents, currency) => {
|
|
4062
|
+
const nonEmptyCurrency = asNonEmptyStringOrNull(currency);
|
|
4063
|
+
return cents == null || !Number.isFinite(cents) || nonEmptyCurrency === null ? csvEmptyCell() : {
|
|
4064
|
+
kind: "moneyCents",
|
|
4065
|
+
cents,
|
|
4066
|
+
currency: nonEmptyCurrency
|
|
4067
|
+
};
|
|
4068
|
+
};
|
|
4069
|
+
const csvIsoDateCell = (value) => {
|
|
4070
|
+
const nonEmptyValue = asNonEmptyStringOrNull(value);
|
|
4071
|
+
return nonEmptyValue !== null ? {
|
|
4072
|
+
kind: "isoDate",
|
|
4073
|
+
value: nonEmptyValue
|
|
4074
|
+
} : csvEmptyCell();
|
|
4075
|
+
};
|
|
4076
|
+
const csvIsoDateTimeCell = (value) => {
|
|
4077
|
+
const nonEmptyValue = asNonEmptyStringOrNull(value);
|
|
4078
|
+
return nonEmptyValue !== null ? {
|
|
4079
|
+
kind: "isoDateTime",
|
|
4080
|
+
value: nonEmptyValue
|
|
4081
|
+
} : csvEmptyCell();
|
|
4082
|
+
};
|
|
4083
|
+
const csvBooleanCell = (value) => value == null ? csvEmptyCell() : {
|
|
4084
|
+
kind: "boolean",
|
|
4085
|
+
value
|
|
4086
|
+
};
|
|
4087
|
+
const DEFAULT_CURRENCY_MINOR_UNITS = 2;
|
|
4088
|
+
const CURRENCY_MINOR_UNITS = {
|
|
4089
|
+
BHD: 3,
|
|
4090
|
+
CLP: 0,
|
|
4091
|
+
JPY: 0,
|
|
4092
|
+
KWD: 3,
|
|
4093
|
+
OMR: 3,
|
|
4094
|
+
TND: 3
|
|
4095
|
+
};
|
|
4096
|
+
function currencyMinorUnits(currency) {
|
|
4097
|
+
return CURRENCY_MINOR_UNITS[currency.trim().toUpperCase()] ?? DEFAULT_CURRENCY_MINOR_UNITS;
|
|
4098
|
+
}
|
|
4099
|
+
function formatCsvCell(cell) {
|
|
4100
|
+
switch (cell.kind) {
|
|
4101
|
+
case "empty": return "";
|
|
4102
|
+
case "text": return cell.value;
|
|
4103
|
+
case "integer": return String(cell.value);
|
|
4104
|
+
case "decimal": return cell.value.toFixed(cell.decimals);
|
|
4105
|
+
case "moneyCents": {
|
|
4106
|
+
const minorUnits = currencyMinorUnits(cell.currency);
|
|
4107
|
+
return (cell.cents / 10 ** minorUnits).toFixed(minorUnits);
|
|
4108
|
+
}
|
|
4109
|
+
case "isoDate":
|
|
4110
|
+
case "isoDateTime": return cell.value;
|
|
4111
|
+
case "boolean": return cell.value ? "true" : "false";
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
//#endregion
|
|
4116
|
+
//#region ../core/src/csv-date-filter.ts
|
|
4117
|
+
function resolveExportDateWindow(input) {
|
|
4118
|
+
const basis = input.requestedBasis ?? input.policy.defaultDateBasis;
|
|
4119
|
+
const supportedDateBases = input.policy.supportedDateBases;
|
|
4120
|
+
if (!supportedDateBases.includes(basis)) {
|
|
4121
|
+
return {
|
|
4122
|
+
ok: false,
|
|
4123
|
+
error: {
|
|
4124
|
+
tag: "unsupported_date_basis",
|
|
4125
|
+
basis,
|
|
4126
|
+
exportId: input.exportId
|
|
4127
|
+
}
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
const range = resolveExportDateRange(input.range, input.today ?? new Date());
|
|
4131
|
+
if (!range.ok) return range;
|
|
4132
|
+
if (range.window.from > range.window.to) {
|
|
4133
|
+
return {
|
|
4134
|
+
ok: false,
|
|
4135
|
+
error: {
|
|
4136
|
+
tag: "invalid_date_range",
|
|
4137
|
+
from: range.window.from,
|
|
4138
|
+
to: range.window.to
|
|
4139
|
+
}
|
|
4140
|
+
};
|
|
4141
|
+
}
|
|
4142
|
+
return {
|
|
4143
|
+
ok: true,
|
|
4144
|
+
window: {
|
|
4145
|
+
basis,
|
|
4146
|
+
from: range.window.from,
|
|
4147
|
+
to: range.window.to,
|
|
4148
|
+
timeZone: input.timeZone
|
|
4149
|
+
}
|
|
4150
|
+
};
|
|
4151
|
+
}
|
|
4152
|
+
function resolveExportDateRange(range, today) {
|
|
4153
|
+
if (range.kind === "custom") {
|
|
4154
|
+
if (typeof range.from !== "string" || range.from.length === 0 || typeof range.to !== "string" || range.to.length === 0) {
|
|
4155
|
+
return {
|
|
4156
|
+
ok: false,
|
|
4157
|
+
error: { tag: "partial_custom_range" }
|
|
4158
|
+
};
|
|
4159
|
+
}
|
|
4160
|
+
const from$1 = parseExportDate(range.from, "from");
|
|
4161
|
+
if (!from$1.ok) return from$1;
|
|
4162
|
+
const to$1 = parseExportDate(range.to, "to");
|
|
4163
|
+
if (!to$1.ok) return to$1;
|
|
4164
|
+
return {
|
|
4165
|
+
ok: true,
|
|
4166
|
+
window: {
|
|
4167
|
+
from: from$1.value,
|
|
4168
|
+
to: to$1.value
|
|
4169
|
+
}
|
|
4170
|
+
};
|
|
4171
|
+
}
|
|
4172
|
+
if (range.preset === "all") {
|
|
4173
|
+
return {
|
|
4174
|
+
ok: true,
|
|
4175
|
+
window: {
|
|
4176
|
+
from: unsafeAsMoneyDateString("1970-01-01"),
|
|
4177
|
+
to: unsafeAsMoneyDateString(formatDate(today))
|
|
4178
|
+
}
|
|
4179
|
+
};
|
|
4180
|
+
}
|
|
4181
|
+
const days = presetDays(range.preset);
|
|
4182
|
+
const to = floorUtcDate(today);
|
|
4183
|
+
const from = new Date(to);
|
|
4184
|
+
from.setUTCDate(from.getUTCDate() - (days - 1));
|
|
4185
|
+
return {
|
|
4186
|
+
ok: true,
|
|
4187
|
+
window: {
|
|
4188
|
+
from: unsafeAsMoneyDateString(formatDate(from)),
|
|
4189
|
+
to: unsafeAsMoneyDateString(formatDate(to))
|
|
4190
|
+
}
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
4193
|
+
function parseExportDate(value, field) {
|
|
4194
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
4195
|
+
return {
|
|
4196
|
+
ok: false,
|
|
4197
|
+
error: {
|
|
4198
|
+
tag: "invalid_date",
|
|
4199
|
+
field,
|
|
4200
|
+
value
|
|
4201
|
+
}
|
|
4202
|
+
};
|
|
4203
|
+
}
|
|
4204
|
+
const date = new Date(`${value}T00:00:00.000Z`);
|
|
4205
|
+
if (Number.isNaN(date.getTime()) || formatDate(date) !== value) {
|
|
4206
|
+
return {
|
|
4207
|
+
ok: false,
|
|
4208
|
+
error: {
|
|
4209
|
+
tag: "invalid_date",
|
|
4210
|
+
field,
|
|
4211
|
+
value
|
|
4212
|
+
}
|
|
4213
|
+
};
|
|
4214
|
+
}
|
|
4215
|
+
return {
|
|
4216
|
+
ok: true,
|
|
4217
|
+
value: unsafeAsMoneyDateString(value)
|
|
4218
|
+
};
|
|
4219
|
+
}
|
|
4220
|
+
function presetDays(preset) {
|
|
4221
|
+
switch (preset) {
|
|
4222
|
+
case "7d": return 7;
|
|
4223
|
+
case "30d": return 30;
|
|
4224
|
+
case "90d": return 90;
|
|
4225
|
+
case "365d": return 365;
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
function floorUtcDate(date) {
|
|
4229
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
4230
|
+
}
|
|
4231
|
+
function formatDate(date) {
|
|
4232
|
+
return date.toISOString().slice(0, 10);
|
|
4233
|
+
}
|
|
4234
|
+
function describeExportDateBasis(basis) {
|
|
4235
|
+
switch (basis) {
|
|
4236
|
+
case "created_at": return "Created date";
|
|
4237
|
+
case "scheduled_at": return "Scheduled date";
|
|
4238
|
+
case "cancelled_at": return "Cancellation date";
|
|
4239
|
+
case "paid_at": return "Payment date";
|
|
4240
|
+
case "processed_at": return "Processed date";
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
|
|
4244
|
+
//#endregion
|
|
4245
|
+
//#region ../core/src/csv-escape.ts
|
|
4246
|
+
function csvEscape(value) {
|
|
4247
|
+
let s = String(value);
|
|
4248
|
+
if (/^\s*[=+\-@]/.test(s)) {
|
|
4249
|
+
s = `'${s}`;
|
|
4250
|
+
}
|
|
4251
|
+
if (s.includes(",") || s.includes("\n") || s.includes("\"")) {
|
|
4252
|
+
return `"${s.replace(/"/g, "\"\"")}"`;
|
|
4253
|
+
}
|
|
4254
|
+
return s;
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
//#endregion
|
|
4258
|
+
//#region ../core/src/csv-filename.ts
|
|
4259
|
+
const MAX_CSV_FILENAME_LENGTH = 180;
|
|
4260
|
+
const CSV_EXTENSION = ".csv";
|
|
4261
|
+
function toSafeCsvFilename(value) {
|
|
4262
|
+
const normalized = value.trim();
|
|
4263
|
+
if (!normalized || /[\0\r\n/\\]/.test(normalized)) {
|
|
4264
|
+
return {
|
|
4265
|
+
ok: false,
|
|
4266
|
+
filename: value
|
|
4267
|
+
};
|
|
4268
|
+
}
|
|
4269
|
+
const withExtension = normalized.toLowerCase().endsWith(CSV_EXTENSION) ? normalized : `${normalized}${CSV_EXTENSION}`;
|
|
4270
|
+
const filename = withExtension.length <= MAX_CSV_FILENAME_LENGTH ? withExtension : `${withExtension.slice(0, MAX_CSV_FILENAME_LENGTH - CSV_EXTENSION.length)}${CSV_EXTENSION}`;
|
|
4271
|
+
return {
|
|
4272
|
+
ok: true,
|
|
4273
|
+
filename
|
|
4274
|
+
};
|
|
4275
|
+
}
|
|
4276
|
+
function assertSafeCsvFilename(value) {
|
|
4277
|
+
const result = toSafeCsvFilename(value);
|
|
4278
|
+
if (!result.ok) {
|
|
4279
|
+
throw new Error(`Invalid CSV filename: ${value}`);
|
|
4280
|
+
}
|
|
4281
|
+
return result.filename;
|
|
4282
|
+
}
|
|
4283
|
+
const unsafeCsvFilename = assertSafeCsvFilename;
|
|
4284
|
+
function csvFilenameDateRange(input) {
|
|
4285
|
+
const from = asNonEmptyStringOrNull(input.from);
|
|
4286
|
+
const to = asNonEmptyStringOrNull(input.to);
|
|
4287
|
+
const range = (() => {
|
|
4288
|
+
if (from !== null && to !== null) {
|
|
4289
|
+
return `${from.slice(0, 10)}-to-${to.slice(0, 10)}`;
|
|
4290
|
+
}
|
|
4291
|
+
if (from !== null) {
|
|
4292
|
+
return `from-${from.slice(0, 10)}`;
|
|
4293
|
+
}
|
|
4294
|
+
if (to !== null) {
|
|
4295
|
+
return `to-${to.slice(0, 10)}`;
|
|
4296
|
+
}
|
|
4297
|
+
return input.fallback ?? "all";
|
|
4298
|
+
})();
|
|
4299
|
+
const prefix = asNonEmptyStringOrNull(input.prefix);
|
|
4300
|
+
if (prefix !== null) {
|
|
4301
|
+
return `${prefix}-${range}`;
|
|
4302
|
+
}
|
|
4303
|
+
return range;
|
|
4304
|
+
}
|
|
4305
|
+
function contentDispositionForCsv(filename) {
|
|
4306
|
+
const asciiFallback = String(filename).replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_");
|
|
4307
|
+
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(String(filename))}`;
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
//#endregion
|
|
4311
|
+
//#region ../core/src/csv-render.ts
|
|
4312
|
+
function renderCsvExport(input) {
|
|
4313
|
+
const maxRows = Number(input.definition.maxRows);
|
|
4314
|
+
if (input.rows.length > maxRows) {
|
|
4315
|
+
return {
|
|
4316
|
+
ok: false,
|
|
4317
|
+
error: {
|
|
4318
|
+
tag: "too_many_rows",
|
|
4319
|
+
maxRows,
|
|
4320
|
+
actualRows: input.rows.length
|
|
4321
|
+
}
|
|
4322
|
+
};
|
|
4323
|
+
}
|
|
4324
|
+
const filename = input.definition.filename(input.options);
|
|
4325
|
+
const safeFilename = toSafeCsvFilename(String(filename));
|
|
4326
|
+
if (!safeFilename.ok) {
|
|
4327
|
+
return {
|
|
4328
|
+
ok: false,
|
|
4329
|
+
error: {
|
|
4330
|
+
tag: "invalid_filename",
|
|
4331
|
+
filename: safeFilename.filename
|
|
4332
|
+
}
|
|
4333
|
+
};
|
|
4334
|
+
}
|
|
4335
|
+
const columns = input.definition.columns(input.options);
|
|
4336
|
+
const duplicate = findDuplicateColumnKey(columns);
|
|
4337
|
+
if (duplicate !== null) {
|
|
4338
|
+
return {
|
|
4339
|
+
ok: false,
|
|
4340
|
+
error: {
|
|
4341
|
+
tag: "duplicate_column_key",
|
|
4342
|
+
key: duplicate
|
|
4343
|
+
}
|
|
4344
|
+
};
|
|
4345
|
+
}
|
|
4346
|
+
const header = columns.map((column) => csvEscape(column.header)).join(",");
|
|
4347
|
+
const rows = input.rows.map((row) => columns.map((column) => csvEscape(formatCsvCell(column.cell(row)))).join(","));
|
|
4348
|
+
return {
|
|
4349
|
+
ok: true,
|
|
4350
|
+
csv: [header, ...rows].join("\n"),
|
|
4351
|
+
filename: safeFilename.filename,
|
|
4352
|
+
rowCount: input.rows.length,
|
|
4353
|
+
sensitivities: collectCsvColumnSensitivities(columns)
|
|
4354
|
+
};
|
|
4355
|
+
}
|
|
4356
|
+
function findDuplicateColumnKey(columns) {
|
|
4357
|
+
const seen = new Set();
|
|
4358
|
+
for (const column of columns) {
|
|
4359
|
+
const key = String(column.key);
|
|
4360
|
+
if (seen.has(key)) return key;
|
|
4361
|
+
seen.add(key);
|
|
4362
|
+
}
|
|
4363
|
+
return null;
|
|
4364
|
+
}
|
|
4365
|
+
function collectCsvColumnSensitivities(columns) {
|
|
4366
|
+
return new Set(columns.map((column) => column.sensitivity));
|
|
4367
|
+
}
|
|
4368
|
+
function describeRenderCsvError(error) {
|
|
4369
|
+
switch (error.tag) {
|
|
4370
|
+
case "too_many_rows": return `Export has ${error.actualRows} rows; narrow the filters to ${error.maxRows} rows or fewer.`;
|
|
4371
|
+
case "duplicate_column_key": return `Duplicate CSV column key: ${error.key}`;
|
|
4372
|
+
case "invalid_filename": return `Invalid CSV filename: ${error.filename}`;
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
//#endregion
|
|
4377
|
+
//#region ../core/src/csv-types.ts
|
|
4378
|
+
function unsafeAsCsvExportId(value) {
|
|
4379
|
+
return value;
|
|
4380
|
+
}
|
|
4381
|
+
function unsafeAsCsvColumnKey(value) {
|
|
4382
|
+
return value;
|
|
4383
|
+
}
|
|
4384
|
+
function asExportRowLimit(value) {
|
|
4385
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
4386
|
+
throw new Error(`Invalid export row limit: ${value}`);
|
|
4387
|
+
}
|
|
4388
|
+
return value;
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
//#endregion
|
|
4392
|
+
//#region ../core/src/email-format.ts
|
|
4393
|
+
function maskEmail(email) {
|
|
4394
|
+
if (email === null || email.length === 0) return null;
|
|
4395
|
+
const [localPart, domain] = email.split("@");
|
|
4396
|
+
if (domain === undefined || domain.length === 0 || localPart === undefined || localPart.length === 0) return email;
|
|
4397
|
+
const visibleLocal = localPart.length <= 2 ? `${localPart[0] ?? ""}***` : `${localPart.slice(0, 1)}${"*".repeat(Math.max(localPart.length - 2, 1))}${localPart.slice(-1)}`;
|
|
4398
|
+
return `${visibleLocal}@${domain}`;
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
//#endregion
|
|
4402
|
+
//#region ../core/src/slug.ts
|
|
4403
|
+
/**
|
|
4404
|
+
* Convert a string to a URL-safe slug
|
|
4405
|
+
*
|
|
4406
|
+
* @param text - The text to slugify
|
|
4407
|
+
* @returns URL-safe slug (lowercase, alphanumeric + hyphens)
|
|
4408
|
+
*
|
|
4409
|
+
* @example
|
|
4410
|
+
* slugify("Initial Consultation") // "initial-consultation"
|
|
4411
|
+
* slugify("90-Minute Deep Tissue") // "90-minute-deep-tissue"
|
|
4412
|
+
* slugify("Reiki & Energy Healing") // "reiki-energy-healing"
|
|
4413
|
+
*/
|
|
4414
|
+
function slugify(text) {
|
|
4415
|
+
return text.toString().toLowerCase().trim().replace(/\s+/g, "-").replace(/[^\w\-]+/g, "").replace(/\-\-+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
|
|
4416
|
+
}
|
|
4417
|
+
/**
|
|
4418
|
+
* Generate a unique slug by appending a number if needed
|
|
4419
|
+
*
|
|
4420
|
+
* @param baseSlug - The base slug to make unique
|
|
4421
|
+
* @param existingSlugs - Array of slugs already in use
|
|
4422
|
+
* @returns Unique slug (adds -2, -3, etc. if needed)
|
|
4423
|
+
*
|
|
4424
|
+
* @example
|
|
4425
|
+
* makeUniqueSlug("consultation", ["consultation"])
|
|
4426
|
+
* // Returns: "consultation-2"
|
|
4427
|
+
*
|
|
4428
|
+
* makeUniqueSlug("consultation", ["consultation", "consultation-2"])
|
|
4429
|
+
* // Returns: "consultation-3"
|
|
4430
|
+
*/
|
|
4431
|
+
function makeUniqueSlug(baseSlug, existingSlugs) {
|
|
4432
|
+
let slug = baseSlug;
|
|
4433
|
+
let counter = 2;
|
|
4434
|
+
while (existingSlugs.includes(slug)) {
|
|
4435
|
+
slug = `${baseSlug}-${counter}`;
|
|
4436
|
+
counter++;
|
|
4437
|
+
}
|
|
4438
|
+
return slug;
|
|
4439
|
+
}
|
|
4440
|
+
/**
|
|
4441
|
+
* Generate a slug from text and ensure it's unique
|
|
4442
|
+
*
|
|
4443
|
+
* @param text - The text to slugify
|
|
4444
|
+
* @param existingSlugs - Array of slugs already in use
|
|
4445
|
+
* @returns Unique URL-safe slug
|
|
4446
|
+
*
|
|
4447
|
+
* @example
|
|
4448
|
+
* generateUniqueSlug("Initial Consultation", ["initial-consultation"])
|
|
4449
|
+
* // Returns: "initial-consultation-2"
|
|
4450
|
+
*/
|
|
4451
|
+
function generateUniqueSlug(text, existingSlugs) {
|
|
4452
|
+
const baseSlug = slugify(text);
|
|
4453
|
+
return makeUniqueSlug(baseSlug, existingSlugs);
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
//#endregion
|
|
4457
|
+
//#region ../core/src/uuid.ts
|
|
4458
|
+
/**
|
|
4459
|
+
* Validates whether a string matches UUID v4 format
|
|
4460
|
+
* Format: 8-4-4-4-12 hexadecimal digits
|
|
4461
|
+
* Example: 550e8400-e29b-41d4-a716-446655440000
|
|
4462
|
+
*/
|
|
4463
|
+
function isValidUuid(str) {
|
|
4464
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
//#endregion
|
|
4468
|
+
//#region ../core/src/stable-json.ts
|
|
4469
|
+
function stableJsonStringify(value) {
|
|
4470
|
+
return JSON.stringify(sortStableJsonValue(value));
|
|
4471
|
+
}
|
|
4472
|
+
function sortStableJsonValue(value) {
|
|
4473
|
+
if (value === null) return null;
|
|
4474
|
+
if (typeof value === "string" || typeof value === "boolean") return value;
|
|
4475
|
+
if (typeof value === "number") {
|
|
4476
|
+
if (!Number.isFinite(value)) {
|
|
4477
|
+
throw new TypeError("Stable JSON only supports finite numbers");
|
|
4478
|
+
}
|
|
4479
|
+
return value;
|
|
4480
|
+
}
|
|
4481
|
+
if (Array.isArray(value)) {
|
|
4482
|
+
return value.map(sortStableJsonValue);
|
|
4483
|
+
}
|
|
4484
|
+
if (typeof value === "object") {
|
|
4485
|
+
const prototype = Object.getPrototypeOf(value);
|
|
4486
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
4487
|
+
throw new TypeError("Stable JSON only supports plain objects");
|
|
4488
|
+
}
|
|
4489
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, child]) => [key, sortStableJsonValue(child)]));
|
|
4490
|
+
}
|
|
4491
|
+
throw new TypeError(`Stable JSON does not support ${typeof value}`);
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
//#endregion
|
|
4495
|
+
//#region ../core/src/secret.ts
|
|
4496
|
+
/**
|
|
4497
|
+
* Credential values that must remain inert when they cross diagnostics,
|
|
4498
|
+
* logging, or serialization boundaries.
|
|
4499
|
+
*
|
|
4500
|
+
* The value is deliberately held behind a non-enumerable capability rather
|
|
4501
|
+
* than exposed as a property. The capability uses a global symbol so a Secret
|
|
4502
|
+
* created by another bundled copy of core remains consumable at an approved
|
|
4503
|
+
* `exposeSecret` boundary.
|
|
4504
|
+
*/
|
|
4505
|
+
const SECRET_KINDS = [
|
|
4506
|
+
"management-key",
|
|
4507
|
+
"content-key",
|
|
4508
|
+
"preview-key",
|
|
4509
|
+
"webhook-secret",
|
|
4510
|
+
"oauth-access-token",
|
|
4511
|
+
"oauth-refresh-token",
|
|
4512
|
+
"preview-session-refresh-token",
|
|
4513
|
+
"oauth-authorization-code",
|
|
4514
|
+
"oauth-pkce-verifier",
|
|
4515
|
+
"registrar-epp-code"
|
|
4516
|
+
];
|
|
4517
|
+
const secretValueSymbol = Symbol.for("riverbank.secret.value");
|
|
4518
|
+
const SECRET_INSPECT_SYMBOL = Symbol.for("nodejs.util.inspect.custom");
|
|
4519
|
+
const MANAGEMENT_KEY_PREFIX = "bld_mgmt_sk_";
|
|
4520
|
+
function placeholderFor(kind) {
|
|
4521
|
+
return `[riverbank:redacted:${kind}]`;
|
|
4522
|
+
}
|
|
4523
|
+
/** Create an opaque secret after a caller has parsed/validated its boundary. */
|
|
4524
|
+
function createSecret(kind, value) {
|
|
4525
|
+
return createSecretInternal(kind, value);
|
|
4526
|
+
}
|
|
4527
|
+
/** Construct a Secret for an already validated kind/value pair. */
|
|
4528
|
+
function createSecretInternal(kind, value) {
|
|
4529
|
+
const placeholder = placeholderFor(kind);
|
|
4530
|
+
const secret = {
|
|
4531
|
+
kind: "secret",
|
|
4532
|
+
secretType: kind,
|
|
4533
|
+
toString: () => placeholder,
|
|
4534
|
+
toJSON: () => placeholder,
|
|
4535
|
+
[SECRET_INSPECT_SYMBOL]: () => placeholder
|
|
4536
|
+
};
|
|
4537
|
+
Object.defineProperty(secret, secretValueSymbol, {
|
|
4538
|
+
configurable: false,
|
|
4539
|
+
enumerable: false,
|
|
4540
|
+
value: () => value,
|
|
4541
|
+
writable: false
|
|
4542
|
+
});
|
|
4543
|
+
return Object.freeze(secret);
|
|
4544
|
+
}
|
|
4545
|
+
/**
|
|
4546
|
+
* Parse an untrusted management API key before constructing its Secret value.
|
|
4547
|
+
*
|
|
4548
|
+
* The Secret type protects an already validated value from accidental
|
|
4549
|
+
* disclosure; this boundary is what validates the value's wire format.
|
|
4550
|
+
*/
|
|
4551
|
+
function parseManagementKey(raw) {
|
|
4552
|
+
if (typeof raw !== "string" || !raw.startsWith(MANAGEMENT_KEY_PREFIX) || raw.length === MANAGEMENT_KEY_PREFIX.length) {
|
|
4553
|
+
return err({ kind: "invalid-management-key" });
|
|
4554
|
+
}
|
|
4555
|
+
return ok(createSecretInternal("management-key", raw));
|
|
4556
|
+
}
|
|
4557
|
+
/**
|
|
4558
|
+
* The only generic plaintext exposure operation. Keep calls at named I/O
|
|
4559
|
+
* boundaries; do not pass exposed strings through domain/application layers.
|
|
4560
|
+
*/
|
|
4561
|
+
function exposeSecret(secret) {
|
|
4562
|
+
const getValue = secret[secretValueSymbol];
|
|
4563
|
+
if (typeof getValue !== "function") return failInvalidSecret();
|
|
4564
|
+
return getValue();
|
|
4565
|
+
}
|
|
4566
|
+
function failInvalidSecret() {
|
|
4567
|
+
throw new TypeError("Invalid Riverbank secret");
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4570
|
+
//#endregion
|
|
4571
|
+
//#region ../core/src/site-route-target.ts
|
|
4572
|
+
const SITE_ROUTE_NODE_TYPES = [
|
|
4573
|
+
"page",
|
|
4574
|
+
"content_entry",
|
|
4575
|
+
"page_surface"
|
|
4576
|
+
];
|
|
4577
|
+
function parseSiteRouteTarget(input) {
|
|
4578
|
+
if (typeof input.nodeType !== "string") {
|
|
4579
|
+
return err({
|
|
4580
|
+
kind: "invalid-node-type",
|
|
4581
|
+
nodeType: input.nodeType
|
|
4582
|
+
});
|
|
4583
|
+
}
|
|
4584
|
+
switch (input.nodeType) {
|
|
4585
|
+
case "page": {
|
|
4586
|
+
const pageId = parsePageId(input.nodeId);
|
|
4587
|
+
return pageId.ok ? ok({
|
|
4588
|
+
kind: "page",
|
|
4589
|
+
pageId: pageId.value
|
|
4590
|
+
}) : err({
|
|
4591
|
+
kind: "invalid-page-id",
|
|
4592
|
+
error: pageId.error
|
|
4593
|
+
});
|
|
4594
|
+
}
|
|
4595
|
+
case "content_entry": {
|
|
4596
|
+
const entryId = parseContentEntryId(input.nodeId);
|
|
4597
|
+
return entryId.ok ? ok({
|
|
4598
|
+
kind: "contentEntry",
|
|
4599
|
+
entryId: entryId.value
|
|
4600
|
+
}) : err({
|
|
4601
|
+
kind: "invalid-content-entry-id",
|
|
4602
|
+
error: entryId.error
|
|
4603
|
+
});
|
|
4604
|
+
}
|
|
4605
|
+
case "page_surface": {
|
|
4606
|
+
const surfaceId = parsePageSurfaceId(input.nodeId);
|
|
4607
|
+
return surfaceId.ok ? ok({
|
|
4608
|
+
kind: "pageSurface",
|
|
4609
|
+
surfaceId: surfaceId.value
|
|
4610
|
+
}) : err({
|
|
4611
|
+
kind: "invalid-page-surface-id",
|
|
4612
|
+
error: surfaceId.error
|
|
4613
|
+
});
|
|
4614
|
+
}
|
|
4615
|
+
default: return err({
|
|
4616
|
+
kind: "unsupported-node-type",
|
|
4617
|
+
nodeType: input.nodeType
|
|
4618
|
+
});
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
function siteRouteTargetNodeType(target) {
|
|
4622
|
+
switch (target.kind) {
|
|
4623
|
+
case "page": return "page";
|
|
4624
|
+
case "contentEntry": return "content_entry";
|
|
4625
|
+
case "pageSurface": return "page_surface";
|
|
4626
|
+
default: return assertNever(target);
|
|
4627
|
+
}
|
|
4628
|
+
}
|
|
4629
|
+
function foldSiteRouteTarget(target, cases) {
|
|
4630
|
+
switch (target.kind) {
|
|
4631
|
+
case "page": return cases.page(target.pageId);
|
|
4632
|
+
case "contentEntry": return cases.contentEntry(target.entryId);
|
|
4633
|
+
case "pageSurface": return cases.pageSurface(target.surfaceId);
|
|
4634
|
+
default: return assertNever(target);
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4638
|
+
//#endregion
|
|
4639
|
+
//#region ../core/src/planner.ts
|
|
4640
|
+
function plannerNoChange(details = {}) {
|
|
4641
|
+
return {
|
|
4642
|
+
...details,
|
|
4643
|
+
tag: "no_change"
|
|
4644
|
+
};
|
|
4645
|
+
}
|
|
4646
|
+
function plannerUpdate(details = {}) {
|
|
4647
|
+
return {
|
|
4648
|
+
...details,
|
|
4649
|
+
tag: "update"
|
|
4650
|
+
};
|
|
4651
|
+
}
|
|
4652
|
+
/** Identity at runtime; marks a planner-derived patch as shallow readonly. */
|
|
4653
|
+
function plannerPatch(patch) {
|
|
4654
|
+
return patch;
|
|
4655
|
+
}
|
|
4656
|
+
/** Default clock for production callers that do not inject time explicitly. */
|
|
4657
|
+
const systemPlanningClock = { now: () => Date.now() };
|
|
4658
|
+
/** Resolve current time from an explicit timestamp, injected clock, or system clock. */
|
|
4659
|
+
function resolvePlanningNow(input = {}) {
|
|
4660
|
+
if (typeof input.now === "number") {
|
|
4661
|
+
return input.now;
|
|
4662
|
+
}
|
|
4663
|
+
return (input.clock ?? systemPlanningClock).now();
|
|
4664
|
+
}
|
|
4665
|
+
/** Resolve current time as a Date from an explicit Date, injected clock, or system clock. */
|
|
4666
|
+
function resolvePlanningNowDate(input = {}) {
|
|
4667
|
+
if (input.now !== undefined) {
|
|
4668
|
+
return input.now;
|
|
4669
|
+
}
|
|
4670
|
+
return new Date((input.clock ?? systemPlanningClock).now());
|
|
4671
|
+
}
|
|
4672
|
+
/**
|
|
4673
|
+
* Resolve a Date-compatible planner input.
|
|
4674
|
+
*
|
|
4675
|
+
* Precedence: bare Date, `{ now }`, `{ clock }`, then `systemPlanningClock`.
|
|
4676
|
+
* Explicit Date inputs are returned by reference, not cloned.
|
|
4677
|
+
*/
|
|
4678
|
+
function resolvePlanningDateInput(input = {}) {
|
|
4679
|
+
if (input instanceof Date) {
|
|
4680
|
+
return input;
|
|
4681
|
+
}
|
|
4682
|
+
return resolvePlanningNowDate(input);
|
|
4683
|
+
}
|
|
4684
|
+
/** Build an enabled selection while preserving literal value types. */
|
|
4685
|
+
function enabledSelection(value) {
|
|
4686
|
+
return {
|
|
4687
|
+
enabled: true,
|
|
4688
|
+
value
|
|
4689
|
+
};
|
|
4690
|
+
}
|
|
4691
|
+
/** Build a disabled selection. */
|
|
4692
|
+
function disabledSelection() {
|
|
4693
|
+
return { enabled: false };
|
|
4694
|
+
}
|
|
4695
|
+
|
|
4696
|
+
//#endregion
|
|
4697
|
+
export { unsafeAsDetectedMimeType as $, parseUtcIsoDateTimeString as A, formatCurrencyAmount as B, formatBookingDateParts as C, getBookingLocalDateKey as D, formatLocalScheduleTime as E, parseEventPageSurfaceSubject as F, parsePageId as G, parseApiKeyId as H, formatBookingPriceDisplay as I, unsafeAsAppointmentResourceId as J, parseSiteId as K, formatEventPricing as L, toScheduledBookingDateRange as M, toScheduledBookingDateTime as N, getLocalScheduleDate as O, parseBookingTimeZone as P, unsafeAsDemoCapability as Q, publicEventPricingCurrency as R, formatBookingDate as S, formatLocalScheduleDate as T, parseEventSeriesId as U, assertNever as V, parseMediaAssetId as W, unsafeAsBookingSessionId as X, unsafeAsBookingRequirementId as Y, unsafeAsDeclaredMimeType as Z, mapNonEmptyArray as _, unsafeAsUploadSessionId as _t, stableJsonStringify as a, unsafeAsOccurrenceId as at, normalizeLocationAddressText as b, ok as bt, parseFooterModuleId as c, unsafeAsPositiveSeconds as ct, parseNewsletterListId as d, unsafeAsSiteFontId as dt, unsafeAsEntitlementId as et, parseNewsletterSignupAuthoringDestinationValue as f, unsafeAsSiteId as ft, fromArray as g, unsafeAsStoragePath as gt, assertAllowedPublicUrl as h, unsafeAsStorageBucketName as ht, parseManagementKey as i, unsafeAsMediaAssetId as it, toBookingTimeZone as j, getLocalScheduleTime as k, parseMailchimpAudienceId as l, unsafeAsProductId as lt, isCanonicalRichTextDoc as m, unsafeAsStaffMemberId as mt, createSecret as n, unsafeAsEventSeriesId as nt, footerNavigationLayouts as o, unsafeAsOriginalFilename as ot, parseNewsletterSignupDestinationProvider as p, unsafeAsSiteScopedStoragePath as pt, parseUserId as q, exposeSecret as r, unsafeAsFileChecksumSha256 as rt, parseFooterCompositionV1 as s, unsafeAsPositiveByteCount as st, resolvePlanningDateInput as t, unsafeAsEventOfferingId as tt, parseMailchimpTagName as u, unsafeAsSafeDownloadFilename as ut, hasText as v, unsafeAsUserId as vt, formatBookingTime as w, toLocationCoordinates as x, isPresent as y, err as yt, publicEventPricingType as z };
|