brainerce 1.59.0 → 1.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -27
- package/dist/index.d.mts +470 -16
- package/dist/index.d.ts +470 -16
- package/dist/index.js +321 -23
- package/dist/index.mjs +320 -23
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -78,6 +78,7 @@ __export(index_exports, {
|
|
|
78
78
|
jsonLdScriptProps: () => jsonLdScriptProps,
|
|
79
79
|
parseDateFieldValue: () => parseDateFieldValue,
|
|
80
80
|
parseWebhookEvent: () => parseWebhookEvent,
|
|
81
|
+
resolveRelativeBounds: () => resolveRelativeBounds,
|
|
81
82
|
resolveStoreLocalParts: () => resolveStoreLocalParts,
|
|
82
83
|
safePaymentRedirect: () => safePaymentRedirect,
|
|
83
84
|
stripHtml: () => stripHtml2,
|
|
@@ -203,7 +204,7 @@ function isDevGuardsEnabled() {
|
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
// src/version.ts
|
|
206
|
-
var SDK_VERSION = "1.
|
|
207
|
+
var SDK_VERSION = "1.60.0";
|
|
207
208
|
|
|
208
209
|
// src/client.ts
|
|
209
210
|
var DEFAULT_BASE_URL = "https://api.brainerce.com";
|
|
@@ -360,6 +361,87 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
360
361
|
);
|
|
361
362
|
}
|
|
362
363
|
};
|
|
364
|
+
// -------------------- Marketing signup (newsletter) --------------------
|
|
365
|
+
/**
|
|
366
|
+
* Email marketing signup for a storefront — a newsletter popup, a footer
|
|
367
|
+
* capture bar, an exit-intent modal.
|
|
368
|
+
*
|
|
369
|
+
* **Confirmed opt-in, always.** `subscribe()` creates the contact and mails
|
|
370
|
+
* them a confirmation link. The address is NOT subscribed and CANNOT receive
|
|
371
|
+
* a campaign until the recipient clicks that link. This is not a setting:
|
|
372
|
+
* consent has to come from the mailbox, or anyone could subscribe anyone.
|
|
373
|
+
*
|
|
374
|
+
* So do not render "You're subscribed!" on success — render "Check your
|
|
375
|
+
* email to confirm." The one is a lie until the click lands.
|
|
376
|
+
*
|
|
377
|
+
* The response is identical for a brand-new address, one that is already
|
|
378
|
+
* subscribed, and one suppressed after a bounce, so the form can't be used to
|
|
379
|
+
* probe who shops here. Show the same message for every success.
|
|
380
|
+
*
|
|
381
|
+
* Storefront (public) and vibe-coded modes only. Rate-limited server-side to
|
|
382
|
+
* 3 requests / 60s per IP, plus one confirmation email per address per 24h.
|
|
383
|
+
*
|
|
384
|
+
* **Where the discount goes.** A "10% off your first order" popup needs a
|
|
385
|
+
* coupon from the dashboard — create one with the `customer_first_order`
|
|
386
|
+
* condition and show the code after a successful call. Subscribing does not
|
|
387
|
+
* mint a code on its own.
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ```typescript
|
|
391
|
+
* // Newsletter popup — hidden honeypot input, Hebrew storefront
|
|
392
|
+
* await brainerce.marketing.subscribe({
|
|
393
|
+
* email: 'jane@example.com',
|
|
394
|
+
* locale: 'he',
|
|
395
|
+
* source: 'popup',
|
|
396
|
+
* honeypot: hiddenFieldValue,
|
|
397
|
+
* });
|
|
398
|
+
* // → show "בדקו את המייל שלכם כדי לאשר" — NOT "נרשמת בהצלחה"
|
|
399
|
+
* ```
|
|
400
|
+
*/
|
|
401
|
+
this.marketing = {
|
|
402
|
+
subscribe: async (input) => {
|
|
403
|
+
if (this.isVibeCodedMode()) {
|
|
404
|
+
return this.vibeCodedRequest(
|
|
405
|
+
"POST",
|
|
406
|
+
"/marketing/subscribe",
|
|
407
|
+
input
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return this.storefrontRequest(
|
|
411
|
+
"POST",
|
|
412
|
+
"/marketing/subscribe",
|
|
413
|
+
input
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
// -------------------- Stock alerts --------------------
|
|
418
|
+
/**
|
|
419
|
+
* "Email me when this is back."
|
|
420
|
+
*
|
|
421
|
+
* ⛔ Not a newsletter signup, and must not be worded as one. It grants no
|
|
422
|
+
* marketing consent, creates no customer account, and the person is never
|
|
423
|
+
* mailed anything else as a result — exactly one message, about this item,
|
|
424
|
+
* with a link that stops it. Someone who unsubscribed from marketing can
|
|
425
|
+
* still use this, so do not gate it on consent.
|
|
426
|
+
*
|
|
427
|
+
* Show the affordance only on an item that is out of stock AND cannot be
|
|
428
|
+
* backordered. Every other case is silently ignored server-side — the
|
|
429
|
+
* response is uniform on purpose, so it cannot be used to read stock levels
|
|
430
|
+
* or test who is a customer — which means a button on an in-stock item looks
|
|
431
|
+
* like it worked and does nothing.
|
|
432
|
+
*
|
|
433
|
+
* Pass `variantId` on any product with variants. Without it the alert waits
|
|
434
|
+
* on the product as a whole, and a shopper who wanted the medium hears when
|
|
435
|
+
* the small comes back.
|
|
436
|
+
*/
|
|
437
|
+
this.stockAlerts = {
|
|
438
|
+
subscribe: async (input) => {
|
|
439
|
+
if (this.isVibeCodedMode()) {
|
|
440
|
+
return this.vibeCodedRequest("POST", "/stock-alerts", input);
|
|
441
|
+
}
|
|
442
|
+
return this.storefrontRequest("POST", "/stock-alerts", input);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
363
445
|
// -------------------- Content (typed merchant content) --------------------
|
|
364
446
|
/**
|
|
365
447
|
* Typed merchant content store: FAQ, Footer, Header, Announcement,
|
|
@@ -3134,6 +3216,11 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
3134
3216
|
* Register a new customer with password (creates account)
|
|
3135
3217
|
* Works in vibe-coded, storefront, and admin mode
|
|
3136
3218
|
*
|
|
3219
|
+
* `birthMonth`/`birthDay` are optional and must be sent together. When
|
|
3220
|
+
* `getStoreInfo().requireBirthday` is true the merchant made the birthday
|
|
3221
|
+
* mandatory on that sales channel, and a call without both fields is
|
|
3222
|
+
* rejected with HTTP 400.
|
|
3223
|
+
*
|
|
3137
3224
|
* @example
|
|
3138
3225
|
* ```typescript
|
|
3139
3226
|
* const auth = await client.registerCustomer({
|
|
@@ -3141,6 +3228,8 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
3141
3228
|
* password: 'securepassword123',
|
|
3142
3229
|
* firstName: 'Jane',
|
|
3143
3230
|
* lastName: 'Doe',
|
|
3231
|
+
* birthMonth: 4, // optional, unless the channel requires a birthday
|
|
3232
|
+
* birthDay: 17,
|
|
3144
3233
|
* });
|
|
3145
3234
|
* ```
|
|
3146
3235
|
*/
|
|
@@ -4419,16 +4508,27 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
4419
4508
|
* List visible reviews for a product (storefront / sales-channel modes).
|
|
4420
4509
|
* Reviews that the merchant has hidden are excluded.
|
|
4421
4510
|
*
|
|
4511
|
+
* Each review carries `images` — the photos its author attached, already
|
|
4512
|
+
* filtered to the ones shoppers are allowed to see. Always an array.
|
|
4513
|
+
*
|
|
4514
|
+
* Ordering defaults to `photos_first`: reviews carrying photos lead, newest-first
|
|
4515
|
+
* within each group. Pass `sort: 'newest'` for plain chronological order. On a
|
|
4516
|
+
* store with no review photos the two are identical.
|
|
4517
|
+
*
|
|
4422
4518
|
* @example
|
|
4423
4519
|
* ```typescript
|
|
4424
4520
|
* const { data, meta } = await client.listProductReviews('prod_123', { page: 1, limit: 20 });
|
|
4425
|
-
* data.forEach(r =>
|
|
4521
|
+
* data.forEach(r => {
|
|
4522
|
+
* console.log(r.rating, r.body, r.verifiedPurchase);
|
|
4523
|
+
* r.images.forEach(img => console.log(img.thumbnailUrl ?? img.url));
|
|
4524
|
+
* });
|
|
4426
4525
|
* ```
|
|
4427
4526
|
*/
|
|
4428
4527
|
async listProductReviews(productId, params) {
|
|
4429
4528
|
const queryParams = {};
|
|
4430
4529
|
if (params?.page) queryParams.page = params.page;
|
|
4431
4530
|
if (params?.limit) queryParams.limit = params.limit;
|
|
4531
|
+
if (params?.sort) queryParams.sort = params.sort;
|
|
4432
4532
|
if (this.isVibeCodedMode()) {
|
|
4433
4533
|
return this.vibeCodedRequest(
|
|
4434
4534
|
"GET",
|
|
@@ -4586,6 +4686,37 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
4586
4686
|
storeId ? { storeId } : void 0
|
|
4587
4687
|
);
|
|
4588
4688
|
}
|
|
4689
|
+
/**
|
|
4690
|
+
* Admin: hide ONE photo on a review, leaving the review and its other photos
|
|
4691
|
+
* visible. Requires an API key with `reviews:write`.
|
|
4692
|
+
*/
|
|
4693
|
+
async hideProductReviewImage(imageId, storeId) {
|
|
4694
|
+
if (!this.apiKey) {
|
|
4695
|
+
throw new BrainerceError("hideProductReviewImage() requires admin (API key) mode", 400);
|
|
4696
|
+
}
|
|
4697
|
+
return this.adminRequest(
|
|
4698
|
+
"PATCH",
|
|
4699
|
+
`/api/v1/review-images/${encodePathSegment(imageId)}/hide`,
|
|
4700
|
+
void 0,
|
|
4701
|
+
storeId ? { storeId } : void 0
|
|
4702
|
+
);
|
|
4703
|
+
}
|
|
4704
|
+
/**
|
|
4705
|
+
* Admin: show one review photo. This is also the approve action — a photo that
|
|
4706
|
+
* has never been approved carries no `approvedAt`, and showing it stamps one, so
|
|
4707
|
+
* stores using review-photo approval need no separate verb.
|
|
4708
|
+
*/
|
|
4709
|
+
async showProductReviewImage(imageId, storeId) {
|
|
4710
|
+
if (!this.apiKey) {
|
|
4711
|
+
throw new BrainerceError("showProductReviewImage() requires admin (API key) mode", 400);
|
|
4712
|
+
}
|
|
4713
|
+
return this.adminRequest(
|
|
4714
|
+
"PATCH",
|
|
4715
|
+
`/api/v1/review-images/${encodePathSegment(imageId)}/show`,
|
|
4716
|
+
void 0,
|
|
4717
|
+
storeId ? { storeId } : void 0
|
|
4718
|
+
);
|
|
4719
|
+
}
|
|
4589
4720
|
/** Admin: unhide a previously hidden review. */
|
|
4590
4721
|
async showProductReview(reviewId, storeId) {
|
|
4591
4722
|
if (!this.apiKey) {
|
|
@@ -7241,11 +7372,20 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
7241
7372
|
);
|
|
7242
7373
|
}
|
|
7243
7374
|
/**
|
|
7244
|
-
* Update the current customer's profile (requires customerToken)
|
|
7245
|
-
* Only available in storefront mode
|
|
7375
|
+
* Update the current customer's profile (requires customerToken).
|
|
7376
|
+
* Only available in storefront and vibe-coded mode.
|
|
7246
7377
|
*
|
|
7247
|
-
* `birthMonth`/`birthDay` (1-12 / 1-31
|
|
7248
|
-
*
|
|
7378
|
+
* `birthMonth`/`birthDay` (1-12 / 1-31) power the loyalty birthday gift.
|
|
7379
|
+
* Month and day only, never a year, for privacy. Send both or neither, and
|
|
7380
|
+
* the day has to exist in the month: anything else is rejected with HTTP 400.
|
|
7381
|
+
* Send `null` for both to REMOVE a stored birthday. Leaving the keys out is a
|
|
7382
|
+
* different request and keeps the stored value, so a profile form whose
|
|
7383
|
+
* fields the shopper cleared has to send nulls, not omit them.
|
|
7384
|
+
*
|
|
7385
|
+
* The saved values now come back on the returned `CustomerProfile`, on
|
|
7386
|
+
* `getMyProfile()` and on every customer read type, so a profile form can
|
|
7387
|
+
* re-render the birthday it just saved. Before this, no API returned the two
|
|
7388
|
+
* fields at all and the form always came back blank.
|
|
7249
7389
|
*/
|
|
7250
7390
|
async updateMyProfile(data) {
|
|
7251
7391
|
if (!this.customerToken && !this.proxyMode) {
|
|
@@ -9422,6 +9562,68 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
9422
9562
|
}
|
|
9423
9563
|
throw new Error("uploadCustomizationFile requires storefront or vibe-coded mode");
|
|
9424
9564
|
}
|
|
9565
|
+
/**
|
|
9566
|
+
* Upload one photo to attach to a product review.
|
|
9567
|
+
*
|
|
9568
|
+
* Available in storefront and vibe-coded modes, and — unlike
|
|
9569
|
+
* `uploadCustomizationFile` — it REQUIRES a logged-in customer who has actually
|
|
9570
|
+
* bought the product. Call `setCustomerToken(...)` first. That is the same bar as
|
|
9571
|
+
* writing the review itself, checked here so an ineligible shopper is told before
|
|
9572
|
+
* they wait for the upload rather than after.
|
|
9573
|
+
*
|
|
9574
|
+
* Returns a storage `key`. Collect the keys and pass them as `imageKeys` when you
|
|
9575
|
+
* submit or update the review — the `url` is for a local preview only, and sending
|
|
9576
|
+
* it back instead of the key will be rejected.
|
|
9577
|
+
*
|
|
9578
|
+
* Server rules:
|
|
9579
|
+
* - `image/jpeg|png|webp|gif` only, cross-checked against the file's real bytes.
|
|
9580
|
+
* - Max 5 MB and 40 megapixels per file.
|
|
9581
|
+
* - Throttled to 10 uploads / minute → HTTP 429.
|
|
9582
|
+
* - 403 when the store has review photos turned off, or the customer has not
|
|
9583
|
+
* bought the product; 400 once the review is already at its photo cap.
|
|
9584
|
+
* - EXIF is stripped (so GPS coordinates never reach the storefront) while the
|
|
9585
|
+
* orientation tag is applied first, so phone photos stay upright.
|
|
9586
|
+
* - A photo uploaded but never attached to a submitted review is reclaimed after
|
|
9587
|
+
* 7 days.
|
|
9588
|
+
*
|
|
9589
|
+
* Read `photos` from `getMyProductReview()` for the store's live limits rather
|
|
9590
|
+
* than hard-coding them.
|
|
9591
|
+
*
|
|
9592
|
+
* @example
|
|
9593
|
+
* ```ts
|
|
9594
|
+
* const { photos } = await client.getMyProductReview(productId);
|
|
9595
|
+
* if (photos.enabled) {
|
|
9596
|
+
* const uploads = await Promise.all(
|
|
9597
|
+
* [...fileInput.files].slice(0, photos.maxPerReview)
|
|
9598
|
+
* .map(f => client.uploadReviewPhoto(productId, f))
|
|
9599
|
+
* );
|
|
9600
|
+
* await client.submitProductReview(productId, {
|
|
9601
|
+
* rating: 5,
|
|
9602
|
+
* body: 'Arrived beautifully wrapped.',
|
|
9603
|
+
* imageKeys: uploads.map(u => u.key),
|
|
9604
|
+
* });
|
|
9605
|
+
* }
|
|
9606
|
+
* ```
|
|
9607
|
+
*/
|
|
9608
|
+
async uploadReviewPhoto(productId, file) {
|
|
9609
|
+
const formData = new FormData();
|
|
9610
|
+
formData.append("file", file);
|
|
9611
|
+
if (this.isVibeCodedMode()) {
|
|
9612
|
+
return this.vibeCodedRequest(
|
|
9613
|
+
"POST",
|
|
9614
|
+
`/products/${encodePathSegment(productId)}/review-photo`,
|
|
9615
|
+
formData
|
|
9616
|
+
);
|
|
9617
|
+
}
|
|
9618
|
+
if (this.storeId && !this.apiKey) {
|
|
9619
|
+
return this.storefrontRequest(
|
|
9620
|
+
"POST",
|
|
9621
|
+
`/products/${encodePathSegment(productId)}/review-photo`,
|
|
9622
|
+
formData
|
|
9623
|
+
);
|
|
9624
|
+
}
|
|
9625
|
+
throw new Error("uploadReviewPhoto requires storefront or vibe-coded mode");
|
|
9626
|
+
}
|
|
9425
9627
|
// -------------------- Team Management (Admin) - DEPRECATED --------------------
|
|
9426
9628
|
// Account-level team methods. These are the ONLY team endpoints reachable with
|
|
9427
9629
|
// a `brainerce_*` API key. The store-level equivalents (`getStoreTeam`,
|
|
@@ -10109,7 +10311,7 @@ var DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\
|
|
|
10109
10311
|
var MIN_OFFSET_MINUTES = -12 * 60;
|
|
10110
10312
|
var MAX_OFFSET_MINUTES = 14 * 60;
|
|
10111
10313
|
var VALID_OFFSET_MINUTE_PARTS = [0, 30, 45];
|
|
10112
|
-
function validateDateAvailabilityConfig(config, fieldType) {
|
|
10314
|
+
function validateDateAvailabilityConfig(config, fieldType, surface = "checkout") {
|
|
10113
10315
|
const errors = [];
|
|
10114
10316
|
if (!config) return errors;
|
|
10115
10317
|
if (config.minDate !== void 0 && !DATE_RE.test(config.minDate)) {
|
|
@@ -10121,6 +10323,29 @@ function validateDateAvailabilityConfig(config, fieldType) {
|
|
|
10121
10323
|
if (config.minDate && config.maxDate && DATE_RE.test(config.minDate) && DATE_RE.test(config.maxDate) && config.minDate > config.maxDate) {
|
|
10122
10324
|
errors.push("minDate must be on or before maxDate");
|
|
10123
10325
|
}
|
|
10326
|
+
if (config.leadTimeMinutes !== void 0) {
|
|
10327
|
+
if (!Number.isInteger(config.leadTimeMinutes) || config.leadTimeMinutes < 0) {
|
|
10328
|
+
errors.push("leadTimeMinutes must be a non-negative integer");
|
|
10329
|
+
}
|
|
10330
|
+
}
|
|
10331
|
+
if (config.maxDaysAhead !== void 0) {
|
|
10332
|
+
if (!Number.isInteger(config.maxDaysAhead) || config.maxDaysAhead < 1) {
|
|
10333
|
+
errors.push("maxDaysAhead must be a positive integer");
|
|
10334
|
+
}
|
|
10335
|
+
}
|
|
10336
|
+
if (config.cutoffTime !== void 0 && !TIME_RE.test(config.cutoffTime)) {
|
|
10337
|
+
errors.push("cutoffTime must be in HH:mm format");
|
|
10338
|
+
}
|
|
10339
|
+
if (surface !== "checkout") {
|
|
10340
|
+
const relativeKeys = ["leadTimeMinutes", "cutoffTime", "maxDaysAhead"].filter(
|
|
10341
|
+
(k) => config[k] !== void 0
|
|
10342
|
+
);
|
|
10343
|
+
if (relativeKeys.length > 0) {
|
|
10344
|
+
errors.push(
|
|
10345
|
+
`${relativeKeys.join("/")} only apply to checkout fields, not ${surface} fields`
|
|
10346
|
+
);
|
|
10347
|
+
}
|
|
10348
|
+
}
|
|
10124
10349
|
if (config.blockedWeekdays) {
|
|
10125
10350
|
for (const d of config.blockedWeekdays) {
|
|
10126
10351
|
if (!Number.isInteger(d) || d < 0 || d > 6) {
|
|
@@ -10138,22 +10363,37 @@ function validateDateAvailabilityConfig(config, fieldType) {
|
|
|
10138
10363
|
}
|
|
10139
10364
|
}
|
|
10140
10365
|
if (config.businessHours) {
|
|
10141
|
-
const
|
|
10366
|
+
const windowsByWeekday = /* @__PURE__ */ new Map();
|
|
10142
10367
|
for (const w of config.businessHours) {
|
|
10143
10368
|
if (!Number.isInteger(w.weekday) || w.weekday < 0 || w.weekday > 6) {
|
|
10144
10369
|
errors.push(`businessHours.weekday must be an integer 0-6, got ${JSON.stringify(w.weekday)}`);
|
|
10145
10370
|
continue;
|
|
10146
10371
|
}
|
|
10147
|
-
if (seenWeekdays.has(w.weekday)) {
|
|
10148
|
-
errors.push(`businessHours has more than one window for weekday ${w.weekday}`);
|
|
10149
|
-
}
|
|
10150
|
-
seenWeekdays.add(w.weekday);
|
|
10151
10372
|
const openValid = TIME_RE.test(w.open);
|
|
10152
10373
|
const closeValid = TIME_RE.test(w.close);
|
|
10153
10374
|
if (!openValid) errors.push(`businessHours.open must be HH:mm, got "${w.open}"`);
|
|
10154
10375
|
if (!closeValid) errors.push(`businessHours.close must be HH:mm, got "${w.close}"`);
|
|
10155
|
-
if (openValid
|
|
10376
|
+
if (!openValid || !closeValid) continue;
|
|
10377
|
+
if (w.open >= w.close) {
|
|
10156
10378
|
errors.push(`businessHours for weekday ${w.weekday}: open must be before close`);
|
|
10379
|
+
continue;
|
|
10380
|
+
}
|
|
10381
|
+
const forDay = windowsByWeekday.get(w.weekday);
|
|
10382
|
+
if (forDay) forDay.push(w);
|
|
10383
|
+
else windowsByWeekday.set(w.weekday, [w]);
|
|
10384
|
+
}
|
|
10385
|
+
const weekdaysWithWindows = Array.from(windowsByWeekday.keys()).sort((a, b) => a - b);
|
|
10386
|
+
for (const weekday of weekdaysWithWindows) {
|
|
10387
|
+
const sorted = [...windowsByWeekday.get(weekday) ?? []].sort(
|
|
10388
|
+
(a, b) => a.open < b.open ? -1 : a.open > b.open ? 1 : 0
|
|
10389
|
+
);
|
|
10390
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
10391
|
+
if (sorted[i].open < sorted[i - 1].close) {
|
|
10392
|
+
errors.push(
|
|
10393
|
+
`businessHours for weekday ${weekday}: ${sorted[i - 1].open}-${sorted[i - 1].close} overlaps ${sorted[i].open}-${sorted[i].close}`
|
|
10394
|
+
);
|
|
10395
|
+
break;
|
|
10396
|
+
}
|
|
10157
10397
|
}
|
|
10158
10398
|
}
|
|
10159
10399
|
}
|
|
@@ -10209,6 +10449,32 @@ function resolveStoreLocalParts(instant, timezone) {
|
|
|
10209
10449
|
weekday: WEEKDAY_TO_INDEX[get("weekday")] ?? instant.getUTCDay()
|
|
10210
10450
|
};
|
|
10211
10451
|
}
|
|
10452
|
+
function resolveRelativeBounds(config, clock) {
|
|
10453
|
+
if (!config || !clock) return {};
|
|
10454
|
+
const leadMinutes = typeof config.leadTimeMinutes === "number" && Number.isFinite(config.leadTimeMinutes) ? Math.max(0, Math.floor(config.leadTimeMinutes)) : 0;
|
|
10455
|
+
const cutoff = typeof config.cutoffTime === "string" && TIME_RE.test(config.cutoffTime) ? config.cutoffTime : null;
|
|
10456
|
+
const daysAhead = typeof config.maxDaysAhead === "number" && Number.isFinite(config.maxDaysAhead) && config.maxDaysAhead >= 1 ? Math.floor(config.maxDaysAhead) : null;
|
|
10457
|
+
if (leadMinutes === 0 && !cutoff && daysAhead === null) return {};
|
|
10458
|
+
const now = clock.now ?? /* @__PURE__ */ new Date();
|
|
10459
|
+
const nowLocal = resolveStoreLocalParts(now, clock.timezone);
|
|
10460
|
+
const bounds = {};
|
|
10461
|
+
if (leadMinutes > 0 || cutoff) {
|
|
10462
|
+
const earliestInstant = new Date(now.getTime() + leadMinutes * 6e4);
|
|
10463
|
+
let earliestDate = resolveStoreLocalParts(earliestInstant, clock.timezone).dateYYYYMMDD;
|
|
10464
|
+
if (cutoff && nowLocal.hhmm >= cutoff) earliestDate = addCalendarDays(earliestDate, 1);
|
|
10465
|
+
bounds.earliestDate = earliestDate;
|
|
10466
|
+
if (leadMinutes > 0) bounds.earliestInstant = earliestInstant;
|
|
10467
|
+
}
|
|
10468
|
+
if (daysAhead !== null) {
|
|
10469
|
+
bounds.latestDate = addCalendarDays(nowLocal.dateYYYYMMDD, daysAhead);
|
|
10470
|
+
}
|
|
10471
|
+
return bounds;
|
|
10472
|
+
}
|
|
10473
|
+
function addCalendarDays(dateYYYYMMDD, days) {
|
|
10474
|
+
const [year, month, day] = dateYYYYMMDD.split("-").map(Number);
|
|
10475
|
+
const shifted = new Date(Date.UTC(year, month - 1, day + days));
|
|
10476
|
+
return `${shifted.getUTCFullYear()}-${pad2(shifted.getUTCMonth() + 1)}-${pad2(shifted.getUTCDate())}`;
|
|
10477
|
+
}
|
|
10212
10478
|
function parseDateFieldValue(raw, fieldType, timezone) {
|
|
10213
10479
|
const str = raw instanceof Date ? Number.isNaN(raw.getTime()) ? "" : raw.toISOString() : typeof raw === "string" ? raw.trim() : "";
|
|
10214
10480
|
const expected = fieldType === "DATE" ? "expected a calendar date in YYYY-MM-DD format" : "expected an ISO-8601 date/time such as 2026-08-13T13:00:00+03:00";
|
|
@@ -10304,19 +10570,30 @@ function timezoneOffsetMs(utcMillis, timezone) {
|
|
|
10304
10570
|
const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
|
|
10305
10571
|
return asIfUtc - Math.floor(utcMillis / 1e3) * 1e3;
|
|
10306
10572
|
}
|
|
10307
|
-
function isCalendarDateAllowed(dateYYYYMMDD, config) {
|
|
10573
|
+
function isCalendarDateAllowed(dateYYYYMMDD, config, clock) {
|
|
10308
10574
|
if (!config) return true;
|
|
10309
10575
|
if (config.minDate && dateYYYYMMDD < config.minDate) return false;
|
|
10310
10576
|
if (config.maxDate && dateYYYYMMDD > config.maxDate) return false;
|
|
10577
|
+
if (relativeBoundFailure(dateYYYYMMDD, config, clock)) return false;
|
|
10311
10578
|
if (config.blockedDates?.includes(dateYYYYMMDD)) return false;
|
|
10312
10579
|
if (config.blockedWeekdays?.length) {
|
|
10313
10580
|
if (config.blockedWeekdays.includes(weekdayOfDateString(dateYYYYMMDD))) return false;
|
|
10314
10581
|
}
|
|
10315
10582
|
return true;
|
|
10316
10583
|
}
|
|
10317
|
-
function
|
|
10584
|
+
function relativeBoundFailure(dateYYYYMMDD, config, clock) {
|
|
10585
|
+
const bounds = resolveRelativeBounds(config, clock);
|
|
10586
|
+
if (bounds.earliestDate && dateYYYYMMDD < bounds.earliestDate) {
|
|
10587
|
+
return `the earliest available date is ${bounds.earliestDate}`;
|
|
10588
|
+
}
|
|
10589
|
+
if (bounds.latestDate && dateYYYYMMDD > bounds.latestDate) {
|
|
10590
|
+
return `the latest available date is ${bounds.latestDate}`;
|
|
10591
|
+
}
|
|
10592
|
+
return null;
|
|
10593
|
+
}
|
|
10594
|
+
function computeAvailableSlots(config, dateYYYYMMDD, clock) {
|
|
10318
10595
|
if (!config) return [];
|
|
10319
|
-
if (!isCalendarDateAllowed(dateYYYYMMDD, config)) return [];
|
|
10596
|
+
if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) return [];
|
|
10320
10597
|
if (!config.businessHours?.length || !config.slotDurationMinutes) return [];
|
|
10321
10598
|
const weekday = weekdayOfDateString(dateYYYYMMDD);
|
|
10322
10599
|
const windows = config.businessHours.filter((w) => w.weekday === weekday);
|
|
@@ -10329,27 +10606,47 @@ function computeAvailableSlots(config, dateYYYYMMDD) {
|
|
|
10329
10606
|
slots.push(minutesToHHMM(t));
|
|
10330
10607
|
}
|
|
10331
10608
|
}
|
|
10332
|
-
|
|
10609
|
+
const ordered = Array.from(new Set(slots)).sort();
|
|
10610
|
+
const { earliestInstant } = resolveRelativeBounds(config, clock);
|
|
10611
|
+
if (!earliestInstant || !clock) return ordered;
|
|
10612
|
+
return ordered.filter((hhmm) => {
|
|
10613
|
+
const [hour, minute] = hhmm.split(":").map(Number);
|
|
10614
|
+
const start = instantFromStoreLocal(dateYYYYMMDD, hour, minute, 0, 0, clock.timezone);
|
|
10615
|
+
return start.getTime() >= earliestInstant.getTime();
|
|
10616
|
+
});
|
|
10333
10617
|
}
|
|
10334
|
-
function getBusinessHoursForDate(config, dateYYYYMMDD) {
|
|
10618
|
+
function getBusinessHoursForDate(config, dateYYYYMMDD, clock) {
|
|
10335
10619
|
if (!config?.businessHours?.length) return [];
|
|
10336
|
-
if (!isCalendarDateAllowed(dateYYYYMMDD, config)) return [];
|
|
10620
|
+
if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) return [];
|
|
10337
10621
|
const weekday = weekdayOfDateString(dateYYYYMMDD);
|
|
10338
10622
|
return config.businessHours.filter((w) => w.weekday === weekday);
|
|
10339
10623
|
}
|
|
10340
|
-
function isDateValueAllowed(instant, config, fieldType, timezone) {
|
|
10624
|
+
function isDateValueAllowed(instant, config, fieldType, timezone, now) {
|
|
10341
10625
|
if (!config) return { allowed: true };
|
|
10626
|
+
const clock = { timezone, now };
|
|
10342
10627
|
if (fieldType === "DATE") {
|
|
10343
10628
|
const dateYYYYMMDD = `${instant.getUTCFullYear()}-${pad2(instant.getUTCMonth() + 1)}-${pad2(instant.getUTCDate())}`;
|
|
10344
|
-
|
|
10629
|
+
const relative2 = relativeBoundFailure(dateYYYYMMDD, config, clock);
|
|
10630
|
+
if (relative2) return { allowed: false, reason: relative2 };
|
|
10631
|
+
if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) {
|
|
10345
10632
|
return { allowed: false, reason: "date is outside the allowed range" };
|
|
10346
10633
|
}
|
|
10347
10634
|
return { allowed: true };
|
|
10348
10635
|
}
|
|
10349
10636
|
const local = resolveStoreLocalParts(instant, timezone);
|
|
10350
|
-
|
|
10637
|
+
const relative = relativeBoundFailure(local.dateYYYYMMDD, config, clock);
|
|
10638
|
+
if (relative) return { allowed: false, reason: relative };
|
|
10639
|
+
if (!isCalendarDateAllowed(local.dateYYYYMMDD, config, clock)) {
|
|
10351
10640
|
return { allowed: false, reason: "date is outside the allowed range" };
|
|
10352
10641
|
}
|
|
10642
|
+
const { earliestInstant } = resolveRelativeBounds(config, clock);
|
|
10643
|
+
if (earliestInstant && instant.getTime() < earliestInstant.getTime()) {
|
|
10644
|
+
const earliestLocal = resolveStoreLocalParts(earliestInstant, timezone);
|
|
10645
|
+
return {
|
|
10646
|
+
allowed: false,
|
|
10647
|
+
reason: `the earliest available time is ${earliestLocal.dateYYYYMMDD} ${earliestLocal.hhmm}`
|
|
10648
|
+
};
|
|
10649
|
+
}
|
|
10353
10650
|
if (!config.businessHours?.length) {
|
|
10354
10651
|
return { allowed: true };
|
|
10355
10652
|
}
|
|
@@ -10358,7 +10655,7 @@ function isDateValueAllowed(instant, config, fieldType, timezone) {
|
|
|
10358
10655
|
return { allowed: false, reason: "no business hours are configured for this day" };
|
|
10359
10656
|
}
|
|
10360
10657
|
if (config.slotDurationMinutes) {
|
|
10361
|
-
const slots = computeAvailableSlots(config, local.dateYYYYMMDD);
|
|
10658
|
+
const slots = computeAvailableSlots(config, local.dateYYYYMMDD, clock);
|
|
10362
10659
|
if (!slots.includes(local.hhmm)) {
|
|
10363
10660
|
return { allowed: false, reason: "time does not match an available slot" };
|
|
10364
10661
|
}
|
|
@@ -10989,6 +11286,7 @@ function isCouponApplicableToProduct(coupon, productId) {
|
|
|
10989
11286
|
jsonLdScriptProps,
|
|
10990
11287
|
parseDateFieldValue,
|
|
10991
11288
|
parseWebhookEvent,
|
|
11289
|
+
resolveRelativeBounds,
|
|
10992
11290
|
resolveStoreLocalParts,
|
|
10993
11291
|
safePaymentRedirect,
|
|
10994
11292
|
stripHtml,
|