brainerce 1.59.0 → 1.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -27
- package/dist/index.d.mts +470 -16
- package/dist/index.d.ts +470 -16
- package/dist/index.js +321 -23
- package/dist/index.mjs +320 -23
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -115,7 +115,7 @@ function isDevGuardsEnabled() {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
// src/version.ts
|
|
118
|
-
var SDK_VERSION = "1.
|
|
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 =>
|
|
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
|
|
7160
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|