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.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
  */
@@ -4117,7 +4205,9 @@ var _BrainerceClient = class _BrainerceClient {
4117
4205
  * try {
4118
4206
  * await client.createCheckout(cartId);
4119
4207
  * } catch (err) {
4120
- * if (err.code === 'PRICE_DRIFT') {
4208
+ * // BrainerceError.details is the whole response body — the code lives
4209
+ * // there, NOT on the error object itself.
4210
+ * if ((err as BrainerceError).details?.code === 'PRICE_DRIFT') {
4121
4211
  * // ask user to confirm new prices, then:
4122
4212
  * await client.refreshCartSnapshots(cartId);
4123
4213
  * await client.createCheckout(cartId);
@@ -4329,16 +4419,27 @@ var _BrainerceClient = class _BrainerceClient {
4329
4419
  * List visible reviews for a product (storefront / sales-channel modes).
4330
4420
  * Reviews that the merchant has hidden are excluded.
4331
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
+ *
4332
4429
  * @example
4333
4430
  * ```typescript
4334
4431
  * const { data, meta } = await client.listProductReviews('prod_123', { page: 1, limit: 20 });
4335
- * 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
+ * });
4336
4436
  * ```
4337
4437
  */
4338
4438
  async listProductReviews(productId, params) {
4339
4439
  const queryParams = {};
4340
4440
  if (params?.page) queryParams.page = params.page;
4341
4441
  if (params?.limit) queryParams.limit = params.limit;
4442
+ if (params?.sort) queryParams.sort = params.sort;
4342
4443
  if (this.isVibeCodedMode()) {
4343
4444
  return this.vibeCodedRequest(
4344
4445
  "GET",
@@ -4496,6 +4597,37 @@ var _BrainerceClient = class _BrainerceClient {
4496
4597
  storeId ? { storeId } : void 0
4497
4598
  );
4498
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
+ }
4499
4631
  /** Admin: unhide a previously hidden review. */
4500
4632
  async showProductReview(reviewId, storeId) {
4501
4633
  if (!this.apiKey) {
@@ -5955,6 +6087,9 @@ var _BrainerceClient = class _BrainerceClient {
5955
6087
  * Get applicable custom field definitions for a checkout.
5956
6088
  * Returns fields filtered by visibility conditions (delivery type, products in cart).
5957
6089
  * Use these to render dynamic input fields in the checkout flow.
6090
+ *
6091
+ * **Vibe-coded or storefront mode only.** There is no checkout custom-field
6092
+ * route on the API-key `/v1` surface; in admin mode this throws.
5958
6093
  */
5959
6094
  async getCheckoutCustomFields(checkoutId) {
5960
6095
  if (this.isVibeCodedMode()) {
@@ -5969,15 +6104,18 @@ var _BrainerceClient = class _BrainerceClient {
5969
6104
  `/checkout/${encodePathSegment(checkoutId)}/custom-fields`
5970
6105
  );
5971
6106
  }
5972
- return this.adminRequest(
5973
- "GET",
5974
- `/api/v1/checkouts/${encodePathSegment(checkoutId)}/custom-fields`
6107
+ throw new BrainerceError(
6108
+ "getCheckoutCustomFields is only available in vibe-coded or storefront mode",
6109
+ 400
5975
6110
  );
5976
6111
  }
5977
6112
  /**
5978
6113
  * Set checkout custom field values and recalculate surcharges.
5979
6114
  * The checkout total is automatically updated to include surcharges.
5980
6115
  *
6116
+ * **Vibe-coded or storefront mode only.** There is no checkout custom-field
6117
+ * route on the API-key `/v1` surface; in admin mode this throws.
6118
+ *
5981
6119
  * @example
5982
6120
  * ```typescript
5983
6121
  * const checkout = await client.setCheckoutCustomFields(checkoutId, {
@@ -6005,10 +6143,9 @@ var _BrainerceClient = class _BrainerceClient {
6005
6143
  data
6006
6144
  );
6007
6145
  }
6008
- return this.adminRequest(
6009
- "PATCH",
6010
- `/api/v1/checkouts/${encodePathSegment(checkoutId)}/custom-fields`,
6011
- data
6146
+ throw new BrainerceError(
6147
+ "setCheckoutCustomFields is only available in vibe-coded or storefront mode",
6148
+ 400
6012
6149
  );
6013
6150
  }
6014
6151
  /**
@@ -7146,11 +7283,20 @@ var _BrainerceClient = class _BrainerceClient {
7146
7283
  );
7147
7284
  }
7148
7285
  /**
7149
- * Update the current customer's profile (requires customerToken)
7150
- * Only available in storefront mode
7286
+ * Update the current customer's profile (requires customerToken).
7287
+ * Only available in storefront and vibe-coded mode.
7151
7288
  *
7152
- * `birthMonth`/`birthDay` (1-12 / 1-31, no year — privacy) power the loyalty
7153
- * 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.
7154
7300
  */
7155
7301
  async updateMyProfile(data) {
7156
7302
  if (!this.customerToken && !this.proxyMode) {
@@ -8274,8 +8420,9 @@ var _BrainerceClient = class _BrainerceClient {
8274
8420
  // /api/stores/:storeId/products/:productId/modifier-groups[/:attachmentId]
8275
8421
  //
8276
8422
  // Server-side validation failures arrive as a structured 400 envelope:
8277
- // { code: 'MODIFIER_VALIDATION_FAILED', errors: ModifierValidationError[] }
8278
- // surfaced via BrainerceError.details.
8423
+ // { code: 'MODIFIER_VALIDATION_FAILED', message, details: { errors: [...] } }
8424
+ // BrainerceError.details holds the WHOLE body, so the issue list is
8425
+ // `err.details.details.errors` — see ModifierValidationFailedError.
8279
8426
  /**
8280
8427
  * List modifier groups in a store, paginated.
8281
8428
  * Requires Admin mode (apiKey).
@@ -9326,28 +9473,99 @@ var _BrainerceClient = class _BrainerceClient {
9326
9473
  }
9327
9474
  throw new Error("uploadCustomizationFile requires storefront or vibe-coded mode");
9328
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
+ }
9329
9538
  // -------------------- Team Management (Admin) - DEPRECATED --------------------
9330
- // These account-level methods are deprecated. Use Store Team Management methods instead.
9539
+ // Account-level team methods. These are the ONLY team endpoints reachable with
9540
+ // a `brainerce_*` API key. The store-level equivalents (`getStoreTeam`,
9541
+ // `inviteStoreMember`, `updateStoreMember`, ...) sit behind
9542
+ // `DashboardOnlyGuard`, which rejects api_key principals by design — so they
9543
+ // return 403 from the SDK, not 404, and "fixing" their path would not help.
9544
+ // Do not migrate SDK code onto them.
9331
9545
  /**
9332
- * @deprecated Use `getStoreTeam(storeId)` instead.
9546
+ * @deprecated Retiring, but there is no API-key replacement yet: `getStoreTeam`
9547
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9333
9548
  */
9334
9549
  async getTeamMembers() {
9335
9550
  return this.adminRequest("GET", "/api/v1/team/members");
9336
9551
  }
9337
9552
  /**
9338
- * @deprecated Use `getStoreTeam(storeId)` instead.
9553
+ * @deprecated Retiring, but there is no API-key replacement yet: `getStoreTeam`
9554
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9339
9555
  */
9340
9556
  async getTeamInvitations() {
9341
9557
  return this.adminRequest("GET", "/api/v1/team/invitations");
9342
9558
  }
9343
9559
  /**
9344
- * @deprecated Use `inviteStoreMember(storeId, data)` instead.
9560
+ * @deprecated Retiring, but there is no API-key replacement yet: `inviteStoreMember`
9561
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9345
9562
  */
9346
9563
  async inviteTeamMember(data) {
9347
9564
  return this.adminRequest("POST", "/api/v1/team/invitations", data);
9348
9565
  }
9349
9566
  /**
9350
- * @deprecated Use `resendStoreInvitation(storeId, invitationId)` instead.
9567
+ * @deprecated Retiring, but there is no API-key replacement yet: `resendStoreInvitation`
9568
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9351
9569
  */
9352
9570
  async resendTeamInvitation(invitationId) {
9353
9571
  return this.adminRequest(
@@ -9356,7 +9574,8 @@ var _BrainerceClient = class _BrainerceClient {
9356
9574
  );
9357
9575
  }
9358
9576
  /**
9359
- * @deprecated Use `revokeStoreInvitation(storeId, invitationId)` instead.
9577
+ * @deprecated Retiring, but there is no API-key replacement yet: `revokeStoreInvitation`
9578
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9360
9579
  */
9361
9580
  async revokeTeamInvitation(invitationId) {
9362
9581
  await this.adminRequest(
@@ -9365,7 +9584,8 @@ var _BrainerceClient = class _BrainerceClient {
9365
9584
  );
9366
9585
  }
9367
9586
  /**
9368
- * @deprecated Use `updateStoreMember(storeId, memberId, data)` instead.
9587
+ * @deprecated Retiring, but there is no API-key replacement yet: `updateStoreMember`
9588
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9369
9589
  */
9370
9590
  async updateTeamMemberRole(memberId, data) {
9371
9591
  return this.adminRequest(
@@ -9375,7 +9595,8 @@ var _BrainerceClient = class _BrainerceClient {
9375
9595
  );
9376
9596
  }
9377
9597
  /**
9378
- * @deprecated Use `removeStoreMember(storeId, memberId)` instead.
9598
+ * @deprecated Retiring, but there is no API-key replacement yet: `removeStoreMember`
9599
+ * is dashboard-only (403 for api_key). Keep using this until one ships.
9379
9600
  */
9380
9601
  async removeTeamMember(memberId) {
9381
9602
  await this.adminRequest("DELETE", `/api/v1/team/members/${encodePathSegment(memberId)}`);
@@ -10001,7 +10222,7 @@ var DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\
10001
10222
  var MIN_OFFSET_MINUTES = -12 * 60;
10002
10223
  var MAX_OFFSET_MINUTES = 14 * 60;
10003
10224
  var VALID_OFFSET_MINUTE_PARTS = [0, 30, 45];
10004
- function validateDateAvailabilityConfig(config, fieldType) {
10225
+ function validateDateAvailabilityConfig(config, fieldType, surface = "checkout") {
10005
10226
  const errors = [];
10006
10227
  if (!config) return errors;
10007
10228
  if (config.minDate !== void 0 && !DATE_RE.test(config.minDate)) {
@@ -10013,6 +10234,29 @@ function validateDateAvailabilityConfig(config, fieldType) {
10013
10234
  if (config.minDate && config.maxDate && DATE_RE.test(config.minDate) && DATE_RE.test(config.maxDate) && config.minDate > config.maxDate) {
10014
10235
  errors.push("minDate must be on or before maxDate");
10015
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
+ }
10016
10260
  if (config.blockedWeekdays) {
10017
10261
  for (const d of config.blockedWeekdays) {
10018
10262
  if (!Number.isInteger(d) || d < 0 || d > 6) {
@@ -10030,22 +10274,37 @@ function validateDateAvailabilityConfig(config, fieldType) {
10030
10274
  }
10031
10275
  }
10032
10276
  if (config.businessHours) {
10033
- const seenWeekdays = /* @__PURE__ */ new Set();
10277
+ const windowsByWeekday = /* @__PURE__ */ new Map();
10034
10278
  for (const w of config.businessHours) {
10035
10279
  if (!Number.isInteger(w.weekday) || w.weekday < 0 || w.weekday > 6) {
10036
10280
  errors.push(`businessHours.weekday must be an integer 0-6, got ${JSON.stringify(w.weekday)}`);
10037
10281
  continue;
10038
10282
  }
10039
- if (seenWeekdays.has(w.weekday)) {
10040
- errors.push(`businessHours has more than one window for weekday ${w.weekday}`);
10041
- }
10042
- seenWeekdays.add(w.weekday);
10043
10283
  const openValid = TIME_RE.test(w.open);
10044
10284
  const closeValid = TIME_RE.test(w.close);
10045
10285
  if (!openValid) errors.push(`businessHours.open must be HH:mm, got "${w.open}"`);
10046
10286
  if (!closeValid) errors.push(`businessHours.close must be HH:mm, got "${w.close}"`);
10047
- if (openValid && closeValid && w.open >= w.close) {
10287
+ if (!openValid || !closeValid) continue;
10288
+ if (w.open >= w.close) {
10048
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
+ }
10049
10308
  }
10050
10309
  }
10051
10310
  }
@@ -10101,6 +10360,32 @@ function resolveStoreLocalParts(instant, timezone) {
10101
10360
  weekday: WEEKDAY_TO_INDEX[get("weekday")] ?? instant.getUTCDay()
10102
10361
  };
10103
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
+ }
10104
10389
  function parseDateFieldValue(raw, fieldType, timezone) {
10105
10390
  const str = raw instanceof Date ? Number.isNaN(raw.getTime()) ? "" : raw.toISOString() : typeof raw === "string" ? raw.trim() : "";
10106
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";
@@ -10196,19 +10481,30 @@ function timezoneOffsetMs(utcMillis, timezone) {
10196
10481
  const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
10197
10482
  return asIfUtc - Math.floor(utcMillis / 1e3) * 1e3;
10198
10483
  }
10199
- function isCalendarDateAllowed(dateYYYYMMDD, config) {
10484
+ function isCalendarDateAllowed(dateYYYYMMDD, config, clock) {
10200
10485
  if (!config) return true;
10201
10486
  if (config.minDate && dateYYYYMMDD < config.minDate) return false;
10202
10487
  if (config.maxDate && dateYYYYMMDD > config.maxDate) return false;
10488
+ if (relativeBoundFailure(dateYYYYMMDD, config, clock)) return false;
10203
10489
  if (config.blockedDates?.includes(dateYYYYMMDD)) return false;
10204
10490
  if (config.blockedWeekdays?.length) {
10205
10491
  if (config.blockedWeekdays.includes(weekdayOfDateString(dateYYYYMMDD))) return false;
10206
10492
  }
10207
10493
  return true;
10208
10494
  }
10209
- 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) {
10210
10506
  if (!config) return [];
10211
- if (!isCalendarDateAllowed(dateYYYYMMDD, config)) return [];
10507
+ if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) return [];
10212
10508
  if (!config.businessHours?.length || !config.slotDurationMinutes) return [];
10213
10509
  const weekday = weekdayOfDateString(dateYYYYMMDD);
10214
10510
  const windows = config.businessHours.filter((w) => w.weekday === weekday);
@@ -10221,27 +10517,47 @@ function computeAvailableSlots(config, dateYYYYMMDD) {
10221
10517
  slots.push(minutesToHHMM(t));
10222
10518
  }
10223
10519
  }
10224
- 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
+ });
10225
10528
  }
10226
- function getBusinessHoursForDate(config, dateYYYYMMDD) {
10529
+ function getBusinessHoursForDate(config, dateYYYYMMDD, clock) {
10227
10530
  if (!config?.businessHours?.length) return [];
10228
- if (!isCalendarDateAllowed(dateYYYYMMDD, config)) return [];
10531
+ if (!isCalendarDateAllowed(dateYYYYMMDD, config, clock)) return [];
10229
10532
  const weekday = weekdayOfDateString(dateYYYYMMDD);
10230
10533
  return config.businessHours.filter((w) => w.weekday === weekday);
10231
10534
  }
10232
- function isDateValueAllowed(instant, config, fieldType, timezone) {
10535
+ function isDateValueAllowed(instant, config, fieldType, timezone, now) {
10233
10536
  if (!config) return { allowed: true };
10537
+ const clock = { timezone, now };
10234
10538
  if (fieldType === "DATE") {
10235
10539
  const dateYYYYMMDD = `${instant.getUTCFullYear()}-${pad2(instant.getUTCMonth() + 1)}-${pad2(instant.getUTCDate())}`;
10236
- 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)) {
10237
10543
  return { allowed: false, reason: "date is outside the allowed range" };
10238
10544
  }
10239
10545
  return { allowed: true };
10240
10546
  }
10241
10547
  const local = resolveStoreLocalParts(instant, timezone);
10242
- 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)) {
10243
10551
  return { allowed: false, reason: "date is outside the allowed range" };
10244
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
+ }
10245
10561
  if (!config.businessHours?.length) {
10246
10562
  return { allowed: true };
10247
10563
  }
@@ -10250,7 +10566,7 @@ function isDateValueAllowed(instant, config, fieldType, timezone) {
10250
10566
  return { allowed: false, reason: "no business hours are configured for this day" };
10251
10567
  }
10252
10568
  if (config.slotDurationMinutes) {
10253
- const slots = computeAvailableSlots(config, local.dateYYYYMMDD);
10569
+ const slots = computeAvailableSlots(config, local.dateYYYYMMDD, clock);
10254
10570
  if (!slots.includes(local.hhmm)) {
10255
10571
  return { allowed: false, reason: "time does not match an available slot" };
10256
10572
  }
@@ -10880,6 +11196,7 @@ export {
10880
11196
  jsonLdScriptProps,
10881
11197
  parseDateFieldValue,
10882
11198
  parseWebhookEvent,
11199
+ resolveRelativeBounds,
10883
11200
  resolveStoreLocalParts,
10884
11201
  safePaymentRedirect,
10885
11202
  stripHtml2 as stripHtml,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainerce",
3
- "version": "1.58.1",
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",