@wix/agent-skills 1.19.2 → 1.20.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.
Files changed (75) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/references/SERVICE_PLUGIN.md +14 -1
  3. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +7 -6
  4. package/skills/wix-app/references/service-plugin/ADDITIONAL-FEES.md +4 -0
  5. package/skills/wix-app/references/service-plugin/AVAILABILITY-TIME-SLOTS-CONFIGURATION.md +118 -0
  6. package/skills/wix-app/references/service-plugin/BOOKING-POLICY.md +65 -0
  7. package/skills/wix-app/references/service-plugin/BOOKINGS-PRICING.md +71 -0
  8. package/skills/wix-app/references/service-plugin/BOOKINGS-STAFF-SORTING.md +11 -3
  9. package/skills/wix-app/references/service-plugin/BOOKINGS-VALIDATION.md +137 -0
  10. package/skills/wix-app/references/service-plugin/DISCOUNT-TRIGGERS.md +23 -16
  11. package/skills/wix-app/references/service-plugin/GIFT-CARDS.md +4 -0
  12. package/skills/wix-app/references/service-plugin/PAYMENT-SETTINGS.md +4 -2
  13. package/skills/wix-app/references/service-plugin/REALTIME-PERMISSIONS.md +45 -0
  14. package/skills/wix-app/references/service-plugin/SHIPPING-RATES.md +15 -0
  15. package/skills/wix-app/references/service-plugin/TOOLS_PROVIDER.md +4 -0
  16. package/skills/wix-app/references/service-plugin/VALIDATIONS.md +4 -0
  17. package/skills/wix-headless/references/CAPABILITIES.md +2 -2
  18. package/skills/wix-headless/references/inline-recipes/experience-store.md +4 -3
  19. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +98 -18
  20. package/skills/wix-headless/references/managed/DEPLOYMENT.md +8 -8
  21. package/skills/wix-headless-fast/install/fast-path.mjs +3 -1
  22. package/skills/wix-headless-fast/references/shared/app/wix/media.ts +13 -0
  23. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +227 -56
  24. package/skills/wix-headless-fast/references/storefront/app/components/storefront/CartDrawer.tsx +37 -3
  25. package/skills/wix-headless-fast/references/storefront/app/components/storefront/FilterPanel.tsx +266 -0
  26. package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +165 -0
  27. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +48 -15
  28. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +129 -11
  29. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +36 -6
  30. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +190 -24
  31. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +62 -4
  32. package/skills/wix-headless-fast/references/storefront/app-astro/layouts/SiteLayout.astro +21 -2
  33. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +5 -1
  34. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +59 -6
  35. package/skills/wix-manage/SKILL.md +4 -1
  36. package/skills/wix-manage/references/events/create-wix-event.md +46 -0
  37. package/skills/wix-manage/references/events/manage-wix-events.md +51 -1
  38. package/skills/wix-manage/references/google-ads/manage-campaign-lifecycle.md +6 -6
  39. package/skills/wix-manage/references/seo/manage-seo-suggestions.md +351 -0
  40. package/skills/wix-vibe-headless/install/deploy.cjs +33 -2
  41. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +1 -1
  42. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +13 -4
  43. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
  44. package/skills/wix-vibe-headless/platforms/base44.md +19 -4
  45. package/skills/wix-vibe-headless/platforms/generic.md +6 -0
  46. package/skills/wix-vibe-headless/references/_shared/nav/nav.tanstack.js +30 -0
  47. package/skills/wix-vibe-headless/references/_shared/routing.md +86 -0
  48. package/skills/wix-vibe-headless/references/blog/INSTRUCTIONS.md +44 -0
  49. package/skills/wix-vibe-headless/references/blog/app/components/PostCard.jsx +1 -1
  50. package/skills/wix-vibe-headless/references/blog/app/components/PostChips.jsx +1 -1
  51. package/skills/wix-vibe-headless/references/blog/app/pages/CategoryPage.jsx +1 -1
  52. package/skills/wix-vibe-headless/references/blog/app/pages/PostDetail.jsx +1 -1
  53. package/skills/wix-vibe-headless/references/blog/app/pages/TagPage.jsx +1 -1
  54. package/skills/wix-vibe-headless/references/bookings/INSTRUCTIONS.md +42 -0
  55. package/skills/wix-vibe-headless/references/bookings/app/components/ServiceCard.jsx +1 -1
  56. package/skills/wix-vibe-headless/references/bookings/app/pages/ServiceDetail.jsx +1 -1
  57. package/skills/wix-vibe-headless/references/events/INSTRUCTIONS.md +42 -0
  58. package/skills/wix-vibe-headless/references/events/app/components/EventCard.jsx +1 -1
  59. package/skills/wix-vibe-headless/references/events/app/pages/EventDetail.jsx +1 -1
  60. package/skills/wix-vibe-headless/references/members/INSTRUCTIONS.md +42 -0
  61. package/skills/wix-vibe-headless/references/members/app/components/MemberMenu.jsx +1 -1
  62. package/skills/wix-vibe-headless/references/members/app/components/RequireAuth.jsx +1 -1
  63. package/skills/wix-vibe-headless/references/members/app/pages/Login.jsx +1 -1
  64. package/skills/wix-vibe-headless/references/portfolio/INSTRUCTIONS.md +43 -0
  65. package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionCard.jsx +1 -1
  66. package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectCard.jsx +1 -1
  67. package/skills/wix-vibe-headless/references/portfolio/app/pages/CollectionPage.jsx +1 -1
  68. package/skills/wix-vibe-headless/references/portfolio/app/pages/ProjectDetail.jsx +1 -1
  69. package/skills/wix-vibe-headless/references/pricing-plans/INSTRUCTIONS.md +43 -0
  70. package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanCard.jsx +1 -1
  71. package/skills/wix-vibe-headless/references/pricing-plans/app/pages/PlanDetail.jsx +1 -1
  72. package/skills/wix-vibe-headless/references/restaurants/INSTRUCTIONS.md +36 -0
  73. package/skills/wix-vibe-headless/references/shared/app/lib/nav.js +9 -0
  74. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +43 -2
  75. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +7 -1
@@ -1,18 +1,24 @@
1
- // Catalog listing for a shop surface: category filter, sort, price/stock/search filters, and
2
- // cursor paging — all applied by Wix across the whole catalog (searchCatalog), never to a page
3
- // already in hand. A changed selection starts a fresh cursor chain.
1
+ // Catalog listing for a shop or category surface: category, sort, price/stock/search filters,
2
+ // option facets, and cursor paging — all applied by Wix across the whole catalog (searchCatalog),
3
+ // never to a page already in hand. A changed selection starts a fresh cursor chain.
4
+ //
5
+ // URL state: sort, filters, facet choices (and a live category switch on /shop) are read from the
6
+ // query string on mount and written back with replaceState — a filtered gallery is a link a
7
+ // shopper can share, reload, and step back to. Paging stays out of the URL.
4
8
  //
5
9
  // SSR-friendly: pass `initialProducts`/`initialCategories` (Astro frontmatter) and they render
6
10
  // immediately — the hook still revalidates the first page once on mount to open the cursor
7
- // chain, replacing the seed without a skeleton flash.
11
+ // chain, replacing the seed without a skeleton flash. A /category/[slug] page passes
12
+ // `initialCategoryId` so the first query is already scoped.
8
13
  import { useCallback, useEffect, useRef, useState } from "react";
9
14
  import {
10
15
  CATALOG_SORTS,
11
16
  fetchCategories,
17
+ fetchFacetData,
12
18
  searchCatalog,
13
19
  type CatalogSort,
14
20
  } from "../../wix/storefront/catalog";
15
- import type { Category, ProductSummary } from "../../wix/storefront/types";
21
+ import type { Category, Facet, PriceRange, ProductSummary } from "../../wix/storefront/types";
16
22
 
17
23
  export const SORTS = CATALOG_SORTS;
18
24
 
@@ -27,12 +33,16 @@ export interface ShopFilters {
27
33
  export interface UseShopOptions {
28
34
  initialProducts?: ProductSummary[];
29
35
  initialCategories?: Category[];
36
+ /** Scope the listing to one category from the first render (a /category/[slug] page). */
37
+ initialCategoryId?: string | null;
30
38
  pageSize?: number;
31
39
  }
32
40
 
33
41
  export interface UseShop {
34
42
  /** null while the first load is in flight — render skeletons, not an empty state. */
35
43
  products: ProductSummary[] | null;
44
+ /** Matching products across the whole catalog for the current selection; null until known. */
45
+ total: number | null;
36
46
  categories: Category[];
37
47
  /** null = "all products". */
38
48
  activeCategoryId: string | null;
@@ -41,6 +51,17 @@ export interface UseShop {
41
51
  setSort: (sort: CatalogSort) => void;
42
52
  filters: ShopFilters;
43
53
  setFilters: (filters: ShopFilters) => void;
54
+ /** The filterable options of the current scope (Color, Size…), from the catalog itself. */
55
+ facets: Facet[];
56
+ /** Lowest and highest product price in the scope — the price slider's bounds; null until known or when all prices are equal. */
57
+ priceRange: PriceRange | null;
58
+ /** Selected facet choice ids — products carrying ANY of them match. */
59
+ selectedChoiceIds: string[];
60
+ toggleChoice: (choiceId: string) => void;
61
+ /** Clears price/stock/search filters and facet selections (keeps category and sort). */
62
+ clearFilters: () => void;
63
+ /** True when any filter or facet is active. */
64
+ hasActiveFilters: boolean;
44
65
  loading: boolean;
45
66
  error: string | null;
46
67
  retry: () => void;
@@ -49,9 +70,29 @@ export interface UseShop {
49
70
  loadingMore: boolean;
50
71
  }
51
72
 
73
+ const URL_KEYS = { sort: "sort", min: "min", max: "max", stock: "stock", q: "q", choice: "choice", category: "category" } as const;
74
+
75
+ function readUrlState(): { sort?: CatalogSort; filters: ShopFilters; choiceIds: string[]; categoryId?: string | null } | null {
76
+ if (typeof window === "undefined") return null;
77
+ const p = new URLSearchParams(window.location.search);
78
+ const sort = p.get(URL_KEYS.sort);
79
+ const filters: ShopFilters = {};
80
+ if (p.get(URL_KEYS.min)) filters.minPrice = p.get(URL_KEYS.min)!;
81
+ if (p.get(URL_KEYS.max)) filters.maxPrice = p.get(URL_KEYS.max)!;
82
+ if (p.get(URL_KEYS.stock) === "1") filters.inStockOnly = true;
83
+ if (p.get(URL_KEYS.q)) filters.search = p.get(URL_KEYS.q)!;
84
+ return {
85
+ sort: sort && sort in CATALOG_SORTS ? (sort as CatalogSort) : undefined,
86
+ filters,
87
+ choiceIds: p.getAll(URL_KEYS.choice),
88
+ categoryId: p.has(URL_KEYS.category) ? p.get(URL_KEYS.category) : undefined,
89
+ };
90
+ }
91
+
52
92
  interface PageState {
53
93
  key: string | null;
54
94
  products: ProductSummary[] | null;
95
+ total: number | null;
55
96
  cursor: string | null;
56
97
  error: string | null;
57
98
  loadingMore: boolean;
@@ -60,22 +101,39 @@ interface PageState {
60
101
  export function useShop({
61
102
  initialProducts,
62
103
  initialCategories,
104
+ initialCategoryId = null,
63
105
  pageSize = 24,
64
106
  }: UseShopOptions = {}): UseShop {
65
107
  const [categories, setCategories] = useState<Category[]>(initialCategories ?? []);
66
- const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
108
+ const [activeCategoryId, setActiveCategoryId] = useState<string | null>(initialCategoryId);
67
109
  const [sort, setSort] = useState<CatalogSort>("featured");
68
110
  const [filters, setFilters] = useState<ShopFilters>({});
111
+ const [selectedChoiceIds, setSelectedChoiceIds] = useState<string[]>([]);
112
+ const [facets, setFacets] = useState<Facet[]>([]);
113
+ const [priceRange, setPriceRange] = useState<PriceRange | null>(null);
69
114
  const [attempt, setAttempt] = useState(0);
70
115
  const [page, setPage] = useState<PageState>({
71
116
  key: null,
72
117
  products: initialProducts ?? null,
118
+ total: null,
73
119
  cursor: null,
74
120
  error: null,
75
121
  loadingMore: false,
76
122
  });
77
123
  const generation = useRef(0);
78
124
  const pendingMore = useRef<object | null>(null);
125
+ const firstWrite = useRef(true);
126
+
127
+ // Adopt the URL's selection once, after hydration (the server rendered the defaults).
128
+ useEffect(() => {
129
+ const u = readUrlState();
130
+ if (!u) return;
131
+ if (u.sort) setSort(u.sort);
132
+ if (Object.keys(u.filters).length) setFilters(u.filters);
133
+ if (u.choiceIds.length) setSelectedChoiceIds(u.choiceIds);
134
+ if (u.categoryId !== undefined && u.categoryId !== initialCategoryId) setActiveCategoryId(u.categoryId || null);
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, []);
79
137
 
80
138
  const key = JSON.stringify([
81
139
  pageSize,
@@ -85,11 +143,35 @@ export function useShop({
85
143
  filters.maxPrice ?? null,
86
144
  !!filters.inStockOnly,
87
145
  filters.search ?? "",
146
+ [...selectedChoiceIds].sort(),
88
147
  attempt,
89
148
  ]);
90
149
  const currentKey = useRef(key);
91
150
  currentKey.current = key;
92
151
 
152
+ // Mirror the selection into the query string (defaults are omitted; nothing else in the URL is touched).
153
+ useEffect(() => {
154
+ if (typeof window === "undefined") return;
155
+ // The mount run still holds the defaults (the adopted URL state lands on the next render) —
156
+ // writing now would erase the very params being adopted.
157
+ if (firstWrite.current) {
158
+ firstWrite.current = false;
159
+ return;
160
+ }
161
+ const p = new URLSearchParams(window.location.search);
162
+ for (const k of Object.values(URL_KEYS)) p.delete(k);
163
+ if (sort !== "featured") p.set(URL_KEYS.sort, sort);
164
+ if (filters.minPrice != null && filters.minPrice !== "") p.set(URL_KEYS.min, String(filters.minPrice));
165
+ if (filters.maxPrice != null && filters.maxPrice !== "") p.set(URL_KEYS.max, String(filters.maxPrice));
166
+ if (filters.inStockOnly) p.set(URL_KEYS.stock, "1");
167
+ if (filters.search?.trim()) p.set(URL_KEYS.q, filters.search.trim());
168
+ for (const id of selectedChoiceIds) p.append(URL_KEYS.choice, id);
169
+ if (activeCategoryId !== initialCategoryId) p.set(URL_KEYS.category, activeCategoryId ?? "");
170
+ const qs = p.toString();
171
+ const next = `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`;
172
+ if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}`) window.history.replaceState(window.history.state, "", next);
173
+ }, [key, sort, filters, selectedChoiceIds, activeCategoryId, initialCategoryId]);
174
+
93
175
  useEffect(() => {
94
176
  if (initialCategories) return;
95
177
  let alive = true;
@@ -100,18 +182,31 @@ export function useShop({
100
182
  // eslint-disable-next-line react-hooks/exhaustive-deps
101
183
  }, []);
102
184
 
185
+ // Facets follow the category scope (a Size facet in "Donuts" is meaningless).
186
+ useEffect(() => {
187
+ let alive = true;
188
+ fetchFacetData({ categoryId: activeCategoryId }).then((d) => {
189
+ if (!alive) return;
190
+ setFacets(d.facets);
191
+ setPriceRange(d.priceRange);
192
+ });
193
+ return () => {
194
+ alive = false;
195
+ };
196
+ }, [activeCategoryId]);
197
+
103
198
  useEffect(() => {
104
199
  const id = ++generation.current;
105
200
  let alive = true;
106
201
  pendingMore.current = null;
107
202
  // Keep the SSR seed (or the previous page) on screen while the fresh query runs;
108
203
  // only a selection change with no seed shows skeletons.
109
- setPage((p) => ({ key, products: p.key === null ? p.products : null, cursor: null, error: null, loadingMore: false }));
110
- const [limit, categoryId, selectedSort, minPrice, maxPrice, inStockOnly, search] = JSON.parse(key);
111
- searchCatalog({ limit, categoryId, sort: selectedSort, minPrice, maxPrice, inStockOnly, search })
204
+ setPage((p) => ({ key, products: p.key === null ? p.products : null, total: null, cursor: null, error: null, loadingMore: false }));
205
+ const [limit, categoryId, selectedSort, minPrice, maxPrice, inStockOnly, search, choiceIds] = JSON.parse(key);
206
+ searchCatalog({ limit, categoryId, sort: selectedSort, minPrice, maxPrice, inStockOnly, search, choiceIds })
112
207
  .then((res) => {
113
208
  if (alive && generation.current === id && currentKey.current === key) {
114
- setPage({ key, products: res.products, cursor: res.nextCursor, error: null, loadingMore: false });
209
+ setPage({ key, products: res.products, total: res.total, cursor: res.nextCursor, error: null, loadingMore: false });
115
210
  }
116
211
  })
117
212
  .catch((e) => {
@@ -119,6 +214,7 @@ export function useShop({
119
214
  setPage({
120
215
  key,
121
216
  products: [],
217
+ total: null,
122
218
  cursor: null,
123
219
  error: e instanceof Error ? e.message : "Couldn't load products.",
124
220
  loadingMore: false,
@@ -166,12 +262,29 @@ export function useShop({
166
262
  }, [key, page.key, page.cursor, pageSize]);
167
263
 
168
264
  const retry = useCallback(() => setAttempt((n) => n + 1), []);
265
+ const toggleChoice = useCallback(
266
+ (choiceId: string) =>
267
+ setSelectedChoiceIds((ids) => (ids.includes(choiceId) ? ids.filter((x) => x !== choiceId) : [...ids, choiceId])),
268
+ [],
269
+ );
270
+ const clearFilters = useCallback(() => {
271
+ setFilters({});
272
+ setSelectedChoiceIds([]);
273
+ }, []);
169
274
 
170
275
  const current = page.key === key;
171
276
  const seeded = page.key === null && page.products !== null; // SSR seed, first query in flight
172
277
  // Seeded content on screen is not "loading" — skeletons are only for a genuinely empty wait.
173
278
  const loading = (!current && !seeded) || (current && page.products === null);
279
+ const hasActiveFilters =
280
+ selectedChoiceIds.length > 0 ||
281
+ !!filters.inStockOnly ||
282
+ filters.minPrice != null && filters.minPrice !== "" ||
283
+ filters.maxPrice != null && filters.maxPrice !== "" ||
284
+ !!(filters.search && filters.search.trim());
174
285
  return {
286
+ products: current || seeded ? page.products : null,
287
+ total: current ? page.total : null,
175
288
  categories,
176
289
  activeCategoryId,
177
290
  setActiveCategoryId,
@@ -179,7 +292,12 @@ export function useShop({
179
292
  setSort,
180
293
  filters,
181
294
  setFilters,
182
- products: current || seeded ? page.products : null,
295
+ facets,
296
+ priceRange,
297
+ selectedChoiceIds,
298
+ toggleChoice,
299
+ clearFilters,
300
+ hasActiveFilters,
183
301
  loading,
184
302
  error: current ? page.error : null,
185
303
  retry,
@@ -65,6 +65,21 @@ function cartCurrency(raw: RawCart | null): string {
65
65
  return raw?.customerInfo?.currencyCode ?? raw?.businessInfo?.currencyCode ?? "";
66
66
  }
67
67
 
68
+ // "Monthly plan · every 2 months · 6 payments" from the line's subscriptionInfo (Cart V2 carries the
69
+ // plan on the line); "" for a one-time purchase. Never invented — every word comes from the plan.
70
+ function subscriptionTerms(info: RawCart | undefined): string {
71
+ if (!info) return "";
72
+ const s: RawCart = info.subscriptionSettings ?? {};
73
+ const unit: Record<string, string> = { DAY: "day", WEEK: "week", MONTH: "month", YEAR: "year" };
74
+ const parts: string[] = [];
75
+ const title = info.title?.original ?? info.title?.translated ?? "";
76
+ if (title) parts.push(title);
77
+ const u = unit[String(s.frequency ?? "")];
78
+ if (u) parts.push(`every ${s.interval && s.interval > 1 ? `${s.interval} ${u}s` : u}`);
79
+ if (!s.autoRenewal && s.billingCycles) parts.push(`${s.billingCycles} payments`);
80
+ return parts.join(" · ");
81
+ }
82
+
68
83
  function toLine(raw: RawCart, currency: string): CartLine {
69
84
  const descriptionLines: string[] = (raw.attributes?.descriptionLines ?? [])
70
85
  .map((d: RawCart) => {
@@ -82,32 +97,41 @@ function toLine(raw: RawCart, currency: string): CartLine {
82
97
  imageUrl: imgSrc(raw.attributes?.image, 300, 300),
83
98
  descriptionLines,
84
99
  status: raw.status ?? "IN_STOCK",
100
+ subscription: subscriptionTerms(raw.subscriptionInfo),
85
101
  };
86
102
  }
87
103
 
88
- function toCart(raw: RawCart | null, subtotal: string): Cart {
104
+ function toCart(raw: RawCart | null, subtotal: string, discount = ""): Cart {
89
105
  const currency = cartCurrency(raw);
90
106
  const lines = (raw?.lineItems ?? []).map((l: RawCart) => toLine(l, currency));
91
107
  return {
92
108
  lines,
93
109
  itemCount: lines.reduce((sum: number, l: CartLine) => sum + l.quantity, 0),
94
110
  subtotal,
111
+ discount,
95
112
  currency,
96
113
  };
97
114
  }
98
115
 
99
116
  async function readCartWithSubtotal(raw: RawCart | null): Promise<Cart> {
117
+ // Only estimate a cart WITH lines — on an absent/empty cart the endpoint returns 404.
100
118
  if (!raw?.lineItems?.length) return toCart(raw, "");
101
- // The authoritative after-discount subtotal comes from the cart estimate, never from
102
- // hand-summing line items (tax and shipping resolve at checkout).
119
+ // The authoritative after-discount subtotal and the CART-level discount come from the cart
120
+ // estimate, never from hand-summing lines. Its delivery/tax/fees read "0" when nothing was
121
+ // calculated — never render those as "Free"; shipping and tax resolve at checkout.
103
122
  let subtotal = "";
123
+ let discount = "";
104
124
  try {
105
125
  const estimate: RawCart = await cartApi.estimateCurrentCart();
106
- subtotal = formatMoney(estimate?.summary?.priceSummary?.subtotal, cartCurrency(raw));
126
+ const summary = estimate?.summary?.priceSummary;
127
+ const currency = cartCurrency(raw);
128
+ subtotal = formatMoney(summary?.subtotal, currency);
129
+ const d = summary?.discount;
130
+ if (Number(d?.convertedAmount ?? d?.amount ?? 0) > 0) discount = formatMoney(d, currency);
107
131
  } catch {
108
132
  /* estimate is a display nicety — the cart itself is still valid */
109
133
  }
110
- const cart = toCart(raw, subtotal);
134
+ const cart = toCart(raw, subtotal, discount);
111
135
  await fillLineImages(cart.lines, raw.lineItems as RawCart[]);
112
136
  return cart;
113
137
  }
@@ -127,6 +151,10 @@ export interface AddToCartExtras {
127
151
  modifierChoices?: Record<string, string>;
128
152
  /** FREE_TEXT modifier inputs: freeTextSettings key -> the buyer's text. */
129
153
  customTextFields?: Record<string, string>;
154
+ /** The variant is out of stock but pre-orderable — sends preOrderRequested. */
155
+ preorder?: boolean;
156
+ /** A chosen recurring plan (subscriptionPricesInfo); omit for a one-time purchase. */
157
+ subscriptionOptionId?: string;
130
158
  }
131
159
 
132
160
  /**
@@ -138,12 +166,14 @@ export async function addToCart(
138
166
  productId: string,
139
167
  variantId?: string | null,
140
168
  quantity = 1,
141
- { modifierChoices, customTextFields }: AddToCartExtras = {},
169
+ { modifierChoices, customTextFields, preorder, subscriptionOptionId }: AddToCartExtras = {},
142
170
  ): Promise<Cart> {
143
171
  const options: Record<string, unknown> = {};
144
172
  if (variantId) options.variantId = variantId;
145
173
  if (modifierChoices && Object.keys(modifierChoices).length) options.options = modifierChoices;
146
174
  if (customTextFields && Object.keys(customTextFields).length) options.customTextFields = customTextFields;
175
+ if (subscriptionOptionId) options.subscriptionOptionId = subscriptionOptionId;
176
+ if (preorder) options.preOrderRequested = true;
147
177
 
148
178
  const { cart } = await cartApi.addLineItemsToCurrentCart({
149
179
  catalogItems: [
@@ -10,6 +10,8 @@ import { wixModule } from "../sdk";
10
10
  import { imgSrc } from "../media";
11
11
  import type {
12
12
  Category,
13
+ Facet,
14
+ FacetData,
13
15
  ProductDetail,
14
16
  ProductOption,
15
17
  ProductModifier,
@@ -24,11 +26,43 @@ const categories = wixModule(categoriesModule);
24
26
  // Requested on every product read. CURRENCY populates formattedAmount (without it, prices render
25
27
  // as bare unlocalized numbers); MEDIA_ITEMS_INFO populates the gallery; the detail read adds
26
28
  // PLAIN_DESCRIPTION and VARIANT_OPTION_CHOICE_NAMES (which populates variantsInfo.variants).
27
- const LIST_FIELDS = ["CURRENCY", "MEDIA_ITEMS_INFO"];
28
- const DETAIL_FIELDS = [...LIST_FIELDS, "PLAIN_DESCRIPTION", "VARIANT_OPTION_CHOICE_NAMES"];
29
+ // MIN_PRICE_VARIANT + DISCOUNT_INFO → variantSummary.minPriceVariant with its discounted price:
30
+ // a card's real price and the direct-add variant id, without a per-product fetch.
31
+ const LIST_FIELDS = ["CURRENCY", "MEDIA_ITEMS_INFO", "MIN_PRICE_VARIANT", "DISCOUNT_INFO"];
32
+ // INFO_SECTION + INFO_SECTION_PLAIN_DESCRIPTION → infoSections[] with HTML bodies.
33
+ const DETAIL_FIELDS = [
34
+ ...LIST_FIELDS,
35
+ "PLAIN_DESCRIPTION",
36
+ "VARIANT_OPTION_CHOICE_NAMES",
37
+ "INFO_SECTION",
38
+ "INFO_SECTION_PLAIN_DESCRIPTION",
39
+ ];
29
40
 
30
41
  type RawProduct = Record<string, any>;
31
42
 
43
+ /**
44
+ * The price the buyer pays, and the price to strike — exact precedence, shared by cards and
45
+ * variants. An automatic discount (priceAfterDiscount, present when DISCOUNT_INFO is requested)
46
+ * wins and strikes the regular actualPrice; otherwise actualPrice with the merchant's
47
+ * compareAtPrice as the "was". `!== undefined` on purpose: a discounted price of 0 is a real price.
48
+ */
49
+ export function sellingPrice(price: RawProduct | undefined): { current?: RawProduct; original?: RawProduct } {
50
+ if (price?.priceAfterDiscount !== undefined) return { current: price.priceAfterDiscount, original: price.actualPrice };
51
+ return { current: price?.actualPrice, original: price?.compareAtPrice };
52
+ }
53
+
54
+ /** The struck price only when it is real and higher than what the buyer pays. */
55
+ function strike(original: RawProduct | undefined, current: RawProduct | undefined): string | null {
56
+ const o = Number(original?.amount), c = Number(current?.amount);
57
+ return original?.formattedAmount && Number.isFinite(o) && Number.isFinite(c) && o > c ? original.formattedAmount : null;
58
+ }
59
+
60
+ /** Every merchant ribbon label, primary first — a ribbon is a label, never proof of a price. */
61
+ function ribbonsOf(raw: RawProduct): string[] {
62
+ return [raw.ribbon?.name, ...((raw.additionalRibbons ?? []) as RawProduct[]).map((r) => r?.name)]
63
+ .filter((n, i, all): n is string => typeof n === "string" && n.length > 0 && all.indexOf(n) === i);
64
+ }
65
+
32
66
  function toAvailability(raw: RawProduct): Availability {
33
67
  const status = raw.inventory?.availabilityStatus;
34
68
  return status === "OUT_OF_STOCK" || status === "PARTIALLY_OUT_OF_STOCK"
@@ -36,13 +70,39 @@ function toAvailability(raw: RawProduct): Availability {
36
70
  : "IN_STOCK";
37
71
  }
38
72
 
73
+ // The identity of a media entry BEFORE scaling — two scaled URLs of one photo differ in their
74
+ // size parameters, so de-duplicating on resolved URLs shows the same image twice.
75
+ function mediaKey(m: RawProduct | undefined): string {
76
+ const v = m?.image ?? m?.url ?? m;
77
+ if (!v) return "";
78
+ if (typeof v === "string") return v.split("#")[0];
79
+ return v.id ?? v.url ?? "";
80
+ }
81
+
82
+ // Every distinct media entry, main first, keyed on identity — shared by the tile (hover image)
83
+ // and the PDP (gallery) so both see the same photos in the same order.
84
+ function mediaEntries(raw: RawProduct): RawProduct[] {
85
+ const out: RawProduct[] = [];
86
+ const seen = new Set<string>();
87
+ for (const m of [raw.media?.main, ...((raw.media?.itemsInfo?.items ?? []) as RawProduct[])]) {
88
+ const k = mediaKey(m);
89
+ if (!k || seen.has(k)) continue;
90
+ seen.add(k);
91
+ out.push(m);
92
+ }
93
+ return out;
94
+ }
95
+
39
96
  function toSummary(raw: RawProduct): ProductSummary {
40
97
  const options: RawProduct[] = raw.options ?? [];
41
- const galleryItems: RawProduct[] = raw.media?.itemsInfo?.items ?? [];
42
- const mainUrl = imgSrc(raw.media?.main, 800, 800);
43
- const hover = galleryItems
44
- .map((m) => imgSrc(m.image ?? m, 800, 800))
45
- .filter((u) => u && u !== mainUrl);
98
+ const media = mediaEntries(raw);
99
+ const mainUrl = imgSrc(media[0], 800, 800);
100
+ const hover = media.slice(1).map((m) => imgSrc(m, 800, 800)).filter(Boolean);
101
+ const swatches = options
102
+ .filter((o) => o.optionRenderType === "SWATCH_CHOICES" || o.optionRenderType === "COLOR_CHOICES")
103
+ .flatMap((o) => (o.choicesSettings?.choices ?? []) as RawProduct[])
104
+ .filter((c) => c.visible !== false && c.colorCode)
105
+ .map((c) => String(c.colorCode));
46
106
  const availability = toAvailability(raw);
47
107
  const preorder = raw.inventory?.preorderStatus === "ENABLED" && availability === "OUT_OF_STOCK";
48
108
  const optionsSummary = options
@@ -51,19 +111,30 @@ function toSummary(raw: RawProduct): ProductSummary {
51
111
  return `${visible.length} ${String(o.name ?? "").toLowerCase()}${visible.length === 1 ? "" : "s"}`;
52
112
  })
53
113
  .join(" · ");
114
+ // Price: the cheapest variant's selling price (discount applied) via MIN_PRICE_VARIANT; the
115
+ // product range when variants are priced differently. A struck "was" only for a single price.
116
+ const min = raw.actualPriceRange?.minValue;
117
+ const max = raw.actualPriceRange?.maxValue;
118
+ const isRange = !!(min?.amount && max?.amount && min.amount !== max.amount);
119
+ const minVariant: RawProduct | undefined = raw.variantSummary?.minPriceVariant;
120
+ const { current, original } = sellingPrice(minVariant?.price);
121
+ const ribbons = ribbonsOf(raw);
54
122
  return {
55
123
  id: raw._id ?? "",
56
124
  slug: raw.slug ?? "",
57
125
  name: raw.name ?? "",
58
- price: raw.actualPriceRange?.minValue?.formattedAmount ?? "",
59
- maxPrice: raw.actualPriceRange?.maxValue?.formattedAmount ?? "",
60
- compareAtPrice: raw.compareAtPriceRange?.minValue?.formattedAmount ?? null,
61
- ribbon: raw.ribbon?.name ?? null,
126
+ price: current?.formattedAmount ?? min?.formattedAmount ?? "",
127
+ maxPrice: max?.formattedAmount ?? "",
128
+ compareAtPrice: isRange ? null : strike(original ?? raw.compareAtPriceRange?.minValue, current ?? min),
129
+ ribbon: ribbons[0] ?? null,
130
+ ribbons,
131
+ minPriceVariantId: minVariant?._id ?? minVariant?.variantId ?? null,
62
132
  availability,
63
133
  preorder,
64
134
  imageUrl: mainUrl,
65
135
  hoverImageUrl: hover[0] ?? "",
66
136
  optionsSummary,
137
+ swatches,
67
138
  quickAddable: options.length === 0 && availability === "IN_STOCK",
68
139
  };
69
140
  }
@@ -107,25 +178,29 @@ function toVariants(raw: RawProduct): ProductVariant[] {
107
178
  const names = c.optionChoiceNames;
108
179
  if (names?.optionName) choices[names.optionName] = names.choiceName ?? "";
109
180
  }
181
+ const { current, original } = sellingPrice(v.price);
110
182
  return {
111
183
  variantId: v._id ?? v.variantId ?? "",
112
184
  choices,
113
- price: v.price?.actualPrice?.formattedAmount ?? "",
114
- compareAtPrice: v.price?.compareAtPrice?.formattedAmount ?? null,
185
+ price: current?.formattedAmount ?? "",
186
+ compareAtPrice: strike(original, current),
115
187
  inStock: v.inventoryStatus?.inStock !== false,
188
+ preorderEnabled: v.inventoryStatus?.preorderEnabled === true,
116
189
  };
117
190
  });
118
191
  }
119
192
 
120
193
  function toDetail(raw: RawProduct): ProductDetail {
121
194
  const summary = toSummary(raw);
122
- const gallery = [
123
- summary.imageUrl,
124
- ...(raw.media?.itemsInfo?.items ?? []).map((m: RawProduct) => imgSrc(m.image ?? m, 1200, 1200)),
125
- ].filter((u, i, arr) => u && arr.indexOf(u) === i);
195
+ // De-duplicated on media identity (mediaEntries), then resolved once at one size — never
196
+ // "main at 800 + items at 1200", which yields two thumbnails of the same photo.
197
+ const gallery = mediaEntries(raw).map((m) => imgSrc(m, 1200, 1200)).filter(Boolean);
126
198
  return {
127
199
  ...summary,
128
- descriptionHtml: raw.plainDescription ?? "",
200
+ descriptionHtml: raw.plainDescription ?? "", // plainDescription IS an HTML string despite the name
201
+ infoSections: ((raw.infoSections ?? []) as RawProduct[])
202
+ .map((s) => ({ title: s.title ?? "", html: s.plainDescription ?? "" }))
203
+ .filter((s) => s.title || s.html),
129
204
  gallery,
130
205
  options: toOptions(raw),
131
206
  modifiers: toModifiers(raw),
@@ -174,6 +249,8 @@ export interface CatalogSearchOptions {
174
249
  inStockOnly?: boolean;
175
250
  /** Name search, max 100 chars. */
176
251
  search?: string;
252
+ /** Option-choice facets: products carrying ANY of these choice ids (see fetchFacets). */
253
+ choiceIds?: string[];
177
254
  }
178
255
 
179
256
  /**
@@ -190,7 +267,8 @@ export async function searchCatalog({
190
267
  maxPrice,
191
268
  inStockOnly = false,
192
269
  search = "",
193
- }: CatalogSearchOptions = {}): Promise<{ products: ProductSummary[]; nextCursor: string | null }> {
270
+ choiceIds = [],
271
+ }: CatalogSearchOptions = {}): Promise<{ products: ProductSummary[]; nextCursor: string | null; total: number | null }> {
194
272
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
195
273
  throw new Error("limit must be between 1 and 100.");
196
274
  let query: RawProduct = { cursorPaging: { limit, ...(cursor ? { cursor } : {}) } };
@@ -210,6 +288,8 @@ export async function searchCatalog({
210
288
  if (max !== undefined)
211
289
  conditions.push({ "actualPriceRange.minValue.amount": { $lte: String(max) } });
212
290
  if (inStockOnly) conditions.push({ "inventory.availabilityStatus": { $eq: "IN_STOCK" } });
291
+ // Facets discover PRODUCTS that carry a choice; the PDP / QuickAdd still resolves the variant.
292
+ if (choiceIds.length) conditions.push({ "options.choicesSettings.choices.choiceId": { $hasSome: choiceIds } });
213
293
  query = {
214
294
  ...query,
215
295
  filter: { $and: conditions },
@@ -218,12 +298,77 @@ export async function searchCatalog({
218
298
  };
219
299
  }
220
300
  const res: RawProduct = await products.searchProducts(query, { fields: LIST_FIELDS as any });
301
+ // The result count for the "N products" line — same filter, issued alongside the first page.
302
+ let total: number | null = null;
303
+ if (!cursor && query.filter) {
304
+ try {
305
+ const count: RawProduct = await products.countProducts({ filter: query.filter });
306
+ total = typeof count?.count === "number" ? count.count : null;
307
+ } catch {
308
+ /* the count is a nicety — the page is still valid */
309
+ }
310
+ }
221
311
  return {
222
312
  products: (res.products ?? []).map((p: RawProduct) => toSummary(p)),
223
313
  nextCursor: res.pagingMetadata?.cursors?.next ?? null,
314
+ total,
224
315
  };
225
316
  }
226
317
 
318
+ /**
319
+ * The filterable options of the catalog (or of one category): every option name with its
320
+ * choices, aggregated from the products themselves — so the panel only offers facets that
321
+ * exist. Color options carry a colorCode for swatches. Non-fatal: [] when the read fails.
322
+ */
323
+ export async function fetchFacets(scope: { categoryId?: string | null } = {}): Promise<Facet[]> {
324
+ return (await fetchFacetData(scope)).facets;
325
+ }
326
+
327
+ /**
328
+ * Facets plus the scope's price bounds (lowest minimum, highest maximum across its products) — the
329
+ * filter panel's slider needs both, from one 100-product read. Non-fatal: empty facets and a null
330
+ * range when the read fails.
331
+ */
332
+ export async function fetchFacetData({ categoryId }: { categoryId?: string | null } = {}): Promise<FacetData> {
333
+ try {
334
+ const conditions: RawProduct[] = [{ visible: true }];
335
+ if (categoryId) conditions.push({ "allCategoriesInfo.categories": { $matchItems: [{ id: categoryId }] } });
336
+ const res: RawProduct = await products.searchProducts(
337
+ { filter: { $and: conditions }, cursorPaging: { limit: 100 } },
338
+ { fields: [] as any },
339
+ );
340
+ const byName = new Map<string, Facet>();
341
+ let lo = Infinity;
342
+ let hi = -Infinity;
343
+ let currency = "";
344
+ for (const p of (res.products ?? []) as RawProduct[]) {
345
+ const min = Number(p.actualPriceRange?.minValue?.amount);
346
+ const max = Number(p.actualPriceRange?.maxValue?.amount);
347
+ if (Number.isFinite(min)) lo = Math.min(lo, min);
348
+ if (Number.isFinite(max)) hi = Math.max(hi, max);
349
+ currency = currency || p.currency || "";
350
+ for (const o of (p.options ?? []) as RawProduct[]) {
351
+ const name = o.name ?? "";
352
+ if (!name) continue;
353
+ const isColor = o.optionRenderType === "SWATCH_CHOICES" || o.optionRenderType === "COLOR_CHOICES";
354
+ const facet: Facet = byName.get(name) ?? { name, isColor, choices: [] };
355
+ for (const ch of (o.choicesSettings?.choices ?? []) as RawProduct[]) {
356
+ if (ch.visible === false || !ch.choiceId) continue;
357
+ if (!facet.choices.some((x) => x.id === ch.choiceId))
358
+ facet.choices.push({ id: ch.choiceId, name: ch.name ?? "", colorCode: ch.colorCode ?? null });
359
+ }
360
+ byName.set(name, facet);
361
+ }
362
+ }
363
+ return {
364
+ facets: [...byName.values()].filter((f) => f.choices.length > 1),
365
+ priceRange: Number.isFinite(lo) && Number.isFinite(hi) && hi > lo ? { min: Math.floor(lo), max: Math.ceil(hi), currency } : null,
366
+ };
367
+ } catch {
368
+ return { facets: [], priceRange: null };
369
+ }
370
+ }
371
+
227
372
  /** First page of visible products in the default order — a thin wrap of searchCatalog. */
228
373
  export async function fetchProducts({ limit = 24 } = {}): Promise<ProductSummary[]> {
229
374
  return (await searchCatalog({ limit })).products;
@@ -260,17 +405,38 @@ export async function fetchCategories(): Promise<Category[]> {
260
405
  .exists("name", true)
261
406
  .find();
262
407
  return (res.items ?? [])
263
- .map((c) => ({
264
- id: (c as RawProduct)._id ?? "",
265
- slug: (c as RawProduct).slug ?? "",
266
- name: (c as RawProduct).name ?? "",
267
- }))
408
+ .map((c) => toCategory(c as RawProduct))
268
409
  .filter((c) => c.slug !== "all-products");
269
410
  } catch {
270
411
  return [];
271
412
  }
272
413
  }
273
414
 
415
+ function toCategory(raw: RawProduct): Category {
416
+ return {
417
+ id: raw._id ?? raw.id ?? "",
418
+ slug: raw.slug ?? "",
419
+ name: raw.name ?? "",
420
+ description: raw.description ?? "",
421
+ };
422
+ }
423
+
424
+ /**
425
+ * One category by its URL slug — the data a /category/[slug] page needs (its id feeds
426
+ * searchCatalog / useShop, its name and description head the page). Null when not found or
427
+ * hidden, which the page turns into a real 404 — never a fallback to all products.
428
+ */
429
+ export async function fetchCategoryBySlug(slug: string): Promise<Category | null> {
430
+ try {
431
+ const res: RawProduct = await categories.getCategoryBySlug(slug, { appNamespace: "@wix/stores" } as any);
432
+ const raw: RawProduct | undefined = res?.category;
433
+ if (!raw || raw.visible === false) return null;
434
+ return toCategory(raw);
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
274
440
  /**
275
441
  * Resolve the variant for the buyer's selections (optionName -> choiceName).
276
442
  * A product with no options resolves to its single variant; with options, every option