brainerce 1.58.1 → 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/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.54.0";
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
  */
@@ -4205,7 +4294,9 @@ var _BrainerceClient = class _BrainerceClient {
4205
4294
  * try {
4206
4295
  * await client.createCheckout(cartId);
4207
4296
  * } catch (err) {
4208
- * if (err.code === 'PRICE_DRIFT') {
4297
+ * // BrainerceError.details is the whole response body — the code lives
4298
+ * // there, NOT on the error object itself.
4299
+ * if ((err as BrainerceError).details?.code === 'PRICE_DRIFT') {
4209
4300
  * // ask user to confirm new prices, then:
4210
4301
  * await client.refreshCartSnapshots(cartId);
4211
4302
  * await client.createCheckout(cartId);
@@ -4417,16 +4508,27 @@ var _BrainerceClient = class _BrainerceClient {
4417
4508
  * List visible reviews for a product (storefront / sales-channel modes).
4418
4509
  * Reviews that the merchant has hidden are excluded.
4419
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
+ *
4420
4518
  * @example
4421
4519
  * ```typescript
4422
4520
  * const { data, meta } = await client.listProductReviews('prod_123', { page: 1, limit: 20 });
4423
- * data.forEach(r => console.log(r.rating, r.body, r.verifiedPurchase));
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
+ * });
4424
4525
  * ```
4425
4526
  */
4426
4527
  async listProductReviews(productId, params) {
4427
4528
  const queryParams = {};
4428
4529
  if (params?.page) queryParams.page = params.page;
4429
4530
  if (params?.limit) queryParams.limit = params.limit;
4531
+ if (params?.sort) queryParams.sort = params.sort;
4430
4532
  if (this.isVibeCodedMode()) {
4431
4533
  return this.vibeCodedRequest(
4432
4534
  "GET",
@@ -4584,6 +4686,37 @@ var _BrainerceClient = class _BrainerceClient {
4584
4686
  storeId ? { storeId } : void 0
4585
4687
  );
4586
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
+ }
4587
4720
  /** Admin: unhide a previously hidden review. */
4588
4721
  async showProductReview(reviewId, storeId) {
4589
4722
  if (!this.apiKey) {
@@ -6043,6 +6176,9 @@ var _BrainerceClient = class _BrainerceClient {
6043
6176
  * Get applicable custom field definitions for a checkout.
6044
6177
  * Returns fields filtered by visibility conditions (delivery type, products in cart).
6045
6178
  * Use these to render dynamic input fields in the checkout flow.
6179
+ *
6180
+ * **Vibe-coded or storefront mode only.** There is no checkout custom-field
6181
+ * route on the API-key `/v1` surface; in admin mode this throws.
6046
6182
  */
6047
6183
  async getCheckoutCustomFields(checkoutId) {
6048
6184
  if (this.isVibeCodedMode()) {
@@ -6057,15 +6193,18 @@ var _BrainerceClient = class _BrainerceClient {
6057
6193
  `/checkout/${encodePathSegment(checkoutId)}/custom-fields`
6058
6194
  );
6059
6195
  }
6060
- return this.adminRequest(
6061
- "GET",
6062
- `/api/v1/checkouts/${encodePathSegment(checkoutId)}/custom-fields`
6196
+ throw new BrainerceError(
6197
+ "getCheckoutCustomFields is only available in vibe-coded or storefront mode",
6198
+ 400
6063
6199
  );
6064
6200
  }
6065
6201
  /**
6066
6202
  * Set checkout custom field values and recalculate surcharges.
6067
6203
  * The checkout total is automatically updated to include surcharges.
6068
6204
  *
6205
+ * **Vibe-coded or storefront mode only.** There is no checkout custom-field
6206
+ * route on the API-key `/v1` surface; in admin mode this throws.
6207
+ *
6069
6208
  * @example
6070
6209
  * ```typescript
6071
6210
  * const checkout = await client.setCheckoutCustomFields(checkoutId, {
@@ -6093,10 +6232,9 @@ var _BrainerceClient = class _BrainerceClient {
6093
6232
  data
6094
6233
  );
6095
6234
  }
6096
- return this.adminRequest(
6097
- "PATCH",
6098
- `/api/v1/checkouts/${encodePathSegment(checkoutId)}/custom-fields`,
6099
- data
6235
+ throw new BrainerceError(
6236
+ "setCheckoutCustomFields is only available in vibe-coded or storefront mode",
6237
+ 400
6100
6238
  );
6101
6239
  }
6102
6240
  /**
@@ -7234,11 +7372,20 @@ var _BrainerceClient = class _BrainerceClient {
7234
7372
  );
7235
7373
  }
7236
7374
  /**
7237
- * Update the current customer's profile (requires customerToken)
7238
- * Only available in storefront mode
7375
+ * Update the current customer's profile (requires customerToken).
7376
+ * Only available in storefront and vibe-coded mode.
7239
7377
  *
7240
- * `birthMonth`/`birthDay` (1-12 / 1-31, no year — privacy) power the loyalty
7241
- * birthday gift; they must be provided together.
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.
7242
7389
  */
7243
7390
  async updateMyProfile(data) {
7244
7391
  if (!this.customerToken && !this.proxyMode) {
@@ -8362,8 +8509,9 @@ var _BrainerceClient = class _BrainerceClient {
8362
8509
  // /api/stores/:storeId/products/:productId/modifier-groups[/:attachmentId]
8363
8510
  //
8364
8511
  // Server-side validation failures arrive as a structured 400 envelope:
8365
- // { code: 'MODIFIER_VALIDATION_FAILED', errors: ModifierValidationError[] }
8366
- // surfaced via BrainerceError.details.
8512
+ // { code: 'MODIFIER_VALIDATION_FAILED', message, details: { errors: [...] } }
8513
+ // BrainerceError.details holds the WHOLE body, so the issue list is
8514
+ // `err.details.details.errors` — see ModifierValidationFailedError.
8367
8515
  /**
8368
8516
  * List modifier groups in a store, paginated.
8369
8517
  * Requires Admin mode (apiKey).
@@ -9414,28 +9562,99 @@ var _BrainerceClient = class _BrainerceClient {
9414
9562
  }
9415
9563
  throw new Error("uploadCustomizationFile requires storefront or vibe-coded mode");
9416
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
+ }
9417
9627
  // -------------------- Team Management (Admin) - DEPRECATED --------------------
9418
- // These account-level methods are deprecated. Use Store Team Management methods instead.
9628
+ // Account-level team methods. These are the ONLY team endpoints reachable with
9629
+ // a `brainerce_*` API key. The store-level equivalents (`getStoreTeam`,
9630
+ // `inviteStoreMember`, `updateStoreMember`, ...) sit behind
9631
+ // `DashboardOnlyGuard`, which rejects api_key principals by design — so they
9632
+ // return 403 from the SDK, not 404, and "fixing" their path would not help.
9633
+ // Do not migrate SDK code onto them.
9419
9634
  /**
9420
- * @deprecated Use `getStoreTeam(storeId)` instead.
9635
+ * @deprecated Retiring, but there is no API-key replacement yet: `getStoreTeam`
9636
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9421
9637
  */
9422
9638
  async getTeamMembers() {
9423
9639
  return this.adminRequest("GET", "/api/v1/team/members");
9424
9640
  }
9425
9641
  /**
9426
- * @deprecated Use `getStoreTeam(storeId)` instead.
9642
+ * @deprecated Retiring, but there is no API-key replacement yet: `getStoreTeam`
9643
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9427
9644
  */
9428
9645
  async getTeamInvitations() {
9429
9646
  return this.adminRequest("GET", "/api/v1/team/invitations");
9430
9647
  }
9431
9648
  /**
9432
- * @deprecated Use `inviteStoreMember(storeId, data)` instead.
9649
+ * @deprecated Retiring, but there is no API-key replacement yet: `inviteStoreMember`
9650
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9433
9651
  */
9434
9652
  async inviteTeamMember(data) {
9435
9653
  return this.adminRequest("POST", "/api/v1/team/invitations", data);
9436
9654
  }
9437
9655
  /**
9438
- * @deprecated Use `resendStoreInvitation(storeId, invitationId)` instead.
9656
+ * @deprecated Retiring, but there is no API-key replacement yet: `resendStoreInvitation`
9657
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9439
9658
  */
9440
9659
  async resendTeamInvitation(invitationId) {
9441
9660
  return this.adminRequest(
@@ -9444,7 +9663,8 @@ var _BrainerceClient = class _BrainerceClient {
9444
9663
  );
9445
9664
  }
9446
9665
  /**
9447
- * @deprecated Use `revokeStoreInvitation(storeId, invitationId)` instead.
9666
+ * @deprecated Retiring, but there is no API-key replacement yet: `revokeStoreInvitation`
9667
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9448
9668
  */
9449
9669
  async revokeTeamInvitation(invitationId) {
9450
9670
  await this.adminRequest(
@@ -9453,7 +9673,8 @@ var _BrainerceClient = class _BrainerceClient {
9453
9673
  );
9454
9674
  }
9455
9675
  /**
9456
- * @deprecated Use `updateStoreMember(storeId, memberId, data)` instead.
9676
+ * @deprecated Retiring, but there is no API-key replacement yet: `updateStoreMember`
9677
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9457
9678
  */
9458
9679
  async updateTeamMemberRole(memberId, data) {
9459
9680
  return this.adminRequest(
@@ -9463,7 +9684,8 @@ var _BrainerceClient = class _BrainerceClient {
9463
9684
  );
9464
9685
  }
9465
9686
  /**
9466
- * @deprecated Use `removeStoreMember(storeId, memberId)` instead.
9687
+ * @deprecated Retiring, but there is no API-key replacement yet: `removeStoreMember`
9688
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9467
9689
  */
9468
9690
  async removeTeamMember(memberId) {
9469
9691
  await this.adminRequest("DELETE", `/api/v1/team/members/${encodePathSegment(memberId)}`);
@@ -10089,7 +10311,7 @@ var DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\
10089
10311
  var MIN_OFFSET_MINUTES = -12 * 60;
10090
10312
  var MAX_OFFSET_MINUTES = 14 * 60;
10091
10313
  var VALID_OFFSET_MINUTE_PARTS = [0, 30, 45];
10092
- function validateDateAvailabilityConfig(config, fieldType) {
10314
+ function validateDateAvailabilityConfig(config, fieldType, surface = "checkout") {
10093
10315
  const errors = [];
10094
10316
  if (!config) return errors;
10095
10317
  if (config.minDate !== void 0 && !DATE_RE.test(config.minDate)) {
@@ -10101,6 +10323,29 @@ function validateDateAvailabilityConfig(config, fieldType) {
10101
10323
  if (config.minDate && config.maxDate && DATE_RE.test(config.minDate) && DATE_RE.test(config.maxDate) && config.minDate > config.maxDate) {
10102
10324
  errors.push("minDate must be on or before maxDate");
10103
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
+ }
10104
10349
  if (config.blockedWeekdays) {
10105
10350
  for (const d of config.blockedWeekdays) {
10106
10351
  if (!Number.isInteger(d) || d < 0 || d > 6) {
@@ -10118,22 +10363,37 @@ function validateDateAvailabilityConfig(config, fieldType) {
10118
10363
  }
10119
10364
  }
10120
10365
  if (config.businessHours) {
10121
- const seenWeekdays = /* @__PURE__ */ new Set();
10366
+ const windowsByWeekday = /* @__PURE__ */ new Map();
10122
10367
  for (const w of config.businessHours) {
10123
10368
  if (!Number.isInteger(w.weekday) || w.weekday < 0 || w.weekday > 6) {
10124
10369
  errors.push(`businessHours.weekday must be an integer 0-6, got ${JSON.stringify(w.weekday)}`);
10125
10370
  continue;
10126
10371
  }
10127
- if (seenWeekdays.has(w.weekday)) {
10128
- errors.push(`businessHours has more than one window for weekday ${w.weekday}`);
10129
- }
10130
- seenWeekdays.add(w.weekday);
10131
10372
  const openValid = TIME_RE.test(w.open);
10132
10373
  const closeValid = TIME_RE.test(w.close);
10133
10374
  if (!openValid) errors.push(`businessHours.open must be HH:mm, got "${w.open}"`);
10134
10375
  if (!closeValid) errors.push(`businessHours.close must be HH:mm, got "${w.close}"`);
10135
- if (openValid && closeValid && w.open >= w.close) {
10376
+ if (!openValid || !closeValid) continue;
10377
+ if (w.open >= w.close) {
10136
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
+ }
10137
10397
  }
10138
10398
  }
10139
10399
  }
@@ -10189,6 +10449,32 @@ function resolveStoreLocalParts(instant, timezone) {
10189
10449
  weekday: WEEKDAY_TO_INDEX[get("weekday")] ?? instant.getUTCDay()
10190
10450
  };
10191
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
+ }
10192
10478
  function parseDateFieldValue(raw, fieldType, timezone) {
10193
10479
  const str = raw instanceof Date ? Number.isNaN(raw.getTime()) ? "" : raw.toISOString() : typeof raw === "string" ? raw.trim() : "";
10194
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";
@@ -10284,19 +10570,30 @@ function timezoneOffsetMs(utcMillis, timezone) {
10284
10570
  const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
10285
10571
  return asIfUtc - Math.floor(utcMillis / 1e3) * 1e3;
10286
10572
  }
10287
- function isCalendarDateAllowed(dateYYYYMMDD, config) {
10573
+ function isCalendarDateAllowed(dateYYYYMMDD, config, clock) {
10288
10574
  if (!config) return true;
10289
10575
  if (config.minDate && dateYYYYMMDD < config.minDate) return false;
10290
10576
  if (config.maxDate && dateYYYYMMDD > config.maxDate) return false;
10577
+ if (relativeBoundFailure(dateYYYYMMDD, config, clock)) return false;
10291
10578
  if (config.blockedDates?.includes(dateYYYYMMDD)) return false;
10292
10579
  if (config.blockedWeekdays?.length) {
10293
10580
  if (config.blockedWeekdays.includes(weekdayOfDateString(dateYYYYMMDD))) return false;
10294
10581
  }
10295
10582
  return true;
10296
10583
  }
10297
- function computeAvailableSlots(config, dateYYYYMMDD) {
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) {
10298
10595
  if (!config) return [];
10299
- if (!isCalendarDateAllowed(dateYYYYMMDD, config)) return [];
10596
+ if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) return [];
10300
10597
  if (!config.businessHours?.length || !config.slotDurationMinutes) return [];
10301
10598
  const weekday = weekdayOfDateString(dateYYYYMMDD);
10302
10599
  const windows = config.businessHours.filter((w) => w.weekday === weekday);
@@ -10309,27 +10606,47 @@ function computeAvailableSlots(config, dateYYYYMMDD) {
10309
10606
  slots.push(minutesToHHMM(t));
10310
10607
  }
10311
10608
  }
10312
- return slots;
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
+ });
10313
10617
  }
10314
- function getBusinessHoursForDate(config, dateYYYYMMDD) {
10618
+ function getBusinessHoursForDate(config, dateYYYYMMDD, clock) {
10315
10619
  if (!config?.businessHours?.length) return [];
10316
- if (!isCalendarDateAllowed(dateYYYYMMDD, config)) return [];
10620
+ if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) return [];
10317
10621
  const weekday = weekdayOfDateString(dateYYYYMMDD);
10318
10622
  return config.businessHours.filter((w) => w.weekday === weekday);
10319
10623
  }
10320
- function isDateValueAllowed(instant, config, fieldType, timezone) {
10624
+ function isDateValueAllowed(instant, config, fieldType, timezone, now) {
10321
10625
  if (!config) return { allowed: true };
10626
+ const clock = { timezone, now };
10322
10627
  if (fieldType === "DATE") {
10323
10628
  const dateYYYYMMDD = `${instant.getUTCFullYear()}-${pad2(instant.getUTCMonth() + 1)}-${pad2(instant.getUTCDate())}`;
10324
- if (!isCalendarDateAllowed(dateYYYYMMDD, config)) {
10629
+ const relative2 = relativeBoundFailure(dateYYYYMMDD, config, clock);
10630
+ if (relative2) return { allowed: false, reason: relative2 };
10631
+ if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) {
10325
10632
  return { allowed: false, reason: "date is outside the allowed range" };
10326
10633
  }
10327
10634
  return { allowed: true };
10328
10635
  }
10329
10636
  const local = resolveStoreLocalParts(instant, timezone);
10330
- if (!isCalendarDateAllowed(local.dateYYYYMMDD, config)) {
10637
+ const relative = relativeBoundFailure(local.dateYYYYMMDD, config, clock);
10638
+ if (relative) return { allowed: false, reason: relative };
10639
+ if (!isCalendarDateAllowed(local.dateYYYYMMDD, config, clock)) {
10331
10640
  return { allowed: false, reason: "date is outside the allowed range" };
10332
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
+ }
10333
10650
  if (!config.businessHours?.length) {
10334
10651
  return { allowed: true };
10335
10652
  }
@@ -10338,7 +10655,7 @@ function isDateValueAllowed(instant, config, fieldType, timezone) {
10338
10655
  return { allowed: false, reason: "no business hours are configured for this day" };
10339
10656
  }
10340
10657
  if (config.slotDurationMinutes) {
10341
- const slots = computeAvailableSlots(config, local.dateYYYYMMDD);
10658
+ const slots = computeAvailableSlots(config, local.dateYYYYMMDD, clock);
10342
10659
  if (!slots.includes(local.hhmm)) {
10343
10660
  return { allowed: false, reason: "time does not match an available slot" };
10344
10661
  }
@@ -10969,6 +11286,7 @@ function isCouponApplicableToProduct(coupon, productId) {
10969
11286
  jsonLdScriptProps,
10970
11287
  parseDateFieldValue,
10971
11288
  parseWebhookEvent,
11289
+ resolveRelativeBounds,
10972
11290
  resolveStoreLocalParts,
10973
11291
  safePaymentRedirect,
10974
11292
  stripHtml,