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.mjs CHANGED
@@ -115,7 +115,7 @@ function isDevGuardsEnabled() {
115
115
  }
116
116
 
117
117
  // src/version.ts
118
- var SDK_VERSION = "1.54.0";
118
+ var SDK_VERSION = "1.60.0";
119
119
 
120
120
  // src/client.ts
121
121
  var DEFAULT_BASE_URL = "https://api.brainerce.com";
@@ -272,6 +272,87 @@ var _BrainerceClient = class _BrainerceClient {
272
272
  );
273
273
  }
274
274
  };
275
+ // -------------------- Marketing signup (newsletter) --------------------
276
+ /**
277
+ * Email marketing signup for a storefront — a newsletter popup, a footer
278
+ * capture bar, an exit-intent modal.
279
+ *
280
+ * **Confirmed opt-in, always.** `subscribe()` creates the contact and mails
281
+ * them a confirmation link. The address is NOT subscribed and CANNOT receive
282
+ * a campaign until the recipient clicks that link. This is not a setting:
283
+ * consent has to come from the mailbox, or anyone could subscribe anyone.
284
+ *
285
+ * So do not render "You're subscribed!" on success — render "Check your
286
+ * email to confirm." The one is a lie until the click lands.
287
+ *
288
+ * The response is identical for a brand-new address, one that is already
289
+ * subscribed, and one suppressed after a bounce, so the form can't be used to
290
+ * probe who shops here. Show the same message for every success.
291
+ *
292
+ * Storefront (public) and vibe-coded modes only. Rate-limited server-side to
293
+ * 3 requests / 60s per IP, plus one confirmation email per address per 24h.
294
+ *
295
+ * **Where the discount goes.** A "10% off your first order" popup needs a
296
+ * coupon from the dashboard — create one with the `customer_first_order`
297
+ * condition and show the code after a successful call. Subscribing does not
298
+ * mint a code on its own.
299
+ *
300
+ * @example
301
+ * ```typescript
302
+ * // Newsletter popup — hidden honeypot input, Hebrew storefront
303
+ * await brainerce.marketing.subscribe({
304
+ * email: 'jane@example.com',
305
+ * locale: 'he',
306
+ * source: 'popup',
307
+ * honeypot: hiddenFieldValue,
308
+ * });
309
+ * // → show "בדקו את המייל שלכם כדי לאשר" — NOT "נרשמת בהצלחה"
310
+ * ```
311
+ */
312
+ this.marketing = {
313
+ subscribe: async (input) => {
314
+ if (this.isVibeCodedMode()) {
315
+ return this.vibeCodedRequest(
316
+ "POST",
317
+ "/marketing/subscribe",
318
+ input
319
+ );
320
+ }
321
+ return this.storefrontRequest(
322
+ "POST",
323
+ "/marketing/subscribe",
324
+ input
325
+ );
326
+ }
327
+ };
328
+ // -------------------- Stock alerts --------------------
329
+ /**
330
+ * "Email me when this is back."
331
+ *
332
+ * ⛔ Not a newsletter signup, and must not be worded as one. It grants no
333
+ * marketing consent, creates no customer account, and the person is never
334
+ * mailed anything else as a result — exactly one message, about this item,
335
+ * with a link that stops it. Someone who unsubscribed from marketing can
336
+ * still use this, so do not gate it on consent.
337
+ *
338
+ * Show the affordance only on an item that is out of stock AND cannot be
339
+ * backordered. Every other case is silently ignored server-side — the
340
+ * response is uniform on purpose, so it cannot be used to read stock levels
341
+ * or test who is a customer — which means a button on an in-stock item looks
342
+ * like it worked and does nothing.
343
+ *
344
+ * Pass `variantId` on any product with variants. Without it the alert waits
345
+ * on the product as a whole, and a shopper who wanted the medium hears when
346
+ * the small comes back.
347
+ */
348
+ this.stockAlerts = {
349
+ subscribe: async (input) => {
350
+ if (this.isVibeCodedMode()) {
351
+ return this.vibeCodedRequest("POST", "/stock-alerts", input);
352
+ }
353
+ return this.storefrontRequest("POST", "/stock-alerts", input);
354
+ }
355
+ };
275
356
  // -------------------- Content (typed merchant content) --------------------
276
357
  /**
277
358
  * Typed merchant content store: FAQ, Footer, Header, Announcement,
@@ -3046,6 +3127,11 @@ var _BrainerceClient = class _BrainerceClient {
3046
3127
  * Register a new customer with password (creates account)
3047
3128
  * Works in vibe-coded, storefront, and admin mode
3048
3129
  *
3130
+ * `birthMonth`/`birthDay` are optional and must be sent together. When
3131
+ * `getStoreInfo().requireBirthday` is true the merchant made the birthday
3132
+ * mandatory on that sales channel, and a call without both fields is
3133
+ * rejected with HTTP 400.
3134
+ *
3049
3135
  * @example
3050
3136
  * ```typescript
3051
3137
  * const auth = await client.registerCustomer({
@@ -3053,6 +3139,8 @@ var _BrainerceClient = class _BrainerceClient {
3053
3139
  * password: 'securepassword123',
3054
3140
  * firstName: 'Jane',
3055
3141
  * lastName: 'Doe',
3142
+ * birthMonth: 4, // optional, unless the channel requires a birthday
3143
+ * birthDay: 17,
3056
3144
  * });
3057
3145
  * ```
3058
3146
  */
@@ -4331,16 +4419,27 @@ var _BrainerceClient = class _BrainerceClient {
4331
4419
  * List visible reviews for a product (storefront / sales-channel modes).
4332
4420
  * Reviews that the merchant has hidden are excluded.
4333
4421
  *
4422
+ * Each review carries `images` — the photos its author attached, already
4423
+ * filtered to the ones shoppers are allowed to see. Always an array.
4424
+ *
4425
+ * Ordering defaults to `photos_first`: reviews carrying photos lead, newest-first
4426
+ * within each group. Pass `sort: 'newest'` for plain chronological order. On a
4427
+ * store with no review photos the two are identical.
4428
+ *
4334
4429
  * @example
4335
4430
  * ```typescript
4336
4431
  * const { data, meta } = await client.listProductReviews('prod_123', { page: 1, limit: 20 });
4337
- * data.forEach(r => console.log(r.rating, r.body, r.verifiedPurchase));
4432
+ * data.forEach(r => {
4433
+ * console.log(r.rating, r.body, r.verifiedPurchase);
4434
+ * r.images.forEach(img => console.log(img.thumbnailUrl ?? img.url));
4435
+ * });
4338
4436
  * ```
4339
4437
  */
4340
4438
  async listProductReviews(productId, params) {
4341
4439
  const queryParams = {};
4342
4440
  if (params?.page) queryParams.page = params.page;
4343
4441
  if (params?.limit) queryParams.limit = params.limit;
4442
+ if (params?.sort) queryParams.sort = params.sort;
4344
4443
  if (this.isVibeCodedMode()) {
4345
4444
  return this.vibeCodedRequest(
4346
4445
  "GET",
@@ -4498,6 +4597,37 @@ var _BrainerceClient = class _BrainerceClient {
4498
4597
  storeId ? { storeId } : void 0
4499
4598
  );
4500
4599
  }
4600
+ /**
4601
+ * Admin: hide ONE photo on a review, leaving the review and its other photos
4602
+ * visible. Requires an API key with `reviews:write`.
4603
+ */
4604
+ async hideProductReviewImage(imageId, storeId) {
4605
+ if (!this.apiKey) {
4606
+ throw new BrainerceError("hideProductReviewImage() requires admin (API key) mode", 400);
4607
+ }
4608
+ return this.adminRequest(
4609
+ "PATCH",
4610
+ `/api/v1/review-images/${encodePathSegment(imageId)}/hide`,
4611
+ void 0,
4612
+ storeId ? { storeId } : void 0
4613
+ );
4614
+ }
4615
+ /**
4616
+ * Admin: show one review photo. This is also the approve action — a photo that
4617
+ * has never been approved carries no `approvedAt`, and showing it stamps one, so
4618
+ * stores using review-photo approval need no separate verb.
4619
+ */
4620
+ async showProductReviewImage(imageId, storeId) {
4621
+ if (!this.apiKey) {
4622
+ throw new BrainerceError("showProductReviewImage() requires admin (API key) mode", 400);
4623
+ }
4624
+ return this.adminRequest(
4625
+ "PATCH",
4626
+ `/api/v1/review-images/${encodePathSegment(imageId)}/show`,
4627
+ void 0,
4628
+ storeId ? { storeId } : void 0
4629
+ );
4630
+ }
4501
4631
  /** Admin: unhide a previously hidden review. */
4502
4632
  async showProductReview(reviewId, storeId) {
4503
4633
  if (!this.apiKey) {
@@ -7153,11 +7283,20 @@ var _BrainerceClient = class _BrainerceClient {
7153
7283
  );
7154
7284
  }
7155
7285
  /**
7156
- * Update the current customer's profile (requires customerToken)
7157
- * Only available in storefront mode
7286
+ * Update the current customer's profile (requires customerToken).
7287
+ * Only available in storefront and vibe-coded mode.
7158
7288
  *
7159
- * `birthMonth`/`birthDay` (1-12 / 1-31, no year — privacy) power the loyalty
7160
- * birthday gift; they must be provided together.
7289
+ * `birthMonth`/`birthDay` (1-12 / 1-31) power the loyalty birthday gift.
7290
+ * Month and day only, never a year, for privacy. Send both or neither, and
7291
+ * the day has to exist in the month: anything else is rejected with HTTP 400.
7292
+ * Send `null` for both to REMOVE a stored birthday. Leaving the keys out is a
7293
+ * different request and keeps the stored value, so a profile form whose
7294
+ * fields the shopper cleared has to send nulls, not omit them.
7295
+ *
7296
+ * The saved values now come back on the returned `CustomerProfile`, on
7297
+ * `getMyProfile()` and on every customer read type, so a profile form can
7298
+ * re-render the birthday it just saved. Before this, no API returned the two
7299
+ * fields at all and the form always came back blank.
7161
7300
  */
7162
7301
  async updateMyProfile(data) {
7163
7302
  if (!this.customerToken && !this.proxyMode) {
@@ -9334,6 +9473,68 @@ var _BrainerceClient = class _BrainerceClient {
9334
9473
  }
9335
9474
  throw new Error("uploadCustomizationFile requires storefront or vibe-coded mode");
9336
9475
  }
9476
+ /**
9477
+ * Upload one photo to attach to a product review.
9478
+ *
9479
+ * Available in storefront and vibe-coded modes, and — unlike
9480
+ * `uploadCustomizationFile` — it REQUIRES a logged-in customer who has actually
9481
+ * bought the product. Call `setCustomerToken(...)` first. That is the same bar as
9482
+ * writing the review itself, checked here so an ineligible shopper is told before
9483
+ * they wait for the upload rather than after.
9484
+ *
9485
+ * Returns a storage `key`. Collect the keys and pass them as `imageKeys` when you
9486
+ * submit or update the review — the `url` is for a local preview only, and sending
9487
+ * it back instead of the key will be rejected.
9488
+ *
9489
+ * Server rules:
9490
+ * - `image/jpeg|png|webp|gif` only, cross-checked against the file's real bytes.
9491
+ * - Max 5 MB and 40 megapixels per file.
9492
+ * - Throttled to 10 uploads / minute → HTTP 429.
9493
+ * - 403 when the store has review photos turned off, or the customer has not
9494
+ * bought the product; 400 once the review is already at its photo cap.
9495
+ * - EXIF is stripped (so GPS coordinates never reach the storefront) while the
9496
+ * orientation tag is applied first, so phone photos stay upright.
9497
+ * - A photo uploaded but never attached to a submitted review is reclaimed after
9498
+ * 7 days.
9499
+ *
9500
+ * Read `photos` from `getMyProductReview()` for the store's live limits rather
9501
+ * than hard-coding them.
9502
+ *
9503
+ * @example
9504
+ * ```ts
9505
+ * const { photos } = await client.getMyProductReview(productId);
9506
+ * if (photos.enabled) {
9507
+ * const uploads = await Promise.all(
9508
+ * [...fileInput.files].slice(0, photos.maxPerReview)
9509
+ * .map(f => client.uploadReviewPhoto(productId, f))
9510
+ * );
9511
+ * await client.submitProductReview(productId, {
9512
+ * rating: 5,
9513
+ * body: 'Arrived beautifully wrapped.',
9514
+ * imageKeys: uploads.map(u => u.key),
9515
+ * });
9516
+ * }
9517
+ * ```
9518
+ */
9519
+ async uploadReviewPhoto(productId, file) {
9520
+ const formData = new FormData();
9521
+ formData.append("file", file);
9522
+ if (this.isVibeCodedMode()) {
9523
+ return this.vibeCodedRequest(
9524
+ "POST",
9525
+ `/products/${encodePathSegment(productId)}/review-photo`,
9526
+ formData
9527
+ );
9528
+ }
9529
+ if (this.storeId && !this.apiKey) {
9530
+ return this.storefrontRequest(
9531
+ "POST",
9532
+ `/products/${encodePathSegment(productId)}/review-photo`,
9533
+ formData
9534
+ );
9535
+ }
9536
+ throw new Error("uploadReviewPhoto requires storefront or vibe-coded mode");
9537
+ }
9337
9538
  // -------------------- Team Management (Admin) - DEPRECATED --------------------
9338
9539
  // Account-level team methods. These are the ONLY team endpoints reachable with
9339
9540
  // a `brainerce_*` API key. The store-level equivalents (`getStoreTeam`,
@@ -10021,7 +10222,7 @@ var DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\
10021
10222
  var MIN_OFFSET_MINUTES = -12 * 60;
10022
10223
  var MAX_OFFSET_MINUTES = 14 * 60;
10023
10224
  var VALID_OFFSET_MINUTE_PARTS = [0, 30, 45];
10024
- function validateDateAvailabilityConfig(config, fieldType) {
10225
+ function validateDateAvailabilityConfig(config, fieldType, surface = "checkout") {
10025
10226
  const errors = [];
10026
10227
  if (!config) return errors;
10027
10228
  if (config.minDate !== void 0 && !DATE_RE.test(config.minDate)) {
@@ -10033,6 +10234,29 @@ function validateDateAvailabilityConfig(config, fieldType) {
10033
10234
  if (config.minDate && config.maxDate && DATE_RE.test(config.minDate) && DATE_RE.test(config.maxDate) && config.minDate > config.maxDate) {
10034
10235
  errors.push("minDate must be on or before maxDate");
10035
10236
  }
10237
+ if (config.leadTimeMinutes !== void 0) {
10238
+ if (!Number.isInteger(config.leadTimeMinutes) || config.leadTimeMinutes < 0) {
10239
+ errors.push("leadTimeMinutes must be a non-negative integer");
10240
+ }
10241
+ }
10242
+ if (config.maxDaysAhead !== void 0) {
10243
+ if (!Number.isInteger(config.maxDaysAhead) || config.maxDaysAhead < 1) {
10244
+ errors.push("maxDaysAhead must be a positive integer");
10245
+ }
10246
+ }
10247
+ if (config.cutoffTime !== void 0 && !TIME_RE.test(config.cutoffTime)) {
10248
+ errors.push("cutoffTime must be in HH:mm format");
10249
+ }
10250
+ if (surface !== "checkout") {
10251
+ const relativeKeys = ["leadTimeMinutes", "cutoffTime", "maxDaysAhead"].filter(
10252
+ (k) => config[k] !== void 0
10253
+ );
10254
+ if (relativeKeys.length > 0) {
10255
+ errors.push(
10256
+ `${relativeKeys.join("/")} only apply to checkout fields, not ${surface} fields`
10257
+ );
10258
+ }
10259
+ }
10036
10260
  if (config.blockedWeekdays) {
10037
10261
  for (const d of config.blockedWeekdays) {
10038
10262
  if (!Number.isInteger(d) || d < 0 || d > 6) {
@@ -10050,22 +10274,37 @@ function validateDateAvailabilityConfig(config, fieldType) {
10050
10274
  }
10051
10275
  }
10052
10276
  if (config.businessHours) {
10053
- const seenWeekdays = /* @__PURE__ */ new Set();
10277
+ const windowsByWeekday = /* @__PURE__ */ new Map();
10054
10278
  for (const w of config.businessHours) {
10055
10279
  if (!Number.isInteger(w.weekday) || w.weekday < 0 || w.weekday > 6) {
10056
10280
  errors.push(`businessHours.weekday must be an integer 0-6, got ${JSON.stringify(w.weekday)}`);
10057
10281
  continue;
10058
10282
  }
10059
- if (seenWeekdays.has(w.weekday)) {
10060
- errors.push(`businessHours has more than one window for weekday ${w.weekday}`);
10061
- }
10062
- seenWeekdays.add(w.weekday);
10063
10283
  const openValid = TIME_RE.test(w.open);
10064
10284
  const closeValid = TIME_RE.test(w.close);
10065
10285
  if (!openValid) errors.push(`businessHours.open must be HH:mm, got "${w.open}"`);
10066
10286
  if (!closeValid) errors.push(`businessHours.close must be HH:mm, got "${w.close}"`);
10067
- if (openValid && closeValid && w.open >= w.close) {
10287
+ if (!openValid || !closeValid) continue;
10288
+ if (w.open >= w.close) {
10068
10289
  errors.push(`businessHours for weekday ${w.weekday}: open must be before close`);
10290
+ continue;
10291
+ }
10292
+ const forDay = windowsByWeekday.get(w.weekday);
10293
+ if (forDay) forDay.push(w);
10294
+ else windowsByWeekday.set(w.weekday, [w]);
10295
+ }
10296
+ const weekdaysWithWindows = Array.from(windowsByWeekday.keys()).sort((a, b) => a - b);
10297
+ for (const weekday of weekdaysWithWindows) {
10298
+ const sorted = [...windowsByWeekday.get(weekday) ?? []].sort(
10299
+ (a, b) => a.open < b.open ? -1 : a.open > b.open ? 1 : 0
10300
+ );
10301
+ for (let i = 1; i < sorted.length; i++) {
10302
+ if (sorted[i].open < sorted[i - 1].close) {
10303
+ errors.push(
10304
+ `businessHours for weekday ${weekday}: ${sorted[i - 1].open}-${sorted[i - 1].close} overlaps ${sorted[i].open}-${sorted[i].close}`
10305
+ );
10306
+ break;
10307
+ }
10069
10308
  }
10070
10309
  }
10071
10310
  }
@@ -10121,6 +10360,32 @@ function resolveStoreLocalParts(instant, timezone) {
10121
10360
  weekday: WEEKDAY_TO_INDEX[get("weekday")] ?? instant.getUTCDay()
10122
10361
  };
10123
10362
  }
10363
+ function resolveRelativeBounds(config, clock) {
10364
+ if (!config || !clock) return {};
10365
+ const leadMinutes = typeof config.leadTimeMinutes === "number" && Number.isFinite(config.leadTimeMinutes) ? Math.max(0, Math.floor(config.leadTimeMinutes)) : 0;
10366
+ const cutoff = typeof config.cutoffTime === "string" && TIME_RE.test(config.cutoffTime) ? config.cutoffTime : null;
10367
+ const daysAhead = typeof config.maxDaysAhead === "number" && Number.isFinite(config.maxDaysAhead) && config.maxDaysAhead >= 1 ? Math.floor(config.maxDaysAhead) : null;
10368
+ if (leadMinutes === 0 && !cutoff && daysAhead === null) return {};
10369
+ const now = clock.now ?? /* @__PURE__ */ new Date();
10370
+ const nowLocal = resolveStoreLocalParts(now, clock.timezone);
10371
+ const bounds = {};
10372
+ if (leadMinutes > 0 || cutoff) {
10373
+ const earliestInstant = new Date(now.getTime() + leadMinutes * 6e4);
10374
+ let earliestDate = resolveStoreLocalParts(earliestInstant, clock.timezone).dateYYYYMMDD;
10375
+ if (cutoff && nowLocal.hhmm >= cutoff) earliestDate = addCalendarDays(earliestDate, 1);
10376
+ bounds.earliestDate = earliestDate;
10377
+ if (leadMinutes > 0) bounds.earliestInstant = earliestInstant;
10378
+ }
10379
+ if (daysAhead !== null) {
10380
+ bounds.latestDate = addCalendarDays(nowLocal.dateYYYYMMDD, daysAhead);
10381
+ }
10382
+ return bounds;
10383
+ }
10384
+ function addCalendarDays(dateYYYYMMDD, days) {
10385
+ const [year, month, day] = dateYYYYMMDD.split("-").map(Number);
10386
+ const shifted = new Date(Date.UTC(year, month - 1, day + days));
10387
+ return `${shifted.getUTCFullYear()}-${pad2(shifted.getUTCMonth() + 1)}-${pad2(shifted.getUTCDate())}`;
10388
+ }
10124
10389
  function parseDateFieldValue(raw, fieldType, timezone) {
10125
10390
  const str = raw instanceof Date ? Number.isNaN(raw.getTime()) ? "" : raw.toISOString() : typeof raw === "string" ? raw.trim() : "";
10126
10391
  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";
@@ -10216,19 +10481,30 @@ function timezoneOffsetMs(utcMillis, timezone) {
10216
10481
  const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
10217
10482
  return asIfUtc - Math.floor(utcMillis / 1e3) * 1e3;
10218
10483
  }
10219
- function isCalendarDateAllowed(dateYYYYMMDD, config) {
10484
+ function isCalendarDateAllowed(dateYYYYMMDD, config, clock) {
10220
10485
  if (!config) return true;
10221
10486
  if (config.minDate && dateYYYYMMDD < config.minDate) return false;
10222
10487
  if (config.maxDate && dateYYYYMMDD > config.maxDate) return false;
10488
+ if (relativeBoundFailure(dateYYYYMMDD, config, clock)) return false;
10223
10489
  if (config.blockedDates?.includes(dateYYYYMMDD)) return false;
10224
10490
  if (config.blockedWeekdays?.length) {
10225
10491
  if (config.blockedWeekdays.includes(weekdayOfDateString(dateYYYYMMDD))) return false;
10226
10492
  }
10227
10493
  return true;
10228
10494
  }
10229
- function computeAvailableSlots(config, dateYYYYMMDD) {
10495
+ function relativeBoundFailure(dateYYYYMMDD, config, clock) {
10496
+ const bounds = resolveRelativeBounds(config, clock);
10497
+ if (bounds.earliestDate && dateYYYYMMDD < bounds.earliestDate) {
10498
+ return `the earliest available date is ${bounds.earliestDate}`;
10499
+ }
10500
+ if (bounds.latestDate && dateYYYYMMDD > bounds.latestDate) {
10501
+ return `the latest available date is ${bounds.latestDate}`;
10502
+ }
10503
+ return null;
10504
+ }
10505
+ function computeAvailableSlots(config, dateYYYYMMDD, clock) {
10230
10506
  if (!config) return [];
10231
- if (!isCalendarDateAllowed(dateYYYYMMDD, config)) return [];
10507
+ if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) return [];
10232
10508
  if (!config.businessHours?.length || !config.slotDurationMinutes) return [];
10233
10509
  const weekday = weekdayOfDateString(dateYYYYMMDD);
10234
10510
  const windows = config.businessHours.filter((w) => w.weekday === weekday);
@@ -10241,27 +10517,47 @@ function computeAvailableSlots(config, dateYYYYMMDD) {
10241
10517
  slots.push(minutesToHHMM(t));
10242
10518
  }
10243
10519
  }
10244
- return slots;
10520
+ const ordered = Array.from(new Set(slots)).sort();
10521
+ const { earliestInstant } = resolveRelativeBounds(config, clock);
10522
+ if (!earliestInstant || !clock) return ordered;
10523
+ return ordered.filter((hhmm) => {
10524
+ const [hour, minute] = hhmm.split(":").map(Number);
10525
+ const start = instantFromStoreLocal(dateYYYYMMDD, hour, minute, 0, 0, clock.timezone);
10526
+ return start.getTime() >= earliestInstant.getTime();
10527
+ });
10245
10528
  }
10246
- function getBusinessHoursForDate(config, dateYYYYMMDD) {
10529
+ function getBusinessHoursForDate(config, dateYYYYMMDD, clock) {
10247
10530
  if (!config?.businessHours?.length) return [];
10248
- if (!isCalendarDateAllowed(dateYYYYMMDD, config)) return [];
10531
+ if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) return [];
10249
10532
  const weekday = weekdayOfDateString(dateYYYYMMDD);
10250
10533
  return config.businessHours.filter((w) => w.weekday === weekday);
10251
10534
  }
10252
- function isDateValueAllowed(instant, config, fieldType, timezone) {
10535
+ function isDateValueAllowed(instant, config, fieldType, timezone, now) {
10253
10536
  if (!config) return { allowed: true };
10537
+ const clock = { timezone, now };
10254
10538
  if (fieldType === "DATE") {
10255
10539
  const dateYYYYMMDD = `${instant.getUTCFullYear()}-${pad2(instant.getUTCMonth() + 1)}-${pad2(instant.getUTCDate())}`;
10256
- if (!isCalendarDateAllowed(dateYYYYMMDD, config)) {
10540
+ const relative2 = relativeBoundFailure(dateYYYYMMDD, config, clock);
10541
+ if (relative2) return { allowed: false, reason: relative2 };
10542
+ if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) {
10257
10543
  return { allowed: false, reason: "date is outside the allowed range" };
10258
10544
  }
10259
10545
  return { allowed: true };
10260
10546
  }
10261
10547
  const local = resolveStoreLocalParts(instant, timezone);
10262
- if (!isCalendarDateAllowed(local.dateYYYYMMDD, config)) {
10548
+ const relative = relativeBoundFailure(local.dateYYYYMMDD, config, clock);
10549
+ if (relative) return { allowed: false, reason: relative };
10550
+ if (!isCalendarDateAllowed(local.dateYYYYMMDD, config, clock)) {
10263
10551
  return { allowed: false, reason: "date is outside the allowed range" };
10264
10552
  }
10553
+ const { earliestInstant } = resolveRelativeBounds(config, clock);
10554
+ if (earliestInstant && instant.getTime() < earliestInstant.getTime()) {
10555
+ const earliestLocal = resolveStoreLocalParts(earliestInstant, timezone);
10556
+ return {
10557
+ allowed: false,
10558
+ reason: `the earliest available time is ${earliestLocal.dateYYYYMMDD} ${earliestLocal.hhmm}`
10559
+ };
10560
+ }
10265
10561
  if (!config.businessHours?.length) {
10266
10562
  return { allowed: true };
10267
10563
  }
@@ -10270,7 +10566,7 @@ function isDateValueAllowed(instant, config, fieldType, timezone) {
10270
10566
  return { allowed: false, reason: "no business hours are configured for this day" };
10271
10567
  }
10272
10568
  if (config.slotDurationMinutes) {
10273
- const slots = computeAvailableSlots(config, local.dateYYYYMMDD);
10569
+ const slots = computeAvailableSlots(config, local.dateYYYYMMDD, clock);
10274
10570
  if (!slots.includes(local.hhmm)) {
10275
10571
  return { allowed: false, reason: "time does not match an available slot" };
10276
10572
  }
@@ -10900,6 +11196,7 @@ export {
10900
11196
  jsonLdScriptProps,
10901
11197
  parseDateFieldValue,
10902
11198
  parseWebhookEvent,
11199
+ resolveRelativeBounds,
10903
11200
  resolveStoreLocalParts,
10904
11201
  safePaymentRedirect,
10905
11202
  stripHtml2 as stripHtml,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainerce",
3
- "version": "1.59.0",
3
+ "version": "1.63.0",
4
4
  "description": "Official SDK for building e-commerce storefronts with Brainerce Platform. Perfect for vibe-coded sites, AI-built stores (Cursor, Lovable, v0), and custom storefronts.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",