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/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
  */
@@ -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 => 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
+ * });
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, no year — privacy) power the loyalty
7248
- * 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.
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 seenWeekdays = /* @__PURE__ */ new Set();
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 && closeValid && w.open >= w.close) {
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 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) {
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
- 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
+ });
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
- 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)) {
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
- 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)) {
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,