brainerce 1.20.2 → 1.23.11

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
@@ -51,10 +51,12 @@ __export(index_exports, {
51
51
  getStockStatus: () => getStockStatus,
52
52
  getVariantOptions: () => getVariantOptions,
53
53
  getVariantPrice: () => getVariantPrice,
54
+ isAllowedPaymentUrl: () => isAllowedPaymentUrl,
54
55
  isCouponApplicableToProduct: () => isCouponApplicableToProduct,
55
56
  isHtmlDescription: () => isHtmlDescription,
56
57
  isWebhookEventType: () => isWebhookEventType,
57
58
  parseWebhookEvent: () => parseWebhookEvent,
59
+ safePaymentRedirect: () => safePaymentRedirect,
58
60
  verifyWebhook: () => verifyWebhook
59
61
  });
60
62
  module.exports = __toCommonJS(index_exports);
@@ -176,7 +178,7 @@ function isDevGuardsEnabled() {
176
178
  }
177
179
 
178
180
  // src/version.ts
179
- var SDK_VERSION = "1.11.2";
181
+ var SDK_VERSION = "1.21.0";
180
182
 
181
183
  // src/client.ts
182
184
  var DEFAULT_BASE_URL = "https://api.brainerce.com";
@@ -202,27 +204,87 @@ var BrainerceClient = class {
202
204
  * This is needed because Stripe redirects lose in-memory state.
203
205
  */
204
206
  this.ACTIVE_CHECKOUT_KEY = "brainerce_active_checkout";
207
+ // -------------------- Contact Forms (schema) --------------------
208
+ /**
209
+ * List active contact forms configured for the store.
210
+ *
211
+ * Storefront (public) mode only. Useful when your site has multiple
212
+ * forms (e.g. "main", "newsletter", "whatsapp_prechat") and you need
213
+ * to pick the right one at render time.
214
+ *
215
+ * @example
216
+ * ```typescript
217
+ * const forms = await brainerce.contactForms.list();
218
+ * // → [{ key: 'main', name: 'Main Contact', isDefault: true }, ...]
219
+ * ```
220
+ */
221
+ this.contactForms = {
222
+ list: async () => {
223
+ if (this.isVibeCodedMode()) {
224
+ return this.vibeCodedRequest("GET", "/contact-forms");
225
+ }
226
+ return this.storefrontRequest("GET", "/contact-forms");
227
+ },
228
+ /**
229
+ * Fetch the full schema for a single contact form, including all
230
+ * visible fields + their localized labels/placeholders/help text.
231
+ *
232
+ * `locale` is the storefront locale at render time (e.g. `"he"`).
233
+ * Falls back to the store's default language when omitted.
234
+ *
235
+ * @example
236
+ * ```typescript
237
+ * const form = await brainerce.contactForms.get('main', 'he');
238
+ * // Render form.fields; submit via brainerce.createInquiry({ formKey: 'main', fields })
239
+ * ```
240
+ */
241
+ get: async (formKey = "main", locale) => {
242
+ const query = locale ? { locale } : void 0;
243
+ if (this.isVibeCodedMode()) {
244
+ return this.vibeCodedRequest(
245
+ "GET",
246
+ `/contact-forms/${encodeURIComponent(formKey)}`,
247
+ void 0,
248
+ query
249
+ );
250
+ }
251
+ return this.storefrontRequest(
252
+ "GET",
253
+ `/contact-forms/${encodeURIComponent(formKey)}`,
254
+ void 0,
255
+ query
256
+ );
257
+ }
258
+ };
205
259
  // -------------------- Local Cart (Client-Side for Guests) --------------------
206
260
  // These methods store cart data in localStorage - NO API calls!
207
261
  // Use for guest users in vibe-coded sites
208
262
  this.LOCAL_CART_KEY = "brainerce_cart";
209
- if (!options.apiKey && !options.storeId && !options.connectionId) {
210
- throw new Error("BrainerceClient: either connectionId, apiKey, or storeId is required");
263
+ const resolvedSalesChannelId = options.salesChannelId ?? options.connectionId;
264
+ if (!options.apiKey && !options.storeId && !resolvedSalesChannelId) {
265
+ throw new Error(
266
+ "BrainerceClient: either salesChannelId, apiKey, or storeId is required"
267
+ );
211
268
  }
212
269
  if (options.apiKey && !options.apiKey.startsWith("brainerce_")) {
213
270
  console.warn('BrainerceClient: apiKey should start with "brainerce_"');
214
271
  }
215
- if (options.connectionId && !options.connectionId.startsWith("vc_")) {
216
- console.warn('BrainerceClient: connectionId should start with "vc_"');
272
+ if (resolvedSalesChannelId && !resolvedSalesChannelId.startsWith("vc_")) {
273
+ console.warn('BrainerceClient: salesChannelId should start with "vc_"');
274
+ }
275
+ if (!options.salesChannelId && options.connectionId) {
276
+ console.warn(
277
+ "BrainerceClient: `connectionId` is deprecated \u2014 use `salesChannelId` instead. `connectionId` will be removed in SDK 2.0."
278
+ );
217
279
  }
218
280
  if (options.apiKey && typeof window !== "undefined") {
219
281
  console.warn(
220
- "BrainerceClient: WARNING - API key detected in browser environment. This is a security risk! Use connectionId or storeId for frontend applications."
282
+ "BrainerceClient: WARNING - API key detected in browser environment. This is a security risk! Use salesChannelId or storeId for frontend applications."
221
283
  );
222
284
  }
223
285
  this.apiKey = options.apiKey;
224
286
  this.storeId = options.storeId;
225
- this.connectionId = options.connectionId;
287
+ this.connectionId = resolvedSalesChannelId;
226
288
  let resolvedBase = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
227
289
  if (resolvedBase.startsWith("/") && typeof window !== "undefined" && window.location?.origin) {
228
290
  resolvedBase = window.location.origin + resolvedBase;
@@ -309,11 +371,17 @@ var BrainerceClient = class {
309
371
  }
310
372
  // -------------------- Mode Detection --------------------
311
373
  /**
312
- * Check if client is in vibe-coded mode (using connectionId)
374
+ * Check if client is in sales-channel mode (using salesChannelId / legacy connectionId).
313
375
  */
314
- isVibeCodedMode() {
376
+ isSalesChannelMode() {
315
377
  return !!this.connectionId && !this.apiKey;
316
378
  }
379
+ /**
380
+ * @deprecated Use `isSalesChannelMode()` instead. Kept as a backwards-compatible alias.
381
+ */
382
+ isVibeCodedMode() {
383
+ return this.isSalesChannelMode();
384
+ }
317
385
  /**
318
386
  * Check if client is in storefront mode (using storeId)
319
387
  */
@@ -625,6 +693,18 @@ var BrainerceClient = class {
625
693
  const categories = Array.isArray(params?.categories) ? params.categories.join(",") : params?.categories;
626
694
  const brands = Array.isArray(params?.brands) ? params.brands.join(",") : params?.brands;
627
695
  const tags = Array.isArray(params?.tags) ? params.tags.join(",") : params?.tags;
696
+ let metafields;
697
+ if (params?.metafields && Object.keys(params.metafields).length > 0) {
698
+ const normalized = {};
699
+ for (const [key, raw] of Object.entries(params.metafields)) {
700
+ const arr = Array.isArray(raw) ? raw : [raw];
701
+ const strings = arr.map((v) => String(v)).filter(Boolean);
702
+ if (strings.length > 0) normalized[key] = strings;
703
+ }
704
+ if (Object.keys(normalized).length > 0) {
705
+ metafields = JSON.stringify(normalized);
706
+ }
707
+ }
628
708
  const queryParams = {
629
709
  page: params?.page,
630
710
  limit: params?.limit,
@@ -635,6 +715,7 @@ var BrainerceClient = class {
635
715
  tags,
636
716
  minPrice: params?.minPrice,
637
717
  maxPrice: params?.maxPrice,
718
+ metafields,
638
719
  sortBy: params?.sortBy,
639
720
  sortOrder: params?.sortOrder,
640
721
  // Admin-only params
@@ -1564,6 +1645,58 @@ var BrainerceClient = class {
1564
1645
  async getCustomerByEmail(email) {
1565
1646
  return this.request("GET", "/api/v1/customers/by-email", void 0, { email });
1566
1647
  }
1648
+ /**
1649
+ * List a customer's saved payment methods (vaulted cards).
1650
+ *
1651
+ * Returns display-only metadata — last4, brand, expiry, default flag,
1652
+ * status. The underlying provider token is encrypted at rest and
1653
+ * NEVER returned through this API.
1654
+ *
1655
+ * Apps mode requires `customers:read` and `payments:read` scopes.
1656
+ *
1657
+ * @param storeId - The store this customer belongs to
1658
+ * @param customerId - The customer's ID
1659
+ * @returns Array of saved payment methods
1660
+ *
1661
+ * @example
1662
+ * ```typescript
1663
+ * const methods = await client.listSavedPaymentMethods(storeId, customerId);
1664
+ * methods.forEach((m) => {
1665
+ * console.log(`${m.brand} ending in ${m.last4} (expires ${m.expMonth}/${m.expYear})`);
1666
+ * });
1667
+ * ```
1668
+ */
1669
+ async listSavedPaymentMethods(storeId, customerId) {
1670
+ return this.request(
1671
+ "GET",
1672
+ `/api/stores/${storeId}/customers/${customerId}/payment-methods`
1673
+ );
1674
+ }
1675
+ /**
1676
+ * Remove a customer's saved payment method.
1677
+ *
1678
+ * Hard-deletes the row. The provider may still hold the underlying
1679
+ * token internally — we don't issue a delete-at-provider call because
1680
+ * not every provider supports it. From the platform's perspective the
1681
+ * token is gone; subsequent charges will fail.
1682
+ *
1683
+ * Apps mode requires `customers:write` scope.
1684
+ *
1685
+ * @param storeId - The store this customer belongs to
1686
+ * @param customerId - The customer's ID
1687
+ * @param paymentMethodId - The saved payment method ID to remove
1688
+ *
1689
+ * @example
1690
+ * ```typescript
1691
+ * await client.removeSavedPaymentMethod(storeId, customerId, methodId);
1692
+ * ```
1693
+ */
1694
+ async removeSavedPaymentMethod(storeId, customerId, paymentMethodId) {
1695
+ return this.request(
1696
+ "DELETE",
1697
+ `/api/stores/${storeId}/customers/${customerId}/payment-methods/${paymentMethodId}`
1698
+ );
1699
+ }
1567
1700
  /**
1568
1701
  * Login an existing customer (returns JWT token)
1569
1702
  * Works in vibe-coded, storefront, and admin mode
@@ -2070,7 +2203,7 @@ var BrainerceClient = class {
2070
2203
  * per IP on the server. Include an empty `honeypot` field on your form
2071
2204
  * and do NOT send it — bots that auto-fill every input will be rejected.
2072
2205
  *
2073
- * @example
2206
+ * **Legacy shape (kept working forever):**
2074
2207
  * ```typescript
2075
2208
  * await brainerce.createInquiry({
2076
2209
  * name: 'Jane Doe',
@@ -2080,6 +2213,16 @@ var BrainerceClient = class {
2080
2213
  * phone: '+1-555-0100',
2081
2214
  * });
2082
2215
  * ```
2216
+ *
2217
+ * **Phase 2 (multi-form / custom fields):**
2218
+ * ```typescript
2219
+ * const form = await brainerce.contactForms.get('newsletter');
2220
+ * await brainerce.createInquiry({
2221
+ * formKey: 'newsletter',
2222
+ * fields: { email: 'jane@example.com', source: 'homepage' },
2223
+ * locale: 'he',
2224
+ * });
2225
+ * ```
2083
2226
  */
2084
2227
  async createInquiry(input) {
2085
2228
  if (this.isVibeCodedMode()) {
@@ -2406,6 +2549,82 @@ var BrainerceClient = class {
2406
2549
  "cart"
2407
2550
  );
2408
2551
  }
2552
+ /**
2553
+ * Recalculate cart totals against current product/variant prices and
2554
+ * discount rules. Idempotent — does NOT mutate the per-item snapshot
2555
+ * `unitPrice`. The returned cart's `hasPriceChanges` / `hasUnavailableItems`
2556
+ * flags reflect the live state. Call this on cart load if you want to
2557
+ * surface drift to the customer before they reach checkout.
2558
+ *
2559
+ * @example
2560
+ * ```typescript
2561
+ * const cart = await client.recalculateCart('cart_123');
2562
+ * if (cart.hasPriceChanges) {
2563
+ * // show "prices have changed" banner
2564
+ * }
2565
+ * ```
2566
+ */
2567
+ async recalculateCart(cartId) {
2568
+ if (cartId === this.VIRTUAL_LOCAL_CART_ID) {
2569
+ return this.withGuards(this.localCartToCart(this.getLocalCart()), "cart");
2570
+ }
2571
+ if (this.isVibeCodedMode()) {
2572
+ return this.withGuards(
2573
+ this.vibeCodedRequest("POST", `/cart/${cartId}/recalculate`),
2574
+ "cart"
2575
+ );
2576
+ }
2577
+ if (this.storeId && !this.apiKey) {
2578
+ return this.withGuards(
2579
+ this.storefrontRequest("POST", `/cart/${cartId}/recalculate`),
2580
+ "cart"
2581
+ );
2582
+ }
2583
+ return this.withGuards(
2584
+ this.adminRequest("POST", `/api/v1/cart/${cartId}/recalculate`),
2585
+ "cart"
2586
+ );
2587
+ }
2588
+ /**
2589
+ * Refresh per-item `unitPrice` snapshots to current live prices. Use this
2590
+ * after the customer accepts new prices in the drift-reconfirm flow. The
2591
+ * subsequent `createCheckout` call will then succeed (it would otherwise
2592
+ * throw `PRICE_DRIFT`).
2593
+ *
2594
+ * @example
2595
+ * ```typescript
2596
+ * try {
2597
+ * await client.createCheckout(cartId);
2598
+ * } catch (err) {
2599
+ * if (err.code === 'PRICE_DRIFT') {
2600
+ * // ask user to confirm new prices, then:
2601
+ * await client.refreshCartSnapshots(cartId);
2602
+ * await client.createCheckout(cartId);
2603
+ * }
2604
+ * }
2605
+ * ```
2606
+ */
2607
+ async refreshCartSnapshots(cartId) {
2608
+ if (cartId === this.VIRTUAL_LOCAL_CART_ID) {
2609
+ return this.withGuards(this.localCartToCart(this.getLocalCart()), "cart");
2610
+ }
2611
+ if (this.isVibeCodedMode()) {
2612
+ return this.withGuards(
2613
+ this.vibeCodedRequest("POST", `/cart/${cartId}/refresh-snapshots`),
2614
+ "cart"
2615
+ );
2616
+ }
2617
+ if (this.storeId && !this.apiKey) {
2618
+ return this.withGuards(
2619
+ this.storefrontRequest("POST", `/cart/${cartId}/refresh-snapshots`),
2620
+ "cart"
2621
+ );
2622
+ }
2623
+ return this.withGuards(
2624
+ this.adminRequest("POST", `/api/v1/cart/${cartId}/refresh-snapshots`),
2625
+ "cart"
2626
+ );
2627
+ }
2409
2628
  /**
2410
2629
  * Link a cart to the currently logged-in customer.
2411
2630
  * Use this after customer logs in to associate their guest cart with their account.
@@ -2646,7 +2865,8 @@ var BrainerceClient = class {
2646
2865
  * ```typescript
2647
2866
  * const { bundles } = await client.getCartBundles('cart_123');
2648
2867
  * bundles.forEach(b => {
2649
- * console.log(`Add ${b.bundleProduct.name} and save! Was ${b.originalPrice}, now ${b.discountedPrice}`);
2868
+ * const names = b.offeredProducts.map(p => p.name).join(', ');
2869
+ * console.log(`Add ${names} and save! Was ${b.totalOriginalPrice}, now ${b.totalDiscountedPrice}`);
2650
2870
  * });
2651
2871
  * ```
2652
2872
  */
@@ -2709,24 +2929,31 @@ var BrainerceClient = class {
2709
2929
  throw new BrainerceError("removeOrderBump() requires vibe-coded or storefront mode", 400);
2710
2930
  }
2711
2931
  /**
2712
- * Add a bundle offer product to the cart with its discount applied.
2932
+ * Accept an N-product bundle offer: every offered product not yet in cart
2933
+ * is added with the bundle discount applied.
2713
2934
  *
2714
2935
  * @param cartId - Cart ID
2715
2936
  * @param bundleOfferId - Bundle offer ID
2716
- * @param variantId - Optional variant ID (required when bundle product has variants and no admin-locked variant)
2937
+ * @param variantSelections - Optional map of `productId variantId` for offered products with variants
2717
2938
  * @returns Updated cart
2718
2939
  *
2719
2940
  * @example
2720
2941
  * ```typescript
2721
2942
  * const { bundles } = await client.getCartBundles('cart_123');
2722
- * // Simple product or locked variant:
2723
- * const cart = await client.addBundleToCart('cart_123', bundles[0].id);
2724
- * // Product with variants (customer selects):
2725
- * const cart = await client.addBundleToCart('cart_123', bundles[0].id, selectedVariantId);
2943
+ * const bundle = bundles[0];
2944
+ * // Simple products only:
2945
+ * await client.addBundleToCart('cart_123', bundle.id);
2946
+ * // Some offered products have variants:
2947
+ * await client.addBundleToCart('cart_123', bundle.id, {
2948
+ * [variantProductId]: selectedVariantId,
2949
+ * });
2726
2950
  * ```
2727
2951
  */
2728
- async addBundleToCart(cartId, bundleOfferId, variantId) {
2729
- const body = { bundleOfferId, ...variantId && { variantId } };
2952
+ async addBundleToCart(cartId, bundleOfferId, variantSelections) {
2953
+ const body = {
2954
+ bundleOfferId,
2955
+ ...variantSelections && Object.keys(variantSelections).length > 0 ? { variantSelections } : {}
2956
+ };
2730
2957
  if (this.isVibeCodedMode()) {
2731
2958
  return this.vibeCodedRequest("POST", `/cart/${cartId}/bundle`, body);
2732
2959
  }
@@ -2842,6 +3069,9 @@ var BrainerceClient = class {
2842
3069
  couponCode: null,
2843
3070
  items: [],
2844
3071
  itemCount: 0,
3072
+ hasPriceChanges: false,
3073
+ hasUnavailableItems: false,
3074
+ unavailableItemIds: [],
2845
3075
  expiresAt: null,
2846
3076
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2847
3077
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -3078,23 +3308,21 @@ var BrainerceClient = class {
3078
3308
  * ```
3079
3309
  */
3080
3310
  async smartAddToCart(item) {
3311
+ const payload = {
3312
+ productId: item.productId,
3313
+ variantId: item.variantId,
3314
+ quantity: item.quantity,
3315
+ metadata: item.metadata,
3316
+ ...item.selections && item.selections.length > 0 ? { selections: item.selections } : {},
3317
+ ...item.nestedByModifierId ? { nestedByModifierId: item.nestedByModifierId } : {}
3318
+ };
3081
3319
  if (this.isCustomerLoggedIn()) {
3082
3320
  const cart = await this.getOrCreateCustomerCart();
3083
- const updated = await this.addToCart(cart.id, {
3084
- productId: item.productId,
3085
- variantId: item.variantId,
3086
- quantity: item.quantity,
3087
- metadata: item.metadata
3088
- });
3321
+ const updated = await this.addToCart(cart.id, payload);
3089
3322
  return updated;
3090
3323
  } else {
3091
3324
  const cart = await this.getOrCreateSessionCart();
3092
- const updated = await this.addToCart(cart.id, {
3093
- productId: item.productId,
3094
- variantId: item.variantId,
3095
- quantity: item.quantity,
3096
- metadata: item.metadata
3097
- });
3325
+ const updated = await this.addToCart(cart.id, payload);
3098
3326
  this.updateSessionCartItemCount(updated.items?.length ?? 0);
3099
3327
  return updated;
3100
3328
  }
@@ -4137,6 +4365,16 @@ var BrainerceClient = class {
4137
4365
  variantId: item.variantId || null,
4138
4366
  quantity: item.quantity,
4139
4367
  unitPrice: item.price || "0",
4368
+ // Local carts never reach the server, so they have no live price to
4369
+ // compare against. Surface the snapshot as both values and flag
4370
+ // everything as "unchanged + available" — the migration to a server
4371
+ // cart on next load will run the real recalc.
4372
+ currentUnitPrice: item.price || "0",
4373
+ priceChanged: false,
4374
+ priceDelta: "0",
4375
+ priceDirection: "unchanged",
4376
+ isAvailable: true,
4377
+ unavailableReason: null,
4140
4378
  discountAmount: "0",
4141
4379
  promoDiscountAmount: "0",
4142
4380
  promoSource: null,
@@ -4159,6 +4397,9 @@ var BrainerceClient = class {
4159
4397
  updatedAt: item.addedAt
4160
4398
  })),
4161
4399
  itemCount: localCart.items.reduce((sum, i) => sum + i.quantity, 0),
4400
+ hasPriceChanges: false,
4401
+ hasUnavailableItems: false,
4402
+ unavailableItemIds: [],
4162
4403
  expiresAt: null,
4163
4404
  createdAt: localCart.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
4164
4405
  updatedAt: localCart.updatedAt || (/* @__PURE__ */ new Date()).toISOString()
@@ -5505,6 +5746,204 @@ var BrainerceClient = class {
5505
5746
  `/api/v1/attributes/${attributeId}/options/${optionId}`
5506
5747
  );
5507
5748
  }
5749
+ // -------------------- Modifier Groups (Admin) --------------------
5750
+ // These methods require Admin mode (apiKey).
5751
+ // Routes: /api/stores/:storeId/modifier-groups[/:id]
5752
+ // /api/stores/:storeId/modifier-groups/:groupId/modifiers[/:modifierId]
5753
+ // /api/stores/:storeId/products/:productId/modifier-groups[/:attachmentId]
5754
+ //
5755
+ // Server-side validation failures arrive as a structured 400 envelope:
5756
+ // { code: 'MODIFIER_VALIDATION_FAILED', errors: ModifierValidationError[] }
5757
+ // surfaced via BrainerceError.details.
5758
+ /**
5759
+ * List modifier groups in a store, paginated.
5760
+ * Requires Admin mode (apiKey).
5761
+ *
5762
+ * @example
5763
+ * ```typescript
5764
+ * const groups = await client.listModifierGroups('store_123', {
5765
+ * page: 1,
5766
+ * limit: 20,
5767
+ * search: 'pizza',
5768
+ * status: 'active',
5769
+ * });
5770
+ * ```
5771
+ */
5772
+ async listModifierGroups(storeId, params) {
5773
+ return this.adminRequest(
5774
+ "GET",
5775
+ `/api/stores/${storeId}/modifier-groups`,
5776
+ void 0,
5777
+ params
5778
+ );
5779
+ }
5780
+ /**
5781
+ * Fetch a single modifier group (with its modifiers).
5782
+ * Requires Admin mode (apiKey).
5783
+ *
5784
+ * Admin fetches include `internalName`; storefront responses strip it
5785
+ * (PRD §5.2 invariant).
5786
+ */
5787
+ async getModifierGroup(storeId, groupId) {
5788
+ return this.adminRequest(
5789
+ "GET",
5790
+ `/api/stores/${storeId}/modifier-groups/${groupId}`
5791
+ );
5792
+ }
5793
+ /**
5794
+ * Create a modifier group. Modifiers themselves are created separately via
5795
+ * `createModifier(storeId, groupId, …)`.
5796
+ * Requires Admin mode (apiKey).
5797
+ *
5798
+ * @example
5799
+ * ```typescript
5800
+ * const group = await client.createModifierGroup('store_123', {
5801
+ * name: 'Toppings',
5802
+ * internalName: 'Pizza toppings',
5803
+ * selectionType: 'MULTIPLE',
5804
+ * minSelections: 0,
5805
+ * maxSelections: 8,
5806
+ * freeQuantity: 3,
5807
+ * freeAllocationPolicy: 'EXPENSIVE_FREE',
5808
+ * });
5809
+ * ```
5810
+ */
5811
+ async createModifierGroup(storeId, data) {
5812
+ return this.adminRequest("POST", `/api/stores/${storeId}/modifier-groups`, data);
5813
+ }
5814
+ /**
5815
+ * Update a modifier group's metadata or selection rules. Pass `status: 'archived'`
5816
+ * to soft-delete (the group becomes hidden from product attachments).
5817
+ * Requires Admin mode (apiKey).
5818
+ */
5819
+ async updateModifierGroup(storeId, groupId, data) {
5820
+ return this.adminRequest(
5821
+ "PATCH",
5822
+ `/api/stores/${storeId}/modifier-groups/${groupId}`,
5823
+ data
5824
+ );
5825
+ }
5826
+ /**
5827
+ * Hard-delete a modifier group. Server rejects deletion when the group is
5828
+ * still attached to any product (detach the attachments first, or use
5829
+ * `updateModifierGroup(..., { status: 'archived' })` to keep the row).
5830
+ * Requires Admin mode (apiKey).
5831
+ */
5832
+ async deleteModifierGroup(storeId, groupId) {
5833
+ await this.adminRequest("DELETE", `/api/stores/${storeId}/modifier-groups/${groupId}`);
5834
+ }
5835
+ /**
5836
+ * Create a modifier inside a group (e.g., "Olives" inside the "Toppings" group).
5837
+ * Requires Admin mode (apiKey).
5838
+ *
5839
+ * `priceDelta` is a decimal string. Negatives are accepted for downsell
5840
+ * modifiers (e.g., `"-2.00"` for "no bread"); the server still enforces
5841
+ * `unitPrice >= 0` at cart-line resolution and rejects negative deltas
5842
+ * combined with a `referencedProductId`.
5843
+ *
5844
+ * @example
5845
+ * ```typescript
5846
+ * const olives = await client.createModifier('store_123', 'mg_toppings', {
5847
+ * name: 'Olives',
5848
+ * priceDelta: '5.00',
5849
+ * isDefault: false,
5850
+ * available: true,
5851
+ * });
5852
+ * ```
5853
+ */
5854
+ async createModifier(storeId, groupId, data) {
5855
+ return this.adminRequest(
5856
+ "POST",
5857
+ `/api/stores/${storeId}/modifier-groups/${groupId}/modifiers`,
5858
+ data
5859
+ );
5860
+ }
5861
+ /**
5862
+ * Update a modifier. Pass `status: 'archived'` to soft-delete.
5863
+ * Requires Admin mode (apiKey).
5864
+ */
5865
+ async updateModifier(storeId, groupId, modifierId, data) {
5866
+ return this.adminRequest(
5867
+ "PATCH",
5868
+ `/api/stores/${storeId}/modifier-groups/${groupId}/modifiers/${modifierId}`,
5869
+ data
5870
+ );
5871
+ }
5872
+ /**
5873
+ * Hard-delete a modifier. Use `updateModifier(..., { status: 'archived' })`
5874
+ * if existing line items / order snapshots reference it.
5875
+ * Requires Admin mode (apiKey).
5876
+ */
5877
+ async deleteModifier(storeId, groupId, modifierId) {
5878
+ await this.adminRequest(
5879
+ "DELETE",
5880
+ `/api/stores/${storeId}/modifier-groups/${groupId}/modifiers/${modifierId}`
5881
+ );
5882
+ }
5883
+ /**
5884
+ * Flip the sold-out toggle on a modifier. Operational endpoint kept separate
5885
+ * from `updateModifier` because the daily ops bar is intentionally lower
5886
+ * (PRD §7.1 — STAFF role once the granular `TOGGLE_MODIFIER_AVAILABILITY`
5887
+ * permission ships).
5888
+ * Requires Admin mode (apiKey).
5889
+ *
5890
+ * @example
5891
+ * ```typescript
5892
+ * // Mark the egg modifier as out of stock during a busy lunch service
5893
+ * await client.toggleModifierAvailability('store_123', 'mg_toppings', 'm_egg', false);
5894
+ * ```
5895
+ */
5896
+ async toggleModifierAvailability(storeId, groupId, modifierId, available) {
5897
+ return this.adminRequest(
5898
+ "POST",
5899
+ `/api/stores/${storeId}/modifier-groups/${groupId}/modifiers/${modifierId}/availability-toggle`,
5900
+ { available }
5901
+ );
5902
+ }
5903
+ /**
5904
+ * Attach a modifier group to a product. Three patterns (PRD §5.4):
5905
+ *
5906
+ * - `variantId` omitted → default attach (applies to all variants).
5907
+ * - `variantId` set + matching default attach already exists → variant override row.
5908
+ * - `variantId` set + no default attach → variant-only group.
5909
+ *
5910
+ * Override fields use `null` to mean inherit; any non-null value (including
5911
+ * `0` or `false`) wins. The disable-for-variant convention is `maxOverride: 0` —
5912
+ * the resolver returns the group with `effectiveMax=0` and the validator
5913
+ * silently skips it on cart add.
5914
+ *
5915
+ * Requires Admin mode (apiKey).
5916
+ */
5917
+ async attachModifierGroup(storeId, productId, data) {
5918
+ return this.adminRequest(
5919
+ "POST",
5920
+ `/api/stores/${storeId}/products/${productId}/modifier-groups`,
5921
+ data
5922
+ );
5923
+ }
5924
+ /**
5925
+ * Update an existing attachment's overrides or position. The `modifierGroupId`
5926
+ * and `variantId` are immutable — to swap groups or move between default /
5927
+ * per-variant rows, detach and re-attach.
5928
+ * Requires Admin mode (apiKey).
5929
+ */
5930
+ async updateAttachment(storeId, productId, attachmentId, data) {
5931
+ return this.adminRequest(
5932
+ "PATCH",
5933
+ `/api/stores/${storeId}/products/${productId}/modifier-groups/${attachmentId}`,
5934
+ data
5935
+ );
5936
+ }
5937
+ /**
5938
+ * Detach a modifier group from a product (or remove a per-variant override row).
5939
+ * Requires Admin mode (apiKey).
5940
+ */
5941
+ async detachModifierGroup(storeId, productId, attachmentId) {
5942
+ await this.adminRequest(
5943
+ "DELETE",
5944
+ `/api/stores/${storeId}/products/${productId}/modifier-groups/${attachmentId}`
5945
+ );
5946
+ }
5508
5947
  // -------------------- Shipping: Zones and Rates (Admin) --------------------
5509
5948
  // These methods require Admin mode (apiKey)
5510
5949
  /**
@@ -5684,7 +6123,10 @@ var BrainerceClient = class {
5684
6123
  type: d.type,
5685
6124
  required: d.required,
5686
6125
  enumValues: d.enumValues || void 0,
5687
- position: d.position
6126
+ position: d.position,
6127
+ isCustomerInput: d.isCustomerInput,
6128
+ appliesToAllProducts: d.appliesToAllProducts,
6129
+ filterable: d.filterable
5688
6130
  }))
5689
6131
  };
5690
6132
  }
@@ -5740,6 +6182,107 @@ var BrainerceClient = class {
5740
6182
  async deleteMetafieldDefinition(definitionId) {
5741
6183
  await this.adminRequest("DELETE", `/api/v1/metafield-definitions/${definitionId}`);
5742
6184
  }
6185
+ /**
6186
+ * Replace the platform publishing configuration on a metafield definition.
6187
+ * `publishedOn` is the source of truth for which sales channels the field
6188
+ * appears on; `platformMetadata` carries the per-platform mapping config
6189
+ * required for sync.
6190
+ *
6191
+ * Requires Admin mode (apiKey).
6192
+ *
6193
+ * @example
6194
+ * ```typescript
6195
+ * await client.setMetafieldPlatforms('def_123', {
6196
+ * publishedOn: ['SHOPIFY', 'WOOCOMMERCE'],
6197
+ * platformMetadata: {
6198
+ * SHOPIFY: { namespace: 'custom', key: 'warranty' },
6199
+ * WOOCOMMERCE: { key: '_warranty_info' },
6200
+ * },
6201
+ * });
6202
+ * ```
6203
+ */
6204
+ async setMetafieldPlatforms(definitionId, data) {
6205
+ return this.adminRequest(
6206
+ "PUT",
6207
+ `/api/v1/metafield-definitions/${definitionId}/platforms`,
6208
+ data
6209
+ );
6210
+ }
6211
+ // -------------- Vibe-coded site publishing (Admin Mode) --------------
6212
+ // Publish/unpublish category/tag/brand/metafield definitions to specific
6213
+ // vibe-coded sites. Mirrors the per-channel control already available for
6214
+ // Products/Coupons. When no publishes exist for an entity, it remains
6215
+ // visible to all vibe-coded sites of the store (legacy default).
6216
+ /**
6217
+ * Publish a metafield definition to a vibe-coded site (admin mode).
6218
+ * @example
6219
+ * ```typescript
6220
+ * await client.publishMetafieldDefinitionToVibeCodedSite('def_123', 'conn_456');
6221
+ * ```
6222
+ */
6223
+ async publishMetafieldDefinitionToVibeCodedSite(definitionId, vibeCodedConnectionId) {
6224
+ return this.adminRequest(
6225
+ "POST",
6226
+ `/api/v1/metafield-definitions/${definitionId}/publish-vibe-coded`,
6227
+ { vibeCodedConnectionId }
6228
+ );
6229
+ }
6230
+ /** Unpublish a metafield definition from a vibe-coded site (admin mode). */
6231
+ async unpublishMetafieldDefinitionFromVibeCodedSite(definitionId, vibeCodedConnectionId) {
6232
+ return this.adminRequest(
6233
+ "POST",
6234
+ `/api/v1/metafield-definitions/${definitionId}/unpublish-vibe-coded`,
6235
+ { vibeCodedConnectionId }
6236
+ );
6237
+ }
6238
+ /** Publish a category to a vibe-coded site (admin mode). */
6239
+ async publishCategoryToVibeCodedSite(categoryId, vibeCodedConnectionId) {
6240
+ return this.adminRequest(
6241
+ "POST",
6242
+ `/api/v1/categories/${categoryId}/publish-vibe-coded`,
6243
+ { vibeCodedConnectionId }
6244
+ );
6245
+ }
6246
+ /** Unpublish a category from a vibe-coded site (admin mode). */
6247
+ async unpublishCategoryFromVibeCodedSite(categoryId, vibeCodedConnectionId) {
6248
+ return this.adminRequest(
6249
+ "POST",
6250
+ `/api/v1/categories/${categoryId}/unpublish-vibe-coded`,
6251
+ { vibeCodedConnectionId }
6252
+ );
6253
+ }
6254
+ /** Publish a tag to a vibe-coded site (admin mode). */
6255
+ async publishTagToVibeCodedSite(tagId, vibeCodedConnectionId) {
6256
+ return this.adminRequest(
6257
+ "POST",
6258
+ `/api/v1/tags/${tagId}/publish-vibe-coded`,
6259
+ { vibeCodedConnectionId }
6260
+ );
6261
+ }
6262
+ /** Unpublish a tag from a vibe-coded site (admin mode). */
6263
+ async unpublishTagFromVibeCodedSite(tagId, vibeCodedConnectionId) {
6264
+ return this.adminRequest(
6265
+ "POST",
6266
+ `/api/v1/tags/${tagId}/unpublish-vibe-coded`,
6267
+ { vibeCodedConnectionId }
6268
+ );
6269
+ }
6270
+ /** Publish a brand to a vibe-coded site (admin mode). */
6271
+ async publishBrandToVibeCodedSite(brandId, vibeCodedConnectionId) {
6272
+ return this.adminRequest(
6273
+ "POST",
6274
+ `/api/v1/brands/${brandId}/publish-vibe-coded`,
6275
+ { vibeCodedConnectionId }
6276
+ );
6277
+ }
6278
+ /** Unpublish a brand from a vibe-coded site (admin mode). */
6279
+ async unpublishBrandFromVibeCodedSite(brandId, vibeCodedConnectionId) {
6280
+ return this.adminRequest(
6281
+ "POST",
6282
+ `/api/v1/brands/${brandId}/unpublish-vibe-coded`,
6283
+ { vibeCodedConnectionId }
6284
+ );
6285
+ }
5743
6286
  /**
5744
6287
  * Replace the list of products a (customer-input) metafield definition is
5745
6288
  * attached to. Diff-scoped: only rows for this definition are touched, so
@@ -6309,6 +6852,55 @@ function createWebhookHandler(handlers) {
6309
6852
  };
6310
6853
  }
6311
6854
 
6855
+ // src/payment-url.ts
6856
+ var ALLOWED_PAYMENT_HOSTS = [
6857
+ // Stripe
6858
+ "checkout.stripe.com",
6859
+ "js.stripe.com",
6860
+ "hooks.stripe.com",
6861
+ // PayPal
6862
+ "www.paypal.com",
6863
+ "www.sandbox.paypal.com",
6864
+ // Cardcom
6865
+ "secure.cardcom.solutions",
6866
+ // Meshulam
6867
+ "meshulam.co.il",
6868
+ // Grow (Linktech)
6869
+ "grow.link",
6870
+ "grow.security",
6871
+ // CreditGuard
6872
+ "creditguard.co.il",
6873
+ // Brainerce-hosted payment embeds (backend payment-embed proxy at
6874
+ // `/api/payment/embed/...` that fronts provider apps' embed shells —
6875
+ // e.g. cardcom-payments OpenFields wrapper). The match also covers
6876
+ // subdomains like `api.brainerce.com`, `staging.brainerce.com`.
6877
+ "brainerce.com"
6878
+ ];
6879
+ function isAllowedPaymentUrl(url, options) {
6880
+ if (!url || typeof url !== "string") return false;
6881
+ let parsed;
6882
+ try {
6883
+ parsed = new URL(url);
6884
+ } catch {
6885
+ return false;
6886
+ }
6887
+ const hostname = parsed.hostname.toLowerCase();
6888
+ if (options?.allowLocalhost && parsed.protocol === "http:" && (hostname === "localhost" || hostname === "127.0.0.1")) {
6889
+ return true;
6890
+ }
6891
+ if (parsed.protocol !== "https:") return false;
6892
+ const allowed = options?.extraHosts ? [...ALLOWED_PAYMENT_HOSTS, ...options.extraHosts] : ALLOWED_PAYMENT_HOSTS;
6893
+ return allowed.some((host) => hostname === host || hostname.endsWith("." + host));
6894
+ }
6895
+ function safePaymentRedirect(url, options) {
6896
+ if (!isAllowedPaymentUrl(url, options)) {
6897
+ throw new Error("Payment redirect URL is not in the allowlist");
6898
+ }
6899
+ if (typeof window !== "undefined") {
6900
+ window.location.href = url;
6901
+ }
6902
+ }
6903
+
6312
6904
  // src/types.ts
6313
6905
  function isHtmlDescription(product) {
6314
6906
  if (product?.descriptionFormat === "html") return true;
@@ -6549,9 +7141,11 @@ function isCouponApplicableToProduct(coupon, productId) {
6549
7141
  getStockStatus,
6550
7142
  getVariantOptions,
6551
7143
  getVariantPrice,
7144
+ isAllowedPaymentUrl,
6552
7145
  isCouponApplicableToProduct,
6553
7146
  isHtmlDescription,
6554
7147
  isWebhookEventType,
6555
7148
  parseWebhookEvent,
7149
+ safePaymentRedirect,
6556
7150
  verifyWebhook
6557
7151
  });