@wix/agent-skills 1.20.0 → 1.21.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/SKILL.md +14 -112
- package/skills/wix-app/references/APP_TOOLS.md +2 -1
- package/skills/wix-app/references/BACKEND_EVENT.md +3 -3
- package/skills/wix-app/references/CUSTOM_ELEMENT_WIDGET.md +41 -152
- package/skills/wix-app/references/DASHBOARD_MODAL.md +4 -4
- package/skills/wix-app/references/SDK_MODULE_MAP.md +44 -0
- package/skills/wix-app/references/SITE_PLUGIN.md +36 -190
- package/skills/wix-app/references/app-tools/TOOLS.md +3 -1
- package/skills/wix-app/references/custom-element-widget/SETTINGS_PANEL.md +21 -0
- package/skills/wix-app/references/dashboard-page/ECOM_NAVIGATION.md +17 -0
- package/skills/wix-app/references/dashboard-plugin/SLOTS.md +4 -18
- package/skills/wix-app/references/site-plugin/EXAMPLES.md +1 -1
- package/skills/wix-headless-fast/SKILL.md +302 -51
- package/skills/wix-headless-fast/entry/skill.md +11 -2
- package/skills/wix-headless-fast/install/AGENTS.md +32 -0
- package/skills/wix-headless-fast/install/agents-md.mjs +39 -0
- package/skills/wix-headless-fast/install/attach.mjs +360 -0
- package/skills/wix-headless-fast/install/deploy.mjs +109 -9
- package/skills/wix-headless-fast/install/setup.mjs +239 -0
- package/skills/wix-headless-fast/references/blog/INSTRUCTIONS.md +226 -60
- package/skills/wix-headless-fast/references/blog/app/components/blog/BlogFeedView.tsx +2 -3
- package/skills/wix-headless-fast/references/blog/app/components/blog/PostView.tsx +2 -3
- package/skills/wix-headless-fast/references/blog/app/hooks/blog/useBlogFeed.ts +24 -116
- package/skills/wix-headless-fast/references/blog/app/hooks/blog/usePost.ts +16 -64
- package/skills/wix-headless-fast/references/blog/app/wix/blog/blog-feed-store.ts +137 -0
- package/skills/wix-headless-fast/references/blog/app/wix/blog/post-store.ts +78 -0
- package/skills/wix-headless-fast/references/blog/app/wix/blog/posts-core.ts +117 -0
- package/skills/wix-headless-fast/references/blog/app/wix/blog/posts.ts +13 -60
- package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy-core.ts +47 -0
- package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy.ts +10 -28
- package/skills/wix-headless-fast/references/blog/app-astro/pages/blog/[...slug].astro +2 -4
- package/skills/wix-headless-fast/references/blog/rest/posts.ts +42 -0
- package/skills/wix-headless-fast/references/blog/rest/taxonomy.ts +62 -0
- package/skills/wix-headless-fast/references/blog/seed/SEED.md +9 -0
- package/skills/wix-headless-fast/references/blog/seed/read-site.mjs +22 -0
- package/skills/wix-headless-fast/references/bookings/INSTRUCTIONS.md +181 -33
- package/skills/wix-headless-fast/references/bookings/app/components/bookings/ServiceBookingView.tsx +2 -1
- package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useBookingFlow.ts +26 -132
- package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useServices.ts +18 -49
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-core.ts +218 -0
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-flow-store.ts +169 -0
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking.ts +57 -142
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-core.ts +92 -0
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-store.ts +90 -0
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services.ts +22 -61
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/types.ts +3 -1
- package/skills/wix-headless-fast/references/bookings/rest/booking.ts +113 -0
- package/skills/wix-headless-fast/references/bookings/rest/services.ts +55 -0
- package/skills/wix-headless-fast/references/bookings/seed/SEED.md +10 -0
- package/skills/wix-headless-fast/references/bookings/seed/read-site.mjs +28 -0
- package/skills/wix-headless-fast/references/cms/INSTRUCTIONS.md +238 -62
- package/skills/wix-headless-fast/references/cms/app/hooks/cms/useCollection.ts +19 -52
- package/skills/wix-headless-fast/references/cms/app/hooks/cms/useItem.ts +16 -36
- package/skills/wix-headless-fast/references/cms/app/wix/cms/collection-store.ts +158 -0
- package/skills/wix-headless-fast/references/cms/app/wix/cms/item-store.ts +93 -0
- package/skills/wix-headless-fast/references/cms/app/wix/cms/items-core.ts +177 -0
- package/skills/wix-headless-fast/references/cms/app/wix/cms/items.ts +13 -41
- package/skills/wix-headless-fast/references/cms/rest/items.ts +125 -0
- package/skills/wix-headless-fast/references/cms/seed/SEED.md +9 -0
- package/skills/wix-headless-fast/references/cms/seed/read-site.mjs +20 -0
- package/skills/wix-headless-fast/references/events/INSTRUCTIONS.md +241 -83
- package/skills/wix-headless-fast/references/events/app/components/events/EventConfirmationView.tsx +1 -1
- package/skills/wix-headless-fast/references/events/app/components/events/EventRegistrationView.tsx +7 -5
- package/skills/wix-headless-fast/references/events/app/components/events/EventsView.tsx +2 -2
- package/skills/wix-headless-fast/references/events/app/hooks/events/useEventRegistration.ts +28 -110
- package/skills/wix-headless-fast/references/events/app/hooks/events/useEvents.ts +19 -54
- package/skills/wix-headless-fast/references/events/app/wix/events/events-core.ts +130 -0
- package/skills/wix-headless-fast/references/events/app/wix/events/events-store.ts +97 -0
- package/skills/wix-headless-fast/references/events/app/wix/events/events.ts +13 -94
- package/skills/wix-headless-fast/references/events/app/wix/events/registration-core.ts +113 -0
- package/skills/wix-headless-fast/references/events/app/wix/events/registration-store.ts +156 -0
- package/skills/wix-headless-fast/references/events/app/wix/events/registration.ts +38 -69
- package/skills/wix-headless-fast/references/events/rest/events.ts +57 -0
- package/skills/wix-headless-fast/references/events/rest/registration.ts +83 -0
- package/skills/wix-headless-fast/references/events/seed/SEED.md +9 -0
- package/skills/wix-headless-fast/references/events/seed/read-site.mjs +26 -0
- package/skills/wix-headless-fast/references/forms/INSTRUCTIONS.md +216 -26
- package/skills/wix-headless-fast/references/forms/app/hooks/forms/useWixForm.ts +35 -267
- package/skills/wix-headless-fast/references/forms/app/wix/forms/form-store.ts +332 -0
- package/skills/wix-headless-fast/references/forms/app/wix/forms/forms-core.ts +238 -0
- package/skills/wix-headless-fast/references/forms/app/wix/forms/forms.ts +28 -243
- package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions-core.ts +216 -0
- package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions.ts +32 -204
- package/skills/wix-headless-fast/references/forms/rest/forms.ts +34 -0
- package/skills/wix-headless-fast/references/forms/rest/submissions.ts +56 -0
- package/skills/wix-headless-fast/references/forms/seed/SEED.md +8 -0
- package/skills/wix-headless-fast/references/forms/seed/read-site.mjs +20 -0
- package/skills/wix-headless-fast/references/forms/seed/seed-forms.mjs +15 -8
- package/skills/wix-headless-fast/references/members/INSTRUCTIONS.md +316 -63
- package/skills/wix-headless-fast/references/members/app/components/members/LoginForm.tsx +1 -1
- package/skills/wix-headless-fast/references/members/app/wix/members/auth-core.ts +105 -0
- package/skills/wix-headless-fast/references/members/app/wix/members/auth.ts +36 -65
- package/skills/wix-headless-fast/references/members/app/wix/members/client.ts +1 -1
- package/skills/wix-headless-fast/references/members/app/wix/members/members-core.ts +43 -0
- package/skills/wix-headless-fast/references/members/app/wix/members/members.ts +11 -32
- package/skills/wix-headless-fast/references/members/rest/auth.ts +215 -0
- package/skills/wix-headless-fast/references/members/rest/members.ts +26 -0
- package/skills/wix-headless-fast/references/members/seed/read-site.mjs +15 -0
- package/skills/wix-headless-fast/references/portfolio/INSTRUCTIONS.md +240 -59
- package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionProjectsView.tsx +2 -2
- package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionsView.tsx +2 -2
- package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/GalleryMedia.tsx +35 -0
- package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/ProjectDetailView.tsx +8 -38
- package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollectionProjects.ts +23 -50
- package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollections.ts +16 -34
- package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useProjectDetail.ts +22 -49
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collection-projects-store.ts +82 -0
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collections-store.ts +58 -0
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio-core.ts +177 -0
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio.ts +31 -118
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/project-detail-store.ts +81 -0
- package/skills/wix-headless-fast/references/portfolio/rest/portfolio.ts +75 -0
- package/skills/wix-headless-fast/references/portfolio/seed/SEED.md +8 -0
- package/skills/wix-headless-fast/references/portfolio/seed/read-site.mjs +20 -0
- package/skills/wix-headless-fast/references/portfolio/seed/seed-portfolio.mjs +19 -21
- package/skills/wix-headless-fast/references/pricing-plans/INSTRUCTIONS.md +214 -51
- package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlanDetailView.tsx +9 -20
- package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlansView.tsx +7 -32
- package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/SubscribeButton.tsx +41 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlanPurchase.ts +12 -33
- package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlans.ts +17 -34
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plan-purchase-store.ts +54 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-core.ts +106 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-store.ts +64 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans.ts +7 -55
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase-core.ts +43 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase.ts +11 -34
- package/skills/wix-headless-fast/references/pricing-plans/rest/plans.ts +31 -0
- package/skills/wix-headless-fast/references/pricing-plans/rest/purchase.ts +20 -0
- package/skills/wix-headless-fast/references/pricing-plans/seed/SEED.md +8 -0
- package/skills/wix-headless-fast/references/pricing-plans/seed/read-site.mjs +21 -0
- package/skills/wix-headless-fast/references/restaurants/INSTRUCTIONS.md +242 -47
- package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/MenuView.tsx +2 -2
- package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/OrderCartDrawer.tsx +2 -2
- package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useMenus.ts +14 -35
- package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useReservation.ts +28 -171
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-core.ts +155 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-store.ts +89 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu.ts +23 -117
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering-core.ts +124 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering.ts +45 -114
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservation-store.ts +193 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations-core.ts +94 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations.ts +15 -87
- package/skills/wix-headless-fast/references/restaurants/rest/menu.ts +60 -0
- package/skills/wix-headless-fast/references/restaurants/rest/ordering.ts +169 -0
- package/skills/wix-headless-fast/references/restaurants/rest/reservations.ts +68 -0
- package/skills/wix-headless-fast/references/restaurants/seed/SEED.md +8 -0
- package/skills/wix-headless-fast/references/restaurants/seed/read-site.mjs +25 -0
- package/skills/wix-headless-fast/references/restaurants/seed/seed-restaurants.mjs +25 -5
- package/skills/wix-headless-fast/references/shared/SUPPLIED-CONTENT.md +23 -0
- package/skills/wix-headless-fast/references/shared/app/wix/media.ts +45 -4
- package/skills/wix-headless-fast/references/shared/app/wix/sdk.ts +13 -0
- package/skills/wix-headless-fast/references/shared/rest/client.ts +155 -0
- package/skills/wix-headless-fast/references/shared/rest/config.ts +3 -0
- package/skills/wix-headless-fast/references/shared/rest/media.ts +65 -0
- package/skills/wix-headless-fast/references/shared/seed/read-site.mjs +99 -0
- package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +186 -63
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/OptionPicker.tsx +95 -0
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +29 -58
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +29 -163
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +26 -300
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-core.ts +121 -0
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +49 -162
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog-core.ts +314 -0
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +47 -370
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/product-detail-store.ts +147 -0
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/shop-store.ts +289 -0
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +2 -0
- package/skills/wix-headless-fast/references/storefront/rest/cart.ts +132 -0
- package/skills/wix-headless-fast/references/storefront/rest/catalog.ts +123 -0
- package/skills/wix-headless-fast/references/storefront/seed/SEED.md +31 -9
- package/skills/wix-headless-fast/references/storefront/seed/read-site.mjs +38 -0
- package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +57 -50
- package/skills/wix-manage/references/ecommerce/ecom-shipping.md +1 -1
- package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-create-coupon.md +10 -19
- package/skills/wix-manage/references/events/create-wix-event.md +5 -1
- package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +0 -40
- package/skills/wix-vibe-headless/references/storefront/seed/SEED.md +3 -0
- package/skills/wix-vibe-headless/references/storefront/seed/seed-store.cjs +6 -0
- package/skills/wix-headless-fast/install/fast-path.mjs +0 -186
- package/skills/wix-headless-fast/install/pin-agents-md.mjs +0 -57
|
@@ -1,369 +1,77 @@
|
|
|
1
|
-
// Catalog reads (Wix Stores Catalog V3) — the only file that touches raw catalog
|
|
2
|
-
// Everything it returns is a plain DTO from ./types
|
|
3
|
-
//
|
|
1
|
+
// Catalog reads (Wix Stores Catalog V3) over the SDK — the only file that touches raw catalog
|
|
2
|
+
// entities on this transport. Everything it returns is a plain DTO from ./types. The rules and
|
|
3
|
+
// mappers live in ./catalog-core (shared with the REST twin in references/storefront/rest/); this
|
|
4
|
+
// file is the transport only. Copy as-is; extend by adding functions, not by editing these.
|
|
4
5
|
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/search-products.md
|
|
5
6
|
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/get-product-by-slug.md
|
|
6
7
|
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/query-categories.md
|
|
7
8
|
import { productsV3 } from "@wix/stores";
|
|
8
9
|
import { categories as categoriesModule } from "@wix/categories";
|
|
9
10
|
import { wixModule } from "../sdk";
|
|
10
|
-
import { imgSrc } from "../media";
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
import { imgSrc, mediaKey } from "../media";
|
|
12
|
+
import {
|
|
13
|
+
CATALOG_SORTS,
|
|
14
|
+
DETAIL_FIELDS,
|
|
15
|
+
LIST_FIELDS,
|
|
16
|
+
catalogFilter,
|
|
17
|
+
resolveVariant,
|
|
18
|
+
searchQuery,
|
|
19
|
+
sellingPrice,
|
|
20
|
+
toCategory,
|
|
21
|
+
toDetail,
|
|
22
|
+
toFacetData,
|
|
23
|
+
toSummary,
|
|
24
|
+
type CatalogSearchOptions,
|
|
25
|
+
type CatalogSort,
|
|
26
|
+
type Raw,
|
|
27
|
+
} from "./catalog-core";
|
|
28
|
+
import type { Category, Facet, FacetData, ProductDetail, ProductSummary } from "./types";
|
|
29
|
+
|
|
30
|
+
export { CATALOG_SORTS, resolveVariant, sellingPrice, type CatalogSearchOptions, type CatalogSort };
|
|
22
31
|
|
|
23
32
|
const products = wixModule(productsV3);
|
|
24
33
|
const categories = wixModule(categoriesModule);
|
|
25
34
|
|
|
26
|
-
// Requested on every product read. CURRENCY populates formattedAmount (without it, prices render
|
|
27
|
-
// as bare unlocalized numbers); MEDIA_ITEMS_INFO populates the gallery; the detail read adds
|
|
28
|
-
// PLAIN_DESCRIPTION and VARIANT_OPTION_CHOICE_NAMES (which populates variantsInfo.variants).
|
|
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
|
-
];
|
|
40
|
-
|
|
41
|
-
type RawProduct = Record<string, any>;
|
|
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
|
-
|
|
66
|
-
function toAvailability(raw: RawProduct): Availability {
|
|
67
|
-
const status = raw.inventory?.availabilityStatus;
|
|
68
|
-
return status === "OUT_OF_STOCK" || status === "PARTIALLY_OUT_OF_STOCK"
|
|
69
|
-
? status
|
|
70
|
-
: "IN_STOCK";
|
|
71
|
-
}
|
|
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
|
-
|
|
96
|
-
function toSummary(raw: RawProduct): ProductSummary {
|
|
97
|
-
const options: RawProduct[] = raw.options ?? [];
|
|
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));
|
|
106
|
-
const availability = toAvailability(raw);
|
|
107
|
-
const preorder = raw.inventory?.preorderStatus === "ENABLED" && availability === "OUT_OF_STOCK";
|
|
108
|
-
const optionsSummary = options
|
|
109
|
-
.map((o) => {
|
|
110
|
-
const visible = (o.choicesSettings?.choices ?? []).filter((c: RawProduct) => c.visible !== false);
|
|
111
|
-
return `${visible.length} ${String(o.name ?? "").toLowerCase()}${visible.length === 1 ? "" : "s"}`;
|
|
112
|
-
})
|
|
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);
|
|
122
|
-
return {
|
|
123
|
-
id: raw._id ?? "",
|
|
124
|
-
slug: raw.slug ?? "",
|
|
125
|
-
name: raw.name ?? "",
|
|
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,
|
|
132
|
-
availability,
|
|
133
|
-
preorder,
|
|
134
|
-
imageUrl: mainUrl,
|
|
135
|
-
hoverImageUrl: hover[0] ?? "",
|
|
136
|
-
optionsSummary,
|
|
137
|
-
swatches,
|
|
138
|
-
quickAddable: options.length === 0 && availability === "IN_STOCK",
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function toOptions(raw: RawProduct): ProductOption[] {
|
|
143
|
-
return (raw.options ?? []).map((o: RawProduct) => {
|
|
144
|
-
const isColor = o.optionRenderType === "SWATCH_CHOICES" || o.optionRenderType === "COLOR_CHOICES";
|
|
145
|
-
return {
|
|
146
|
-
id: o._id ?? o.id ?? o.name ?? "",
|
|
147
|
-
name: o.name ?? "",
|
|
148
|
-
isColor,
|
|
149
|
-
choices: (o.choicesSettings?.choices ?? [])
|
|
150
|
-
.filter((c: RawProduct) => c.visible !== false)
|
|
151
|
-
.map((c: RawProduct) => ({
|
|
152
|
-
choiceId: c.choiceId ?? "",
|
|
153
|
-
name: c.name ?? "",
|
|
154
|
-
colorCode: c.colorCode ?? null,
|
|
155
|
-
inStock: c.inStock !== false,
|
|
156
|
-
})),
|
|
157
|
-
};
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function toModifiers(raw: RawProduct): ProductModifier[] {
|
|
162
|
-
return (raw.modifiers ?? []).map((m: RawProduct) => ({
|
|
163
|
-
key: m.key ?? m.freeTextSettings?.key ?? m.name ?? "",
|
|
164
|
-
name: m.name ?? "",
|
|
165
|
-
mandatory: m.mandatory === true,
|
|
166
|
-
type: m.modifierRenderType === "FREE_TEXT" ? ("text" as const) : ("choices" as const),
|
|
167
|
-
choices: (m.choicesSettings?.choices ?? []).map((c: RawProduct) => ({
|
|
168
|
-
key: c.key ?? c.name ?? "",
|
|
169
|
-
name: c.name ?? "",
|
|
170
|
-
})),
|
|
171
|
-
}));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function toVariants(raw: RawProduct): ProductVariant[] {
|
|
175
|
-
return (raw.variantsInfo?.variants ?? []).map((v: RawProduct) => {
|
|
176
|
-
const choices: Record<string, string> = {};
|
|
177
|
-
for (const c of v.choices ?? []) {
|
|
178
|
-
const names = c.optionChoiceNames;
|
|
179
|
-
if (names?.optionName) choices[names.optionName] = names.choiceName ?? "";
|
|
180
|
-
}
|
|
181
|
-
const { current, original } = sellingPrice(v.price);
|
|
182
|
-
return {
|
|
183
|
-
variantId: v._id ?? v.variantId ?? "",
|
|
184
|
-
choices,
|
|
185
|
-
price: current?.formattedAmount ?? "",
|
|
186
|
-
compareAtPrice: strike(original, current),
|
|
187
|
-
inStock: v.inventoryStatus?.inStock !== false,
|
|
188
|
-
preorderEnabled: v.inventoryStatus?.preorderEnabled === true,
|
|
189
|
-
};
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function toDetail(raw: RawProduct): ProductDetail {
|
|
194
|
-
const summary = toSummary(raw);
|
|
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);
|
|
198
|
-
return {
|
|
199
|
-
...summary,
|
|
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),
|
|
204
|
-
gallery,
|
|
205
|
-
options: toOptions(raw),
|
|
206
|
-
modifiers: toModifiers(raw),
|
|
207
|
-
variants: toVariants(raw),
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Search Products applies sort/filter/search server-side, across the WHOLE catalog, before
|
|
212
|
-
// cursor paging — the only correct place for them. Sorting or filtering an already-fetched
|
|
213
|
-
// page only rearranges the slice you happen to hold.
|
|
214
|
-
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/search-products.md
|
|
215
|
-
export const CATALOG_SORTS = {
|
|
216
|
-
featured: { label: "Default order" }, // the catalog's own order; no merchant-curated promise
|
|
217
|
-
priceAsc: { label: "Price: low to high" },
|
|
218
|
-
priceHigh: { label: "Price: high to low" },
|
|
219
|
-
name: { label: "Name: A\u2013Z" },
|
|
220
|
-
newest: { label: "Newest" },
|
|
221
|
-
} as const;
|
|
222
|
-
export type CatalogSort = keyof typeof CATALOG_SORTS;
|
|
223
|
-
|
|
224
|
-
const SORT_FIELDS: Partial<Record<CatalogSort, { fieldName: string; order: "ASC" | "DESC" }[]>> = {
|
|
225
|
-
priceAsc: [{ fieldName: "actualPriceRange.minValue.amount", order: "ASC" }, { fieldName: "name", order: "ASC" }],
|
|
226
|
-
priceHigh: [{ fieldName: "actualPriceRange.minValue.amount", order: "DESC" }, { fieldName: "name", order: "ASC" }],
|
|
227
|
-
name: [{ fieldName: "name", order: "ASC" }],
|
|
228
|
-
newest: [{ fieldName: "createdDate", order: "DESC" }, { fieldName: "name", order: "ASC" }],
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
function priceBound(value: number | string | undefined, name: string): number | undefined {
|
|
232
|
-
if (value == null || value === "") return undefined;
|
|
233
|
-
const parsed = Number(value);
|
|
234
|
-
if (!String(value).trim() || !Number.isFinite(parsed) || parsed < 0) {
|
|
235
|
-
throw new Error(`${name} must be a non-negative number.`);
|
|
236
|
-
}
|
|
237
|
-
return parsed;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export interface CatalogSearchOptions {
|
|
241
|
-
limit?: number;
|
|
242
|
-
/** Continues the ORIGINAL query — pass alone; new sort/filter values start without one. */
|
|
243
|
-
cursor?: string;
|
|
244
|
-
categoryId?: string | null;
|
|
245
|
-
sort?: CatalogSort;
|
|
246
|
-
/** Bounds compare the product's minimum actual variant price, in site currency. */
|
|
247
|
-
minPrice?: number | string;
|
|
248
|
-
maxPrice?: number | string;
|
|
249
|
-
inStockOnly?: boolean;
|
|
250
|
-
/** Name search, max 100 chars. */
|
|
251
|
-
search?: string;
|
|
252
|
-
/** Option-choice facets: products carrying ANY of these choice ids (see fetchFacets). */
|
|
253
|
-
choiceIds?: string[];
|
|
254
|
-
}
|
|
255
|
-
|
|
256
35
|
/**
|
|
257
|
-
* Search visible catalog products — sorted, filtered, and searched by Wix across the
|
|
258
|
-
* catalog, then cursor-paged. `nextCursor` continues the same query; start over (no cursor)
|
|
259
|
-
* whenever any selection changes.
|
|
36
|
+
* Search visible catalog products — sorted, filtered, faceted, and searched by Wix across the
|
|
37
|
+
* whole catalog, then cursor-paged. `nextCursor` continues the same query; start over (no cursor)
|
|
38
|
+
* whenever any selection changes. `total` (the "N products" line) is issued with the first page.
|
|
260
39
|
*/
|
|
261
|
-
export async function searchCatalog({
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
categoryId,
|
|
265
|
-
sort = "featured",
|
|
266
|
-
minPrice,
|
|
267
|
-
maxPrice,
|
|
268
|
-
inStockOnly = false,
|
|
269
|
-
search = "",
|
|
270
|
-
choiceIds = [],
|
|
271
|
-
}: CatalogSearchOptions = {}): Promise<{ products: ProductSummary[]; nextCursor: string | null; total: number | null }> {
|
|
272
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
273
|
-
throw new Error("limit must be between 1 and 100.");
|
|
274
|
-
let query: RawProduct = { cursorPaging: { limit, ...(cursor ? { cursor } : {}) } };
|
|
275
|
-
if (!cursor) {
|
|
276
|
-
if (!(sort in CATALOG_SORTS)) throw new Error("Unsupported catalog sort.");
|
|
277
|
-
const min = priceBound(minPrice, "minPrice");
|
|
278
|
-
const max = priceBound(maxPrice, "maxPrice");
|
|
279
|
-
if (min !== undefined && max !== undefined && min > max)
|
|
280
|
-
throw new Error("minPrice must not exceed maxPrice.");
|
|
281
|
-
if (search.trim().length > 100) throw new Error("Search must be at most 100 characters.");
|
|
282
|
-
const conditions: RawProduct[] = [{ visible: true }];
|
|
283
|
-
if (categoryId)
|
|
284
|
-
conditions.push({ "allCategoriesInfo.categories": { $matchItems: [{ id: categoryId }] } });
|
|
285
|
-
// Search rejects two operators in one field object — separate bounds, joined by $and.
|
|
286
|
-
if (min !== undefined)
|
|
287
|
-
conditions.push({ "actualPriceRange.minValue.amount": { $gte: String(min) } });
|
|
288
|
-
if (max !== undefined)
|
|
289
|
-
conditions.push({ "actualPriceRange.minValue.amount": { $lte: String(max) } });
|
|
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 } });
|
|
293
|
-
query = {
|
|
294
|
-
...query,
|
|
295
|
-
filter: { $and: conditions },
|
|
296
|
-
...(SORT_FIELDS[sort] ? { sort: SORT_FIELDS[sort] } : {}),
|
|
297
|
-
...(search.trim() ? { search: { expression: search.trim(), fields: ["name"] } } : {}),
|
|
298
|
-
};
|
|
299
|
-
}
|
|
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.
|
|
40
|
+
export async function searchCatalog(o: CatalogSearchOptions = {}): Promise<{ products: ProductSummary[]; nextCursor: string | null; total: number | null }> {
|
|
41
|
+
const query = searchQuery(o);
|
|
42
|
+
const res: Raw = await products.searchProducts(query, { fields: LIST_FIELDS as any });
|
|
302
43
|
let total: number | null = null;
|
|
303
|
-
if (!cursor && query.filter) {
|
|
44
|
+
if (!o.cursor && query.filter) {
|
|
304
45
|
try {
|
|
305
|
-
const count:
|
|
46
|
+
const count: Raw = await products.countProducts({ filter: query.filter });
|
|
306
47
|
total = typeof count?.count === "number" ? count.count : null;
|
|
307
48
|
} catch {
|
|
308
49
|
/* the count is a nicety — the page is still valid */
|
|
309
50
|
}
|
|
310
51
|
}
|
|
311
52
|
return {
|
|
312
|
-
products: (res.products ?? []).map((p
|
|
53
|
+
products: ((res.products ?? []) as Raw[]).map((p) => toSummary(p, imgSrc, mediaKey)),
|
|
313
54
|
nextCursor: res.pagingMetadata?.cursors?.next ?? null,
|
|
314
55
|
total,
|
|
315
56
|
};
|
|
316
57
|
}
|
|
317
58
|
|
|
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
|
-
*/
|
|
59
|
+
/** The filterable options of the catalog (or one category). Non-fatal: [] when the read fails. */
|
|
323
60
|
export async function fetchFacets(scope: { categoryId?: string | null } = {}): Promise<Facet[]> {
|
|
324
61
|
return (await fetchFacetData(scope)).facets;
|
|
325
62
|
}
|
|
326
63
|
|
|
327
64
|
/**
|
|
328
65
|
* 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
|
|
330
|
-
* range when the read fails.
|
|
66
|
+
* filter panel's slider needs both, from one 100-product read. Non-fatal: empty on failure.
|
|
331
67
|
*/
|
|
332
68
|
export async function fetchFacetData({ categoryId }: { categoryId?: string | null } = {}): Promise<FacetData> {
|
|
333
69
|
try {
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
{ filter: { $and: conditions }, cursorPaging: { limit: 100 } },
|
|
338
|
-
{ fields: [] as any },
|
|
70
|
+
const res: Raw = await products.searchProducts(
|
|
71
|
+
{ filter: catalogFilter({ categoryId }), cursorPaging: { limit: 100 } },
|
|
72
|
+
{ fields: ["CURRENCY"] as any },
|
|
339
73
|
);
|
|
340
|
-
|
|
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
|
-
};
|
|
74
|
+
return toFacetData((res.products ?? []) as Raw[]);
|
|
367
75
|
} catch {
|
|
368
76
|
return { facets: [], priceRange: null };
|
|
369
77
|
}
|
|
@@ -375,10 +83,7 @@ export async function fetchProducts({ limit = 24 } = {}): Promise<ProductSummary
|
|
|
375
83
|
}
|
|
376
84
|
|
|
377
85
|
/** First page of a category — same wrap; category filtering only works through search. */
|
|
378
|
-
export async function fetchProductsByCategory(
|
|
379
|
-
categoryId: string,
|
|
380
|
-
{ limit = 24 } = {},
|
|
381
|
-
): Promise<ProductSummary[]> {
|
|
86
|
+
export async function fetchProductsByCategory(categoryId: string, { limit = 24 } = {}): Promise<ProductSummary[]> {
|
|
382
87
|
return (await searchCatalog({ limit, categoryId })).products;
|
|
383
88
|
}
|
|
384
89
|
|
|
@@ -386,7 +91,7 @@ export async function fetchProductsByCategory(
|
|
|
386
91
|
export async function fetchProductBySlug(slug: string): Promise<ProductDetail | null> {
|
|
387
92
|
try {
|
|
388
93
|
const res = await products.getProductBySlug(slug, { fields: DETAIL_FIELDS as any });
|
|
389
|
-
return res.product ? toDetail(res.product as
|
|
94
|
+
return res.product ? toDetail(res.product as Raw, imgSrc, mediaKey) : null;
|
|
390
95
|
} catch {
|
|
391
96
|
return null;
|
|
392
97
|
}
|
|
@@ -404,53 +109,25 @@ export async function fetchCategories(): Promise<Category[]> {
|
|
|
404
109
|
.queryCategories({ treeReference: { appNamespace: "@wix/stores" } })
|
|
405
110
|
.exists("name", true)
|
|
406
111
|
.find();
|
|
407
|
-
return (res.items ?? [])
|
|
408
|
-
.map((c) => toCategory(c as RawProduct))
|
|
409
|
-
.filter((c) => c.slug !== "all-products");
|
|
112
|
+
return (res.items ?? []).map((c) => toCategory(c as Raw)).filter((c) => c.slug !== "all-products");
|
|
410
113
|
} catch {
|
|
411
114
|
return [];
|
|
412
115
|
}
|
|
413
116
|
}
|
|
414
117
|
|
|
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
118
|
/**
|
|
425
119
|
* 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).
|
|
427
|
-
* hidden, which the page turns into a real 404 — never a
|
|
120
|
+
* searchCatalog / useShop, its name and description head the page). The response is WRAPPED:
|
|
121
|
+
* { category }. Null when not found or hidden, which the page turns into a real 404 — never a
|
|
122
|
+
* fallback to all products.
|
|
428
123
|
*/
|
|
429
124
|
export async function fetchCategoryBySlug(slug: string): Promise<Category | null> {
|
|
430
125
|
try {
|
|
431
|
-
const res:
|
|
432
|
-
const raw:
|
|
126
|
+
const res: Raw = await categories.getCategoryBySlug(slug, { appNamespace: "@wix/stores" } as any);
|
|
127
|
+
const raw: Raw | undefined = res?.category;
|
|
433
128
|
if (!raw || raw.visible === false) return null;
|
|
434
129
|
return toCategory(raw);
|
|
435
130
|
} catch {
|
|
436
131
|
return null;
|
|
437
132
|
}
|
|
438
133
|
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Resolve the variant for the buyer's selections (optionName -> choiceName).
|
|
442
|
-
* A product with no options resolves to its single variant; with options, every option
|
|
443
|
-
* must be selected and match. Returns null while the selection is incomplete.
|
|
444
|
-
*/
|
|
445
|
-
export function resolveVariant(
|
|
446
|
-
detail: ProductDetail,
|
|
447
|
-
selections: Record<string, string>,
|
|
448
|
-
): ProductVariant | null {
|
|
449
|
-
if (detail.options.length === 0) return detail.variants[0] ?? null;
|
|
450
|
-
if (!detail.options.every((o) => selections[o.name])) return null;
|
|
451
|
-
return (
|
|
452
|
-
detail.variants.find((v) =>
|
|
453
|
-
detail.options.every((o) => v.choices[o.name] === selections[o.name]),
|
|
454
|
-
) ?? null
|
|
455
|
-
);
|
|
456
|
-
}
|
package/skills/wix-headless-fast/references/storefront/app/wix/storefront/product-detail-store.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Product detail as a framework-free store — the logic behind useProductDetail, usable from React
|
|
2
|
+
// (useProductDetail wraps it), from a static page's PDP and quick-add picker, from Vue/Svelte, or as
|
|
3
|
+
// the specification for a port. Option selection → variant resolution → add-to-cart, plus modifier
|
|
4
|
+
// inputs. This is where the "adds variants[0] regardless of the buyer's choice" class of bugs comes
|
|
5
|
+
// from — always drive a purchase surface through this store, never resolve variants by hand.
|
|
6
|
+
//
|
|
7
|
+
// Selections start EMPTY (a buyer who never noticed a pre-picked default buys the wrong colour).
|
|
8
|
+
// Pass the server-fetched ProductDetail as `initial` (SSR) or a `slug` (the store fetches on start).
|
|
9
|
+
// One store per product surface: createProductDetailStore(), not a singleton.
|
|
10
|
+
import { fetchProductBySlug, resolveVariant } from "./catalog";
|
|
11
|
+
import { addLine } from "./cart-store";
|
|
12
|
+
import type { ProductDetail, ProductOption, ProductVariant } from "./types";
|
|
13
|
+
|
|
14
|
+
export interface ProductDetailStoreOptions {
|
|
15
|
+
initial?: ProductDetail | null;
|
|
16
|
+
slug?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OptionGroupView extends ProductOption {
|
|
20
|
+
choices: (ProductOption["choices"][number] & { selected: boolean })[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProductDetailState {
|
|
24
|
+
/** null while loading (or when the slug doesn't resolve — check notFound). */
|
|
25
|
+
product: ProductDetail | null;
|
|
26
|
+
notFound: boolean;
|
|
27
|
+
/** Option groups with per-choice `selected`, ready to render as pills/swatches. */
|
|
28
|
+
optionGroups: OptionGroupView[];
|
|
29
|
+
/** Modifier inputs keyed by modifier key. */
|
|
30
|
+
modifierValues: Record<string, string>;
|
|
31
|
+
/** The resolved variant; null while the selection is incomplete. */
|
|
32
|
+
variant: ProductVariant | null;
|
|
33
|
+
/** The RANGE ("€24.99 – €34.99", one value when equal) until every option is picked, then the variant's price. */
|
|
34
|
+
price: string;
|
|
35
|
+
/** Struck "was" price — only once a variant is resolved (or for a single-price product), never beside a range. */
|
|
36
|
+
compareAtPrice: string | null;
|
|
37
|
+
/** The resolved variant is out of stock but pre-orderable — label the action "Pre-order". */
|
|
38
|
+
isPreorder: boolean;
|
|
39
|
+
/** False until every option is selected and the resolved variant is in stock or pre-orderable. */
|
|
40
|
+
canAdd: boolean;
|
|
41
|
+
/** Why the action is disabled — neutral guidance ("Choose Size"), not an error; null when addable. */
|
|
42
|
+
blockedReason: string | null;
|
|
43
|
+
quantity: number;
|
|
44
|
+
adding: boolean;
|
|
45
|
+
error: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ProductDetailStore {
|
|
49
|
+
getState(): ProductDetailState;
|
|
50
|
+
subscribe(listener: () => void): () => void;
|
|
51
|
+
/** Fetch by slug when no `initial` was given. Call once when mounted. */
|
|
52
|
+
start(): void;
|
|
53
|
+
stop(): void;
|
|
54
|
+
selectOption(optionName: string, choiceName: string): void;
|
|
55
|
+
setModifier(key: string, value: string): void;
|
|
56
|
+
setQuantity(n: number): void;
|
|
57
|
+
/** Adds the resolved variant to the cart (the cart store opens the drawer). Throws on refusal. */
|
|
58
|
+
add(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createProductDetailStore({ initial, slug }: ProductDetailStoreOptions): ProductDetailStore {
|
|
62
|
+
let product: ProductDetail | null = initial ?? null;
|
|
63
|
+
let notFound = false;
|
|
64
|
+
let selections: Record<string, string> = {};
|
|
65
|
+
let modifierValues: Record<string, string> = {};
|
|
66
|
+
let quantity = 1;
|
|
67
|
+
let adding = false;
|
|
68
|
+
let error: string | null = null;
|
|
69
|
+
let started = false;
|
|
70
|
+
const listeners = new Set<() => void>();
|
|
71
|
+
let snapshot: ProductDetailState | null = null;
|
|
72
|
+
const emit = () => { snapshot = null; for (const fn of listeners) fn(); };
|
|
73
|
+
|
|
74
|
+
function getState(): ProductDetailState {
|
|
75
|
+
if (snapshot) return snapshot;
|
|
76
|
+
const variant = product ? resolveVariant(product, selections) : null;
|
|
77
|
+
const optionGroups: OptionGroupView[] = (product?.options ?? []).map((o) => ({
|
|
78
|
+
...o,
|
|
79
|
+
choices: o.choices.map((c) => ({ ...c, selected: selections[o.name] === c.name })),
|
|
80
|
+
}));
|
|
81
|
+
const missingModifier = (product?.modifiers ?? []).find((m) => m.mandatory && (modifierValues[m.key] ?? "").length === 0);
|
|
82
|
+
const missingOptions = (product?.options ?? []).filter((o) => !selections[o.name]).map((o) => o.name);
|
|
83
|
+
const selectionComplete = missingOptions.length === 0;
|
|
84
|
+
// In stock OR pre-orderable counts as buyable; a preorder add carries preOrderRequested.
|
|
85
|
+
const isPreorder = !!variant && !variant.inStock && variant.preorderEnabled;
|
|
86
|
+
const available = !!variant && (variant.inStock || variant.preorderEnabled);
|
|
87
|
+
const canAdd = !!product && available && !missingModifier;
|
|
88
|
+
const blockedReason: string | null = !product
|
|
89
|
+
? null
|
|
90
|
+
: !selectionComplete ? `Choose ${missingOptions.join(" and ")}`
|
|
91
|
+
: !variant ? "This combination isn't available"
|
|
92
|
+
: !available ? "Out of stock"
|
|
93
|
+
: missingModifier ? `Add ${missingModifier.name}`
|
|
94
|
+
: null;
|
|
95
|
+
// Before every option is picked, the range — never an empty price, never a lone struck minimum.
|
|
96
|
+
const isRange = !!product && product.price !== product.maxPrice && !!product.maxPrice;
|
|
97
|
+
const rangeDisplay = product ? (isRange ? `${product.price} – ${product.maxPrice}` : product.price) : "";
|
|
98
|
+
snapshot = {
|
|
99
|
+
product, notFound, optionGroups, modifierValues, variant,
|
|
100
|
+
price: variant?.price || rangeDisplay,
|
|
101
|
+
compareAtPrice: variant ? variant.compareAtPrice : isRange ? null : (product?.compareAtPrice ?? null),
|
|
102
|
+
isPreorder, canAdd, blockedReason, quantity, adding, error,
|
|
103
|
+
};
|
|
104
|
+
return snapshot;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
getState,
|
|
109
|
+
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
110
|
+
start() {
|
|
111
|
+
if (started) return;
|
|
112
|
+
started = true;
|
|
113
|
+
if (initial || !slug) return;
|
|
114
|
+
fetchProductBySlug(slug)
|
|
115
|
+
.then((p) => { if (!started) return; product = p; notFound = p === null; emit(); })
|
|
116
|
+
.catch(() => { if (started) { notFound = true; emit(); } });
|
|
117
|
+
},
|
|
118
|
+
stop() { started = false; },
|
|
119
|
+
selectOption(optionName, choiceName) {
|
|
120
|
+
selections = { ...selections, [optionName]: choiceName };
|
|
121
|
+
quantity = 1; // the ceiling belongs to the newly resolved variant
|
|
122
|
+
emit();
|
|
123
|
+
},
|
|
124
|
+
setModifier(key, value) { modifierValues = { ...modifierValues, [key]: value }; emit(); },
|
|
125
|
+
setQuantity(n) { quantity = n; emit(); },
|
|
126
|
+
async add() {
|
|
127
|
+
const s = getState();
|
|
128
|
+
if (!product || !s.variant) return;
|
|
129
|
+
adding = true; error = null; emit();
|
|
130
|
+
try {
|
|
131
|
+
const choiceModifiers: Record<string, string> = {};
|
|
132
|
+
const textModifiers: Record<string, string> = {};
|
|
133
|
+
for (const m of product.modifiers) {
|
|
134
|
+
const value = modifierValues[m.key];
|
|
135
|
+
if (!value) continue;
|
|
136
|
+
if (m.type === "text") textModifiers[m.key] = value; else choiceModifiers[m.key] = value;
|
|
137
|
+
}
|
|
138
|
+
await addLine(product.id, s.variant.variantId, quantity, { modifierChoices: choiceModifiers, customTextFields: textModifiers, preorder: s.isPreorder });
|
|
139
|
+
} catch (e) {
|
|
140
|
+
error = e instanceof Error ? e.message : String(e);
|
|
141
|
+
throw e;
|
|
142
|
+
} finally {
|
|
143
|
+
adding = false; emit();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|