@wix/agent-skills 1.19.1 → 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.
- package/package.json +1 -1
- package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +1 -1
- package/skills/wix-app/references/SERVICE_PLUGIN.md +14 -1
- package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +7 -2
- package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +53 -28
- package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +17 -4
- package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
- package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +3 -4
- package/skills/wix-app/references/service-plugin/ADDITIONAL-FEES.md +4 -0
- package/skills/wix-app/references/service-plugin/AVAILABILITY-TIME-SLOTS-CONFIGURATION.md +118 -0
- package/skills/wix-app/references/service-plugin/BOOKING-POLICY.md +65 -0
- package/skills/wix-app/references/service-plugin/BOOKINGS-PRICING.md +71 -0
- package/skills/wix-app/references/service-plugin/BOOKINGS-STAFF-SORTING.md +11 -3
- package/skills/wix-app/references/service-plugin/BOOKINGS-VALIDATION.md +137 -0
- package/skills/wix-app/references/service-plugin/DISCOUNT-TRIGGERS.md +23 -16
- package/skills/wix-app/references/service-plugin/GIFT-CARDS.md +4 -0
- package/skills/wix-app/references/service-plugin/PAYMENT-SETTINGS.md +4 -2
- package/skills/wix-app/references/service-plugin/REALTIME-PERMISSIONS.md +45 -0
- package/skills/wix-app/references/service-plugin/SHIPPING-RATES.md +15 -0
- package/skills/wix-app/references/service-plugin/TOOLS_PROVIDER.md +4 -0
- package/skills/wix-app/references/service-plugin/VALIDATIONS.md +4 -0
- package/skills/wix-base44-connector/SKILL.md +46 -24
- package/skills/wix-headless/SKILL.md +1 -0
- package/skills/wix-headless/references/CAPABILITIES.md +7 -2
- package/skills/wix-headless/references/SDK_HANDOFF.md +2 -1
- package/skills/wix-headless/references/inline-recipes/experience-store.md +49 -0
- package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +431 -115
- package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
- package/skills/wix-headless/references/managed/CREATE.md +5 -0
- package/skills/wix-headless/references/managed/DEPLOYMENT.md +8 -8
- package/skills/wix-headless-fast/install/fast-path.mjs +3 -1
- package/skills/wix-headless-fast/references/shared/app/wix/media.ts +13 -0
- package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +227 -56
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/CartDrawer.tsx +37 -3
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/FilterPanel.tsx +266 -0
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +165 -0
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +48 -15
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +129 -11
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +36 -6
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +190 -24
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +62 -4
- package/skills/wix-headless-fast/references/storefront/app-astro/layouts/SiteLayout.astro +21 -2
- package/skills/wix-headless-fast/references/storefront/seed/SEED.md +5 -1
- package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +59 -6
- package/skills/wix-manage/SKILL.md +8 -5
- package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +4 -1
- package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -1
- package/skills/wix-manage/references/bookings/create-class-service.md +3 -1
- package/skills/wix-manage/references/bookings/create-course-service.md +4 -2
- package/skills/wix-manage/references/bookings/end-to-end-booking-flow.md +68 -3
- package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-seasonal-promotion.md +1 -1
- package/skills/wix-manage/references/events/create-wix-event.md +46 -0
- package/skills/wix-manage/references/events/manage-wix-events.md +51 -1
- package/skills/wix-manage/references/google-ads/manage-campaign-lifecycle.md +6 -6
- package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
- package/skills/wix-manage/references/seo/manage-content-plan.md +1 -1
- package/skills/wix-manage/references/seo/manage-seo-suggestions.md +351 -0
- package/skills/wix-manage/references/sites/upload-static-site.md +264 -34
- package/skills/wix-vibe-headless/install/deploy.cjs +33 -2
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +1 -1
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +13 -4
- package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
- package/skills/wix-vibe-headless/platforms/base44.md +19 -4
- package/skills/wix-vibe-headless/platforms/generic.md +6 -0
- package/skills/wix-vibe-headless/references/_shared/nav/nav.tanstack.js +30 -0
- package/skills/wix-vibe-headless/references/_shared/routing.md +86 -0
- package/skills/wix-vibe-headless/references/blog/INSTRUCTIONS.md +44 -0
- package/skills/wix-vibe-headless/references/blog/app/components/PostCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/blog/app/components/PostChips.jsx +1 -1
- package/skills/wix-vibe-headless/references/blog/app/pages/CategoryPage.jsx +1 -1
- package/skills/wix-vibe-headless/references/blog/app/pages/PostDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/blog/app/pages/TagPage.jsx +1 -1
- package/skills/wix-vibe-headless/references/bookings/INSTRUCTIONS.md +42 -0
- package/skills/wix-vibe-headless/references/bookings/app/components/ServiceCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/bookings/app/pages/ServiceDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/events/INSTRUCTIONS.md +42 -0
- package/skills/wix-vibe-headless/references/events/app/components/EventCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/events/app/pages/EventDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/members/INSTRUCTIONS.md +42 -0
- package/skills/wix-vibe-headless/references/members/app/components/MemberMenu.jsx +1 -1
- package/skills/wix-vibe-headless/references/members/app/components/RequireAuth.jsx +1 -1
- package/skills/wix-vibe-headless/references/members/app/pages/Login.jsx +1 -1
- package/skills/wix-vibe-headless/references/portfolio/INSTRUCTIONS.md +43 -0
- package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/portfolio/app/pages/CollectionPage.jsx +1 -1
- package/skills/wix-vibe-headless/references/portfolio/app/pages/ProjectDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/pricing-plans/INSTRUCTIONS.md +43 -0
- package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/pricing-plans/app/pages/PlanDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/restaurants/INSTRUCTIONS.md +36 -0
- package/skills/wix-vibe-headless/references/shared/app/lib/nav.js +9 -0
- package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +110 -27
- package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +32 -14
- package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +14 -3
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +47 -35
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +77 -30
- package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-cart.js +31 -2
- package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-catalog.js +31 -6
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
// Catalog listing for a shop surface: category
|
|
2
|
-
// cursor paging — all applied by Wix across the whole catalog (searchCatalog),
|
|
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>(
|
|
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
|
-
|
|
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
|
|
102
|
-
// hand-summing
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
42
|
-
const mainUrl = imgSrc(
|
|
43
|
-
const hover =
|
|
44
|
-
|
|
45
|
-
.filter((
|
|
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:
|
|
59
|
-
maxPrice:
|
|
60
|
-
compareAtPrice: raw.compareAtPriceRange?.minValue
|
|
61
|
-
ribbon:
|
|
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:
|
|
114
|
-
compareAtPrice:
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|