@shoppexio/storefront-react 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,19 +5,25 @@ import {
5
5
  ACCOUNT_STOREFRONT_ROUTES,
6
6
  ACCOUNT_TABS,
7
7
  DEFAULT_ACCOUNT_TAB,
8
+ FAVORITES_CHANGED_EVENT,
9
+ FavoritesProvider,
8
10
  IMPLEMENTED_ACCOUNT_TABS,
9
11
  SERVING_CONTEXT_HINT,
10
12
  accountOrderHref,
11
13
  accountTabHref,
12
14
  accountTabLabelKey,
13
15
  canonicalAccountPath,
16
+ createFavoritesStore,
14
17
  getBuyerCurrency,
15
18
  getShoppex,
16
19
  isAccountTab,
17
20
  isImplementedAccountTab,
21
+ notifyFavoritesChanged,
18
22
  parseAccountPath,
19
- resolveAccountView
20
- } from "./chunk-D6XKDVAV.js";
23
+ resolveAccountView,
24
+ sdkFavoritesTransport,
25
+ useFavorites
26
+ } from "./chunk-ZEFZPUAW.js";
21
27
 
22
28
  // src/account-route-messages.ts
23
29
  var ACCOUNT_ROUTE_MESSAGE_KEYS = [
@@ -283,6 +289,43 @@ function badgesForProductPage(badges) {
283
289
  return badges.filter((badge) => badge.showOnProductPage);
284
290
  }
285
291
 
292
+ // src/service-status.ts
293
+ var SERVICE_STATES = ["operational", "updating", "detected", "offline"];
294
+ var STATE_ORDER = { detected: 0, offline: 1, updating: 2, operational: 3 };
295
+ function parseServiceStatus(value, context) {
296
+ if (value === null || value === void 0) return null;
297
+ if (typeof value !== "object" || Array.isArray(value)) {
298
+ throw new Error(`Invalid Shoppex ${context}.service_status: expected an object or null.`);
299
+ }
300
+ const record = value;
301
+ const state = record.state;
302
+ if (typeof state !== "string" || !SERVICE_STATES.includes(state)) {
303
+ throw new Error(`Invalid Shoppex ${context}.service_status.state: expected one of ${SERVICE_STATES.join(", ")}.`);
304
+ }
305
+ const updatedAt = record.updated_at;
306
+ if (typeof updatedAt !== "string" || Number.isNaN(Date.parse(updatedAt))) {
307
+ throw new Error(`Invalid Shoppex ${context}.service_status.updated_at: expected an ISO timestamp.`);
308
+ }
309
+ const note = typeof record.note === "string" && record.note.trim() ? record.note.trim() : null;
310
+ return { state, note, updatedAt };
311
+ }
312
+ function buildStatusPageModel(products) {
313
+ const entries = [];
314
+ const seen = /* @__PURE__ */ new Set();
315
+ for (const product of products) {
316
+ if (product.serviceStatus === null || seen.has(product.id)) continue;
317
+ seen.add(product.id);
318
+ entries.push({ product, status: product.serviceStatus });
319
+ }
320
+ if (entries.length === 0) return null;
321
+ entries.sort((a, b) => STATE_ORDER[a.status.state] - STATE_ORDER[b.status.state]);
322
+ const counts = { operational: 0, updating: 0, detected: 0, offline: 0 };
323
+ for (const entry of entries) counts[entry.status.state] += 1;
324
+ const presentStates = [...SERVICE_STATES].sort((a, b) => STATE_ORDER[a] - STATE_ORDER[b]).filter((state) => counts[state] > 0);
325
+ const lastUpdatedAt = entries.map((entry) => entry.status.updatedAt).reduce((latest, value) => Date.parse(value) > Date.parse(latest) ? value : latest);
326
+ return { entries, counts, presentStates, lastUpdatedAt };
327
+ }
328
+
286
329
  // src/video-embed.ts
287
330
  var ALLOWED_VIDEO_EMBED_HOSTS = /* @__PURE__ */ new Set([
288
331
  "www.youtube-nocookie.com",
@@ -372,11 +415,30 @@ function resolveProductVideoEmbedSrc(value) {
372
415
  var PRODUCT_TYPES = ["PRODUCT", "SUBSCRIPTION", "INFO_CARD", "SERIALS", "PHYSICAL"];
373
416
  function resolveDeliveryPromise(product) {
374
417
  if (product.productType === "PHYSICAL") return { kind: "shipped" };
418
+ if (!product.deliveryTimeStated) return { kind: "unspecified" };
375
419
  if (product.deliveryTimeCount !== null) {
376
420
  return { kind: "timed", count: product.deliveryTimeCount, unit: product.deliveryTimeUnit };
377
421
  }
378
422
  return { kind: "instant" };
379
423
  }
424
+ var DELIVERABLES_BY_TYPE = {
425
+ SERIALS: "serials",
426
+ FILE: "file",
427
+ SERVICE: "service",
428
+ DYNAMIC: "dynamic",
429
+ INFO_CARD: "info_card",
430
+ SUBSCRIPTION: "subscription",
431
+ PHYSICAL: "physical"
432
+ };
433
+ function parseDeliverable(value) {
434
+ const normalized = typeof value === "string" ? value.trim().toUpperCase() : "";
435
+ return DELIVERABLES_BY_TYPE[normalized] ?? null;
436
+ }
437
+ function resolveYouReceive(product) {
438
+ if (product.deliverable === "serials") return { kind: "serials", unitLabel: product.unitLabelSingular };
439
+ if (product.deliverable === "file") return { kind: "download" };
440
+ return null;
441
+ }
380
442
  var SUBSCRIPTION_INTERVAL_UNITS = ["DAY", "WEEK", "MONTH", "YEAR"];
381
443
  var SUBSCRIPTION_INTERVAL_NOUNS = {
382
444
  DAY: "day",
@@ -405,6 +467,25 @@ function parseSubscription(product, productType) {
405
467
  trialDays: normalizePositiveDays(product.trial_period) ?? 0
406
468
  };
407
469
  }
470
+ var PAYMENT_METHOD_KINDS = ["card", "wallet", "crypto"];
471
+ function parsePaymentMethods(value) {
472
+ if (!Array.isArray(value)) {
473
+ throw new Error("Invalid Shoppex store: payment_methods must be an array.");
474
+ }
475
+ return value.map((entry, index) => {
476
+ const context = `store.payment_methods[${index}]`;
477
+ const record = requireUnknownRecord(entry, context);
478
+ const kind = record.kind;
479
+ if (typeof kind !== "string" || !PAYMENT_METHOD_KINDS.includes(kind)) {
480
+ throw new Error(`Invalid Shoppex ${context}: kind must be card, wallet or crypto.`);
481
+ }
482
+ return {
483
+ id: requireString(record, "id", context),
484
+ label: requireString(record, "label", context),
485
+ kind
486
+ };
487
+ });
488
+ }
408
489
  function hasInjectedStorefrontData() {
409
490
  return window.__SHOPPEX_INITIAL__ !== void 0;
410
491
  }
@@ -583,7 +664,7 @@ function getStorefrontSettings() {
583
664
  ]),
584
665
  defaultCurrency,
585
666
  activeCurrency,
586
- availableGateways: (Array.isArray(store.available_gateways) ? store.available_gateways : []).filter((entry) => typeof entry === "string" && entry.trim() !== ""),
667
+ paymentMethods: parsePaymentMethods(store.payment_methods),
587
668
  cartEnabled: optionalBoolean(store.cart_enabled, true, "store.cart_enabled"),
588
669
  hideOutOfStock: optionalBoolean(store.hide_out_of_stock, false, "store.hide_out_of_stock"),
589
670
  hideStockCounter: optionalBoolean(store.hide_stock_counter, false, "store.hide_stock_counter"),
@@ -608,6 +689,7 @@ function getStorefrontSettings() {
608
689
  // visitor only found out from the backend's refusal. A payload without them
609
690
  // is broken, so it fails here instead.
610
691
  contactFormEnabled: requireBoolean(store.contact_form_enabled, "store.contact_form_enabled"),
692
+ stockAlertsEnabled: requireBoolean(store.stock_alerts_enabled, "store.stock_alerts_enabled"),
611
693
  requireInvoiceForContact: requireBoolean(
612
694
  store.require_invoice_for_contact,
613
695
  "store.require_invoice_for_contact"
@@ -724,13 +806,19 @@ function parseProduct(value, index) {
724
806
  rating: rating?.average ?? null,
725
807
  ratingCount: rating?.count ?? 0,
726
808
  ratingTally: rating?.tally ?? null,
809
+ shortDescription: optionalString(product.short_description),
810
+ deliverable: parseDeliverable(product.type),
811
+ unitLabelSingular: optionalString(product.label_singular),
812
+ unitLabelPlural: optionalString(product.label_plural),
813
+ serviceStatus: parseServiceStatus(product.service_status, context),
727
814
  highlights: parseHighlights(product.product_highlights),
728
815
  metadata: parseMetadata(product.metadata),
729
816
  descriptionTabs: parseContentTabs(product.description_tabs),
730
817
  faqs: parseFaqs(product.faqs),
731
818
  license: parseProductLicense(product),
732
819
  licensingEnabled: product.licensing_enabled === true || product.licensing_enabled === 1,
733
- deliveryTimeCount: normalizePositiveDays(product.delivery_time),
820
+ deliveryTimeStated: Object.prototype.hasOwnProperty.call(product, "delivery_time") && product.delivery_time !== void 0,
821
+ deliveryTimeCount: normalizeDeliveryCount(product.delivery_time),
734
822
  deliveryTimeUnit: normalizeDeliveryTimeUnit(product.delivery_time_unit),
735
823
  subscription: parseSubscription(product, productType)
736
824
  };
@@ -796,6 +884,7 @@ function parsePriceVariants(value, productContext) {
796
884
  return {
797
885
  id: requireString(variant, "id", context),
798
886
  label: requireStringFromKeys(variant, ["label", "title"], context),
887
+ description: optionalString(variant.description),
799
888
  license: parseVariantLicense(variant),
800
889
  price: unitPrice,
801
890
  baseUnitPrice: unitPrice,
@@ -1105,6 +1194,11 @@ function normalizeDiscountPercent(value) {
1105
1194
  if (!Number.isFinite(percent) || percent <= 0) return 0;
1106
1195
  return Math.min(percent, 100);
1107
1196
  }
1197
+ function normalizeDeliveryCount(value) {
1198
+ if (value === null || value === void 0 || value === "") return null;
1199
+ const parsed = Number(value);
1200
+ return Number.isFinite(parsed) && parsed > 0 ? Math.ceil(parsed) : null;
1201
+ }
1108
1202
  function normalizePositiveDays(value) {
1109
1203
  if (value === null || value === void 0 || value === "") return null;
1110
1204
  const parsed = Number(value);
@@ -2542,7 +2636,7 @@ function useNavigationLinks(slot, themeLinks, authored) {
2542
2636
  }
2543
2637
 
2544
2638
  // src/reviews.ts
2545
- import { useEffect as useEffect3, useState as useState3 } from "react";
2639
+ import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
2546
2640
  var SHOP_REVIEW_PAGE_SIZE = 12;
2547
2641
  function toTimestamp(value) {
2548
2642
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -2582,7 +2676,8 @@ function toShopReviews(rows, labels) {
2582
2676
  dateLabel: formatDateLabel(timestamp),
2583
2677
  timestamp,
2584
2678
  productTitle: row.product_title?.trim() || null,
2585
- verified: row.verified === true
2679
+ verified: row.verified === true,
2680
+ automated: row.is_automated === true
2586
2681
  };
2587
2682
  }).sort((a, b) => b.timestamp - a.timestamp);
2588
2683
  }
@@ -2591,20 +2686,83 @@ function reviewsForProduct(reviews, productTitle) {
2591
2686
  if (!title) return [];
2592
2687
  return reviews.filter((review) => review.productTitle === title);
2593
2688
  }
2594
- async function loadLatestWrittenReviews(limit, labels) {
2689
+ async function loadShopReviewsPage(options) {
2690
+ const product = options.product?.trim() || null;
2691
+ const result = await getShoppex().getShopReviewsPage(options.cursor ?? null, product ? { product } : void 0);
2692
+ if (!result.success || !result.data) {
2693
+ throw new Error(result.message?.trim() || "Reviews could not be loaded.");
2694
+ }
2695
+ if (product !== null && result.data.product !== product) {
2696
+ throw new Error("This store does not filter reviews by product yet.");
2697
+ }
2698
+ const { pagination } = result.data;
2699
+ return {
2700
+ reviews: toShopReviews(result.data.feedback, options.labels),
2701
+ nextCursor: pagination.has_more ? pagination.next_cursor : null,
2702
+ summary: result.data.summary ?? null
2703
+ };
2704
+ }
2705
+ async function loadLatestWrittenReviews(limit, labels, options = {}) {
2595
2706
  const written = [];
2596
2707
  const seen = /* @__PURE__ */ new Set();
2597
2708
  let cursor = null;
2598
- while (written.length < limit) {
2599
- const result = await getShoppex().getShopReviewsPage(cursor);
2600
- if (!result.success || !result.data) return null;
2601
- written.push(...result.data.feedback.filter((row) => !row.is_automated && (row.comment ?? row.message ?? "").trim() !== ""));
2602
- const next = result.data.pagination.has_more ? result.data.pagination.next_cursor : null;
2603
- if (!next || seen.has(next)) break;
2604
- seen.add(next);
2605
- cursor = next;
2709
+ try {
2710
+ while (written.length < limit) {
2711
+ const page = await loadShopReviewsPage({ cursor, product: options.product, labels: { anonymousAuthor: labels.anonymousAuthor, emptyComment: "" } });
2712
+ written.push(...page.reviews.filter((review) => !review.automated && review.comment !== ""));
2713
+ if (!page.nextCursor || seen.has(page.nextCursor)) break;
2714
+ seen.add(page.nextCursor);
2715
+ cursor = page.nextCursor;
2716
+ }
2717
+ } catch {
2718
+ return null;
2606
2719
  }
2607
- return toShopReviews(written, labels).slice(0, limit);
2720
+ return written.slice(0, limit).map((review) => ({ ...review, comment: review.comment || labels.emptyComment }));
2721
+ }
2722
+ function usePagedShopReviews(options) {
2723
+ const product = options.product?.trim() || null;
2724
+ const { anonymousAuthor, emptyComment } = options.labels;
2725
+ const [state, setState] = useState3({ status: "loading" });
2726
+ const [cursor, setCursor] = useState3(null);
2727
+ const generation = useRef2(0);
2728
+ useEffect3(() => {
2729
+ const current = generation.current += 1;
2730
+ setState({ status: "loading" });
2731
+ setCursor(null);
2732
+ loadShopReviewsPage({ product, labels: { anonymousAuthor, emptyComment } }).then((page) => {
2733
+ if (generation.current !== current) return;
2734
+ setCursor(page.nextCursor);
2735
+ setState({
2736
+ status: "ready",
2737
+ reviews: page.reviews,
2738
+ summary: page.summary,
2739
+ hasMore: page.nextCursor !== null,
2740
+ loadingMore: false,
2741
+ moreFailed: false
2742
+ });
2743
+ }).catch(() => {
2744
+ if (generation.current === current) setState({ status: "error" });
2745
+ });
2746
+ }, [product, anonymousAuthor, emptyComment]);
2747
+ const loadMore = useCallback2(() => {
2748
+ if (state.status !== "ready" || state.loadingMore || cursor === null) return;
2749
+ const current = generation.current;
2750
+ setState({ ...state, loadingMore: true, moreFailed: false });
2751
+ loadShopReviewsPage({ cursor, product, labels: { anonymousAuthor, emptyComment } }).then((page) => {
2752
+ if (generation.current !== current) return;
2753
+ setCursor(page.nextCursor);
2754
+ setState((previous) => previous.status !== "ready" ? previous : {
2755
+ ...previous,
2756
+ reviews: [...previous.reviews, ...page.reviews],
2757
+ hasMore: page.nextCursor !== null,
2758
+ loadingMore: false
2759
+ });
2760
+ }).catch(() => {
2761
+ if (generation.current !== current) return;
2762
+ setState((previous) => previous.status !== "ready" ? previous : { ...previous, loadingMore: false, moreFailed: true });
2763
+ });
2764
+ }, [state, cursor, product, anonymousAuthor, emptyComment]);
2765
+ return { ...state, loadMore };
2608
2766
  }
2609
2767
  function averageRating(reviews) {
2610
2768
  if (reviews.length === 0) return null;
@@ -2663,18 +2821,69 @@ async function buildShopPageModel(page) {
2663
2821
  };
2664
2822
  }
2665
2823
  async function loadShopPage(slug) {
2824
+ let result;
2825
+ try {
2826
+ result = await getShoppex().getPage(slug);
2827
+ } catch {
2828
+ return { status: "error" };
2829
+ }
2830
+ if (!result.success) return result.status === 404 ? { status: "missing" } : { status: "error" };
2831
+ const page = result.data;
2832
+ if (!page || typeof page.name !== "string" || !page.name.trim()) return { status: "missing" };
2666
2833
  try {
2667
- const result = await getShoppex().getPage(slug);
2668
- const page = result.success ? result.data : void 0;
2669
- if (!page || typeof page.name !== "string" || !page.name.trim()) {
2670
- return { status: "missing" };
2671
- }
2672
2834
  return { status: "ready", page: await buildShopPageModel(page) };
2673
2835
  } catch {
2674
- return { status: "missing" };
2836
+ return { status: "error" };
2675
2837
  }
2676
2838
  }
2677
2839
 
2840
+ // src/stock-alerts.ts
2841
+ function canRequestStockAlert(settings, product, soldOut) {
2842
+ return settings.stockAlertsEnabled && soldOut && !product.onHold && product.productType !== "INFO_CARD";
2843
+ }
2844
+ function bootstrapGlobal(name) {
2845
+ const value = window[name];
2846
+ if (typeof value !== "string" || !value.trim()) {
2847
+ throw new Error(
2848
+ `Shoppex bootstrap global "${name}" is missing. This storefront must be served by the Shoppex storefront (or preview) worker.`
2849
+ );
2850
+ }
2851
+ return value.trim().replace(/\/+$/, "");
2852
+ }
2853
+ function pageLocale() {
2854
+ const lang = typeof document === "undefined" ? "" : document.documentElement.lang.trim();
2855
+ return lang || null;
2856
+ }
2857
+ var GENERIC_FAILURE = "We could not save your request. Please try again.";
2858
+ async function requestStockAlert(request) {
2859
+ const email = request.email.trim();
2860
+ if (!email) return { ok: false, status: null, message: "Enter your email address." };
2861
+ let response;
2862
+ try {
2863
+ const shopSlug = bootstrapGlobal("shopSlug");
2864
+ const apiBaseUrl = bootstrapGlobal("apiBaseUrl");
2865
+ response = await fetch(
2866
+ `${apiBaseUrl}/v1/storefront/shops/name/${encodeURIComponent(shopSlug)}/products/${encodeURIComponent(request.productId)}/stock-alerts`,
2867
+ {
2868
+ method: "POST",
2869
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2870
+ body: JSON.stringify({
2871
+ email,
2872
+ variant_id: request.variantId,
2873
+ locale: pageLocale(),
2874
+ ...request.honeypot ? { website: request.honeypot } : {}
2875
+ })
2876
+ }
2877
+ );
2878
+ } catch {
2879
+ return { ok: false, status: null, message: GENERIC_FAILURE };
2880
+ }
2881
+ if (response.status === 202) return { ok: true };
2882
+ const payload = await response.json().catch(() => null);
2883
+ const serverMessage = typeof payload?.error === "string" && payload.error.trim() ? payload.error.trim() : typeof payload?.message === "string" && payload.message.trim() ? payload.message.trim() : null;
2884
+ return { ok: false, status: response.status, message: serverMessage ?? GENERIC_FAILURE };
2885
+ }
2886
+
2678
2887
  // src/storefront-context.tsx
2679
2888
  import { createContext as createContext2, useContext as useContext2, useEffect as useEffect4, useState as useState4 } from "react";
2680
2889
  import { jsx as jsx2 } from "react/jsx-runtime";
@@ -2721,10 +2930,13 @@ export {
2721
2930
  DEFAULT_ACCOUNT_TAB,
2722
2931
  DEFAULT_LOW_STOCK_THRESHOLD,
2723
2932
  DELIVERY_TIME_UNITS,
2933
+ FAVORITES_CHANGED_EVENT,
2934
+ FavoritesProvider,
2724
2935
  IMPLEMENTED_ACCOUNT_TABS,
2725
2936
  MAX_PAY_WHAT_YOU_WANT_PRICE,
2726
2937
  PRODUCT_BADGE_ICON_NAMES,
2727
2938
  QUOTE_UNAVAILABLE_CHECKOUT_ERROR,
2939
+ SERVICE_STATES,
2728
2940
  SERVING_CONTEXT_HINT,
2729
2941
  SHOP_REVIEW_PAGE_SIZE,
2730
2942
  STOREFRONT_SORT_KEYS,
@@ -2741,15 +2953,18 @@ export {
2741
2953
  buildCustomFieldPayload,
2742
2954
  buildInitialCheckoutFieldValues,
2743
2955
  buildProductDetail,
2956
+ buildStatusPageModel,
2744
2957
  buildStorefrontCatalog,
2745
2958
  calculateDisplayLineTotal,
2746
2959
  calculateLinePricing,
2960
+ canRequestStockAlert,
2747
2961
  canonicalAccountPath,
2748
2962
  checkoutFieldKey,
2749
2963
  checkoutFieldWriteRepriced,
2750
2964
  checkoutNeedsDetails,
2751
2965
  clampQuantity,
2752
2966
  clearRecoveredQuoteCheckoutError,
2967
+ createFavoritesStore,
2753
2968
  filterCatalogCards,
2754
2969
  findStorefrontProduct,
2755
2970
  firstOptionalString,
@@ -2781,7 +2996,9 @@ export {
2781
2996
  isSelectionSoldOut,
2782
2997
  loadLatestWrittenReviews,
2783
2998
  loadShopPage,
2999
+ loadShopReviewsPage,
2784
3000
  missingRequiredCheckoutValues,
3001
+ notifyFavoritesChanged,
2785
3002
  optionalBoolean,
2786
3003
  optionalString,
2787
3004
  optionalStringWithDefault,
@@ -2791,9 +3008,11 @@ export {
2791
3008
  parseEnum,
2792
3009
  parseFiniteNumber,
2793
3010
  parseProductBadges,
3011
+ parseServiceStatus,
2794
3012
  parseStorefrontSortKey,
2795
3013
  readCheckoutNeedsDetails,
2796
3014
  readStorefrontData,
3015
+ requestStockAlert,
2797
3016
  requireArray,
2798
3017
  requireBoolean,
2799
3018
  requireNumber,
@@ -2817,9 +3036,11 @@ export {
2817
3036
  resolveSupportChannels,
2818
3037
  resolveUnsellableLineIds,
2819
3038
  resolveVariantStock,
3039
+ resolveYouReceive,
2820
3040
  reviewsForProduct,
2821
3041
  roundCurrency,
2822
3042
  sanitizeMerchantHtml,
3043
+ sdkFavoritesTransport,
2823
3044
  sortCatalogCards,
2824
3045
  startCartMutation,
2825
3046
  stripMerchantHtmlImages,
@@ -2827,8 +3048,10 @@ export {
2827
3048
  toShopReviews,
2828
3049
  toStarCount,
2829
3050
  useCart,
3051
+ useFavorites,
2830
3052
  useNavigationLinks,
2831
3053
  useOnlineUserCount,
3054
+ usePagedShopReviews,
2832
3055
  useShopReviews,
2833
3056
  useStorefront,
2834
3057
  validateCheckoutFieldValue,