@wix/agent-skills 1.19.0 → 1.19.2
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 +33 -31
- package/skills/wix-app/references/DASHBOARD_PAGE.md +6 -10
- package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +17 -8
- package/skills/wix-app/references/WIX_PATTERNS_DOCS.md +53 -131
- package/skills/wix-app/references/dashboard-page/UX_SUCCESS_MODEL.md +1 -2
- package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +88 -73
- package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +46 -22
- package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +20 -6
- package/skills/wix-app/references/editor-react-component/CSS-GUIDELINES.md +9 -0
- package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
- package/skills/wix-app/references/editor-react-component/PARTS.md +5 -2
- package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +4 -5
- package/skills/wix-app/scripts/pkg-root.cjs +73 -0
- package/skills/wix-app/scripts/scan-a11y-code.cjs +310 -40
- package/skills/wix-app/scripts/scan-a11y-eslint.cjs +118 -24
- package/skills/wix-app/scripts/scan-a11y-render.cjs +551 -0
- package/skills/wix-app/scripts/scan-a11y-review.cjs +373 -0
- package/skills/wix-base44-connector/SKILL.md +67 -43
- package/skills/wix-base44-connector/scripts/utils.cjs +39 -3
- package/skills/wix-docs/SKILL.md +47 -20
- package/skills/wix-headless/SKILL.md +1 -0
- package/skills/wix-headless/entry/bootstrap.mjs +106 -50
- package/skills/wix-headless/entry/skill.md +5 -3
- 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 +48 -0
- package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +350 -114
- package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
- package/skills/wix-headless/references/managed/AUTHENTICATION.md +25 -5
- package/skills/wix-headless/references/managed/CREATE.md +5 -0
- package/skills/wix-headless-fast/SKILL.md +25 -7
- package/skills/wix-headless-fast/entry/skill.md +23 -14
- package/skills/wix-headless-fast/install/fast-path.mjs +48 -5
- package/skills/wix-headless-fast/install/pin-agents-md.mjs +57 -0
- package/skills/wix-headless-fast/references/shared/CUSTOM_OPERATIONS.md +3 -3
- package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +204 -35
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +146 -36
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-store.ts +8 -2
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +95 -20
- package/skills/wix-headless-fast/references/storefront/seed/SEED.md +21 -2
- package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +104 -18
- package/skills/wix-manage/SKILL.md +16 -10
- 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/google-ads/billing-and-payment.md +3 -3
- package/skills/wix-manage/references/google-ads/create-performance-max-campaign.md +8 -8
- package/skills/wix-manage/references/google-ads/get-campaign-suggestions.md +4 -4
- package/skills/wix-manage/references/google-ads/google-ads-dashboard-navigation.md +1 -1
- package/skills/wix-manage/references/google-ads/install-and-create-account.md +9 -9
- package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
- package/skills/wix-manage/references/google-ads/query-campaign-analytics.md +7 -7
- package/skills/wix-manage/references/seo/manage-content-plan.md +182 -136
- package/skills/wix-manage/references/seo/manage-google-search-console.md +272 -0
- package/skills/wix-manage/references/sites/upload-static-site.md +283 -0
- package/skills/wix-manage/references/stores/change-store-currency.md +1 -1
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +3 -1
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +3 -1
- package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
- package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +69 -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 +41 -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
- package/skills/wix-app/references/dashboard-page/COLLECTION_TOOLKIT.md +0 -80
- package/skills/wix-app/references/dashboard-page/ENTITY_PAGE_TOOLKIT.md +0 -140
- package/skills/wix-app/references/dashboard-page/PATTERNS_BUNDLE_READING.md +0 -112
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductCard.tsx +0 -100
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductDetailView.tsx +0 -130
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductGrid.tsx +0 -48
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/ShopView.tsx +0 -66
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/VariantPicker.tsx +0 -103
- package/skills/wix-headless-fast/references/storefront/app-astro/pages/products/[slug].astro +0 -56
- package/skills/wix-headless-fast/references/storefront/app-astro/pages/shop.astro +0 -23
|
@@ -9,7 +9,7 @@ or configuration changes are needed to build the UI. Never mock products or hand
|
|
|
9
9
|
`/checkout` URL; the shipped cart uses the eCom redirect session.
|
|
10
10
|
|
|
11
11
|
## Prerequisites
|
|
12
|
-
- The site's **Wix Stores** catalog is the read/cart target. It
|
|
12
|
+
- The site's **Wix Stores** catalog is the read/cart target. It may be empty while the client is being built — render the empty state cleanly. Flows that seed do so after the client is built; verify the storefront once the catalog is populated, not against the empty state.
|
|
13
13
|
|
|
14
14
|
## Already installed in `src/`
|
|
15
15
|
Successful deployment verified these files are in place; use this map without needing to read their source (`@/` → `src/`).
|
|
@@ -103,18 +103,22 @@ export default function ProductCard({ product }) {
|
|
|
103
103
|
const {
|
|
104
104
|
isSoldOut, isPreorder, isPartiallyOutOfStock, // booleans
|
|
105
105
|
leftBadges, // [{ type: 'pre-order'|'sold-out'|'limited-stock', label }]
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
ribbons, // every merchant ribbon label, primary first — render ALL of them, one shared style
|
|
107
|
+
promoBadge, // { type: 'ribbon', label } | null — the primary ribbon, for a single-badge slot
|
|
108
|
+
priceDisplay, // the price the buyer pays (discounted when an automatic discount applies), or a min–max range
|
|
109
|
+
compareAtDisplay, // struck "was" price | null — never beside a range (the PDP shows the real comparison)
|
|
109
110
|
colors, // hex colour strings
|
|
110
111
|
optionLabel, // e.g. "3 sizes · 2 materials", or ""
|
|
111
112
|
isQuickAddable, // no product options and not sold out; does NOT check modifiers
|
|
113
|
+
directAddVariantId, // the variant a quick-add sends; null when not quick-addable
|
|
112
114
|
image, hoverImage,// normalised primary / second gallery URL | null
|
|
113
115
|
} = useProductCard(product);
|
|
114
116
|
// Render your card.
|
|
115
117
|
}
|
|
116
118
|
```
|
|
117
|
-
|
|
119
|
+
A ribbon is a label, never proof of a price: don't render a "Sale" badge from `compareAtDisplay`,
|
|
120
|
+
and don't compute a percent-off — the hook deliberately doesn't. For quick-add, call
|
|
121
|
+
`addToCart(product.id, directAddVariantId)` only when `isQuickAddable` and no mandatory
|
|
118
122
|
modifier needs input (`product.modifiers` entries have a `mandatory` boolean). Otherwise link to `/product/${product.slug}` for selection, including
|
|
119
123
|
pre-orders. Listing results have no full variants; the PDP hook loads and resolves them.
|
|
120
124
|
Use cart-context `loading` to disable repeated adds. Failures open the shipped drawer with
|
|
@@ -198,15 +202,19 @@ export default function ProductDetail() {
|
|
|
198
202
|
// d contains:
|
|
199
203
|
// product: Wix product | null; .name, .slug, .plainDescription (HTML)
|
|
200
204
|
// notFound: boolean; error: string | null (load error); retry: () => void
|
|
201
|
-
// price
|
|
205
|
+
// price: formatted — the product's min–max range until every option is picked, then the selected
|
|
206
|
+
// variant's price (an automatic discount beats the regular price); compareAtPrice: the struck
|
|
207
|
+
// "was" price, "" when none. discountNames: automatic-discount names, product level.
|
|
202
208
|
// options, modifiers: arrays consumed by useVariantOptions above
|
|
203
|
-
// selectedOptions: { [optionId]: choiceId }; selectOption(optionId, choiceId)
|
|
209
|
+
// selectedOptions: { [optionId]: choiceId } — starts EMPTY (no pre-picked choice); selectOption(optionId, choiceId)
|
|
204
210
|
// modifierValues: { [key]: value }; setModifier(key, value)
|
|
205
211
|
// variant: resolved variant | null; null if required choices are missing or no match exists
|
|
206
212
|
// focusMediaUrl: selected choice image, then variant image, or null
|
|
207
|
-
// quantity, setQuantity(n): number (may be "" mid-edit)
|
|
208
|
-
// inStock, canAdd, adding: booleans
|
|
209
|
-
//
|
|
213
|
+
// quantity, setQuantity(n): number (may be "" mid-edit); resets to 1 when an option changes
|
|
214
|
+
// inStock, isPreorder, canAdd, adding: booleans — a pre-order variant IS addable
|
|
215
|
+
// blockedReason: string | null — why the buy button is disabled ("Choose Size", "Out of stock");
|
|
216
|
+
// render it next to the action as neutral guidance, not an error, until the buyer tries to buy
|
|
217
|
+
// submit: async () => adds product + resolved variant + quantity + modifiers (+ preorder)
|
|
210
218
|
// resolves undefined on success, null on failure (not a cart object or a boolean)
|
|
211
219
|
// Replace these placeholders with your error, not-found, and loading UI.
|
|
212
220
|
if (d.error) return null; // show d.error and offer d.retry()
|
|
@@ -220,24 +228,28 @@ export default function ProductDetail() {
|
|
|
220
228
|
```
|
|
221
229
|
- Render `product.plainDescription` as HTML; strike `compareAtPrice` only when present and different from `price`.
|
|
222
230
|
- Make the gallery follow `focusMediaUrl` when the selected option changes.
|
|
223
|
-
- Render the option/modifier controls below;
|
|
224
|
-
- Keep quantity at least 1. Disable adding when `!canAdd || adding`; call `submit()` only with a loaded product and `canAdd`. `submit()` coerces quantity to at least 1 but does not itself enforce `canAdd`.
|
|
225
|
-
- `canAdd` checks variant resolution,
|
|
231
|
+
- Render the option/modifier controls below; mark choices whose `inStock` is false; show `d.blockedReason` beside the button while it's disabled.
|
|
232
|
+
- Label the action by state: `isPreorder` → "Pre-order"; otherwise "Add to cart". Keep quantity at least 1. Disable adding when `!canAdd || adding`; call `submit()` only with a loaded product and `canAdd`. `submit()` coerces quantity to at least 1 but does not itself enforce `canAdd`.
|
|
233
|
+
- `canAdd` checks variant resolution, availability (in stock or pre-orderable), and mandatory modifier values. `inStock` defaults to true without a resolved variant; use `canAdd` for the full gate.
|
|
234
|
+
- Render the PDP gallery from `images` — every image reachable (thumbnails, arrows, or a swipeable rail), not just the first; a single-image product gets no empty strip.
|
|
226
235
|
- `submit()` resets `adding` after completion and preserves the cart result: `undefined` on success, `null` on failure. Add failures live in `useCart().error`, not the PDP load `error`; the shipped drawer opens to display them.
|
|
227
236
|
|
|
228
|
-
For an optional **
|
|
237
|
+
For an optional **add and check out** shortcut, check the add result before checkout:
|
|
229
238
|
```jsx
|
|
230
239
|
// Inside ProductDetail, with the other hooks above any conditional returns:
|
|
231
240
|
const { checkout, loading: cartLoading } = useCart(); // named import from @/context/CartContext
|
|
232
|
-
async function
|
|
241
|
+
async function addAndCheckout() {
|
|
233
242
|
if (!d.product || !d.canAdd || d.adding || cartLoading) return;
|
|
234
243
|
const result = await d.submit();
|
|
235
244
|
if (result === null) return; // add failed; preserve its error and do not check out the old cart
|
|
236
245
|
await checkout();
|
|
237
246
|
}
|
|
238
|
-
// <button disabled={!d.canAdd || d.adding || cartLoading} onClick={
|
|
247
|
+
// <button disabled={!d.canAdd || d.adding || cartLoading} onClick={addAndCheckout}>Add and check out</button>
|
|
239
248
|
```
|
|
240
|
-
|
|
249
|
+
This adds to the shopper's **current cart** and checks that cart out — so label it that way. A true
|
|
250
|
+
**Buy Now** (skip the cart, buy just this item) needs a standalone cart handed to the redirect session;
|
|
251
|
+
it is not shipped, and routing "Buy now" through the current cart is wrong (it would carry whatever the
|
|
252
|
+
cart already holds). For direct `addToCart(...)` calls, use the same `result === null` check before checkout.
|
|
241
253
|
A resolved promise alone does not mean success; a truthiness check also rejects successful `undefined`.
|
|
242
254
|
|
|
243
255
|
### Variant and modifier controls
|
|
@@ -277,14 +289,17 @@ within `CartProvider` (the PDP hook calls it for you).
|
|
|
277
289
|
|
|
278
290
|
```js
|
|
279
291
|
const { addToCart, isOpen, setIsOpen, loading, error, clearError } = useCart();
|
|
280
|
-
// addToCart(productId, variantId?, qty = 1, { modifierChoices?, customTextFields? }?)
|
|
292
|
+
// addToCart(productId, variantId?, qty = 1, { modifierChoices?, customTextFields?, subscriptionOptionId?, preorder? }?)
|
|
281
293
|
// isOpen: boolean; setIsOpen(true): open the drawer; setIsOpen(false): close it
|
|
282
294
|
// loading: mutation-in-progress boolean; disable repeated adds while true
|
|
283
295
|
// error: string | null; clearError(): clears it
|
|
284
296
|
```
|
|
285
|
-
`addToCart` uses `product.id` and the resolved `variant.id` when needed. Extras
|
|
297
|
+
`addToCart` uses `product.id` and the resolved `variant.id` when needed. Extras: string maps
|
|
286
298
|
`modifierChoices: { [modifier.key]: choiceKey }` and
|
|
287
|
-
`customTextFields: { [modifier.freeTextSettings.key]: userInput }
|
|
299
|
+
`customTextFields: { [modifier.freeTextSettings.key]: userInput }` (include mandatory values);
|
|
300
|
+
`preorder: true` for a pre-order variant (the PDP hook sets it); `subscriptionOptionId` for a chosen
|
|
301
|
+
recurring plan (omit for one-time). The shipped PDP hook doesn't render subscriptions — add that
|
|
302
|
+
only when the catalog has `subscriptionPricesInfo`, requiring an explicit plan choice.
|
|
288
303
|
|
|
289
304
|
The context's add/remove/update/checkout methods return promises resolving to `undefined` on
|
|
290
305
|
success or `null` on failure, storing the failure in `error`. They clear the previous error and
|
|
@@ -298,8 +313,10 @@ Only use these additional contracts if you choose to render your own cart surfac
|
|
|
298
313
|
from the same `useCart()` context; mutation results and error handling are described above.
|
|
299
314
|
|
|
300
315
|
```js
|
|
301
|
-
const { cart, itemCount, removeItem, updateQuantity, checkout, refreshCart } = useCart();
|
|
316
|
+
const { cart, summary, itemCount, removeItem, updateQuantity, checkout, refreshCart } = useCart();
|
|
302
317
|
// cart: server cart | null; itemCount: sum of confirmed line quantities
|
|
318
|
+
// summary: the estimate's { priceSummary: { subtotal, discount, total } } | null — refreshed with the cart;
|
|
319
|
+
// subtotal is after discounts, discount is the CART-level discount only; both ConvertedMoney
|
|
303
320
|
// removeItem(lineItemId); updateQuantity(lineItemId, qty)
|
|
304
321
|
// checkout(); refreshCart()
|
|
305
322
|
```
|
|
@@ -307,7 +324,8 @@ For custom cart presentation, `cart?.lineItems ?? []` is the list. Each line has
|
|
|
307
324
|
`name.original`, and optional `attributes.image.url`. Option/modifier labels are in
|
|
308
325
|
`attributes.descriptionLines`: `[{ name: { original }, plainText?: { original },
|
|
309
326
|
colorInfo?: { original, code } }]`; use `plainText.original` or `colorInfo.original` for the value.
|
|
310
|
-
Update/remove use the line's `id`, not a catalog product id.
|
|
327
|
+
Update/remove use the line's `id`, not a catalog product id. Show each line's `attributes.descriptionLines`
|
|
328
|
+
(the chosen options and text) — a shopper verifies the configuration in the cart, not the checkout.
|
|
311
329
|
|
|
312
330
|
Normalize custom cart images with `storeImage(line.attributes?.image?.url)` (see **Images**).
|
|
313
331
|
|
|
@@ -320,10 +338,10 @@ Cart money is `{ amount, convertedAmount }`, with no formatted string. `amount`
|
|
|
320
338
|
currency; `convertedAmount` is in display currency. Format
|
|
321
339
|
`money.convertedAmount ?? money.amount` with `Intl.NumberFormat` and
|
|
322
340
|
`cart.customerInfo?.currencyCode ?? cart.businessInfo?.currencyCode` (the shipped drawer falls
|
|
323
|
-
back to `USD`). Use
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
341
|
+
back to `USD`). Use `summary.priceSummary.subtotal` (after discounts) with `cart.subtotal` as the
|
|
342
|
+
fallback while `summary` is null, a Discount row only when `priceSummary.discount` is above zero, and
|
|
343
|
+
line `pricing.totalPrice`; never sum lines yourself. Shipping and tax resolve at checkout — say
|
|
344
|
+
"calculated at checkout", never a hardcoded charge, threshold, or a made-up zero.
|
|
327
345
|
|
|
328
346
|
Line `quantityInfo.confirmedQuantity` is the current quantity; `availableQuantity` caps increases
|
|
329
347
|
when finite. `status` can be `IN_STOCK`, `PARTIALLY_IN_STOCK`, `OUT_OF_STOCK`, or
|
|
@@ -416,6 +434,28 @@ function Layout() {
|
|
|
416
434
|
}
|
|
417
435
|
```
|
|
418
436
|
|
|
437
|
+
## What a complete storefront shows
|
|
438
|
+
|
|
439
|
+
The wiring above is necessary, not sufficient. These are defaults for when the brief doesn't say
|
|
440
|
+
otherwise — whatever the user asked for in their prompt wins over any line here. Look at the
|
|
441
|
+
catalog before designing — categories, assortment size, media, options, sales — and design for this
|
|
442
|
+
store, not for a stereotype of its category. Then:
|
|
443
|
+
|
|
444
|
+
- **Home:** what the store sells and one shopping action in the first screen; real products from
|
|
445
|
+
the catalog under truthful headings; not a repeat of the shop page.
|
|
446
|
+
- **Shop:** a real product card — image, name, price, link — in the first screen; sort and the
|
|
447
|
+
filters the catalog supports; loading, empty, no-results, and error states that look different.
|
|
448
|
+
- **Product page:** image, name, price, the first choice, and the buy button with `blockedReason`
|
|
449
|
+
in the first screen; every ribbon; every image reachable in the gallery.
|
|
450
|
+
- **Cart:** the shipped drawer — it opens after every add, and checkout is a button in it.
|
|
451
|
+
- **Overlays you build** (quick-add, mobile nav, filters): mount at the document root, lock
|
|
452
|
+
background scroll, close on Escape, return focus on close — as the shipped `CartDrawer` does.
|
|
453
|
+
- **Copy:** nothing the merchant didn't supply — no invented reviews, scarcity, or delivery
|
|
454
|
+
promises; no Wix IDs or technical words in visible text.
|
|
455
|
+
|
|
456
|
+
Pre-order ships (`isPreorder`). Subscriptions, product groups, promotions, and notify-me are built
|
|
457
|
+
only when the catalog has them — never fabricated; consult the documentation skill for their endpoints.
|
|
458
|
+
|
|
419
459
|
## Missing capabilities
|
|
420
460
|
For anything these interfaces do not cover, consult the official Wix API documentation using
|
|
421
461
|
the documentation skill available in your environment.
|
|
@@ -425,4 +465,6 @@ shipped file; catalog and cart helpers link their API references inline.
|
|
|
425
465
|
## Hard rules
|
|
426
466
|
- Header/footer live in a `Layout` around `<Outlet/>` (see **Routes and provider**) — keep shared chrome out of individual pages.
|
|
427
467
|
- Checkout goes through the shipped cart (redirect-session) — never a hand-built `/checkout` URL.
|
|
428
|
-
- Render live Wix data or your empty state — never mock products.
|
|
468
|
+
- Render live Wix data or your empty state — never mock products, reviews, ratings, or counts.
|
|
469
|
+
- Prices and ribbons come from the hooks as-is: no computed percent-off, no "Sale" badge inferred from a compare-at price, no struck price beside a range.
|
|
470
|
+
- Cart totals come from `summary`/`cart` — never summed or hardcoded in the client; shipping and tax say "calculated at checkout".
|
|
@@ -5,15 +5,14 @@
|
|
|
5
5
|
// • prices come from the SERVER cart. In Cart V2 each line total is `pricing.totalPrice`, a
|
|
6
6
|
// ConvertedMoney { amount, convertedAmount } with NO formatted string — format it yourself with the
|
|
7
7
|
// cart's currency (see formatCartMoney). Never compute a total in the client: tax, shipping and
|
|
8
|
-
// promotions are resolved server-side
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// cart; absent that call, show the raw `subtotal`.
|
|
8
|
+
// promotions are resolved server-side. The V2 cart has no `subtotalAfterDiscounts`/`discount` —
|
|
9
|
+
// the after-discount subtotal and the cart-level discount come from the estimate the context keeps
|
|
10
|
+
// in `summary.priceSummary`; the raw cart `subtotal` is the fallback while it's unknown.
|
|
12
11
|
// • `quantityInfo.availableQuantity` caps the stepper, so exceeding stock is refused here rather than
|
|
13
12
|
// at checkout.
|
|
14
13
|
// • `status` is flagged per line here because checkout() refuses the whole cart when any item isn't
|
|
15
14
|
// IN_STOCK — showing it on the row is what makes that refusal understandable.
|
|
16
|
-
import { useEffect } from "react";
|
|
15
|
+
import { useEffect, useRef } from "react";
|
|
17
16
|
import { useCart } from "@/context/CartContext";
|
|
18
17
|
import { storeImage } from "@/lib/storeImage";
|
|
19
18
|
|
|
@@ -26,28 +25,41 @@ function formatCartMoney(money, cart) {
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
export default function CartDrawer() {
|
|
29
|
-
const { cart, isOpen, setIsOpen, removeItem, updateQuantity, checkout, loading, error, clearError } = useCart();
|
|
28
|
+
const { cart, summary, isOpen, setIsOpen, removeItem, updateQuantity, checkout, loading, error, clearError } = useCart();
|
|
30
29
|
const lineItems = cart?.lineItems ?? [];
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
30
|
+
// Totals come from the estimate the context keeps next to the cart (after-discount subtotal, the
|
|
31
|
+
// cart-level discount). While it's unknown, or if it failed, fall back to the cart's raw subtotal.
|
|
32
|
+
// Delivery and tax are NOT in here — they resolve at checkout, and the footer says so.
|
|
33
|
+
const subtotal = formatCartMoney(summary?.priceSummary?.subtotal ?? cart?.subtotal, cart);
|
|
34
|
+
const discountAmount = Number(summary?.priceSummary?.discount?.convertedAmount ?? summary?.priceSummary?.discount?.amount ?? 0);
|
|
35
|
+
const discount = discountAmount > 0 ? formatCartMoney(summary.priceSummary.discount, cart) : "";
|
|
34
36
|
const unavailable = lineItems.filter((li) => li.status && li.status !== "IN_STOCK");
|
|
35
37
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
+
// The overlay contract, verified in the browser rather than assumed from CSS: Escape closes (the
|
|
39
|
+
// backdrop click is pointer-only), the page behind stops scrolling, focus moves into the panel on
|
|
40
|
+
// open and returns to whatever opened it on close.
|
|
41
|
+
const panelRef = useRef(null);
|
|
38
42
|
useEffect(() => {
|
|
39
43
|
if (!isOpen) return;
|
|
44
|
+
const opener = document.activeElement;
|
|
45
|
+
const previousOverflow = document.body.style.overflow;
|
|
46
|
+
document.body.style.overflow = "hidden";
|
|
47
|
+
panelRef.current?.focus();
|
|
40
48
|
const onKey = (e) => e.key === "Escape" && setIsOpen(false);
|
|
41
49
|
document.addEventListener("keydown", onKey);
|
|
42
|
-
return () =>
|
|
50
|
+
return () => {
|
|
51
|
+
document.removeEventListener("keydown", onKey);
|
|
52
|
+
document.body.style.overflow = previousOverflow;
|
|
53
|
+
if (opener instanceof HTMLElement) opener.focus();
|
|
54
|
+
};
|
|
43
55
|
}, [isOpen, setIsOpen]);
|
|
44
56
|
|
|
45
57
|
if (!isOpen) return null;
|
|
46
58
|
|
|
47
59
|
return (
|
|
48
60
|
<div onClick={() => setIsOpen(false)} className="fixed inset-0 z-50 bg-black/40 flex justify-end">
|
|
49
|
-
<aside onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Your cart"
|
|
50
|
-
className="w-[min(420px,100%)] h-full flex flex-col bg-background text-foreground border-l border-border font-body">
|
|
61
|
+
<aside ref={panelRef} tabIndex={-1} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Your cart"
|
|
62
|
+
className="w-[min(420px,100%)] h-full flex flex-col bg-background text-foreground border-l border-border font-body outline-none">
|
|
51
63
|
<header className="flex justify-between items-center p-4 border-b border-border">
|
|
52
64
|
<strong className="font-display">Your cart{lineItems.length ? ` (${lineItems.length})` : ""}</strong>
|
|
53
65
|
<button onClick={() => setIsOpen(false)} aria-label="Close cart"
|
|
@@ -123,6 +135,12 @@ export default function CartDrawer() {
|
|
|
123
135
|
|
|
124
136
|
{lineItems.length > 0 && (
|
|
125
137
|
<footer className="p-4 border-t border-border flex flex-col gap-3">
|
|
138
|
+
{discount && (
|
|
139
|
+
<div className="flex justify-between items-baseline text-sm">
|
|
140
|
+
<span className="text-muted-foreground">Discount</span>
|
|
141
|
+
<span>−{discount}</span>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
126
144
|
{subtotal && (
|
|
127
145
|
<div className="flex justify-between items-baseline">
|
|
128
146
|
<span className="text-muted-foreground">Subtotal</span>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
|
4
4
|
import {
|
|
5
5
|
getCurrentCart,
|
|
6
|
+
estimateCurrentCart,
|
|
6
7
|
addToCart as apiAdd,
|
|
7
8
|
removeFromCart as apiRemove,
|
|
8
9
|
updateCartItemQuantity as apiQty,
|
|
@@ -12,7 +13,11 @@ import {
|
|
|
12
13
|
const CartContext = createContext(null);
|
|
13
14
|
|
|
14
15
|
export function CartProvider({ children }) {
|
|
15
|
-
const [cart,
|
|
16
|
+
const [cart, setCartState] = useState(null);
|
|
17
|
+
// The calculated summary (after-discount subtotal, cart-level discount) — from the estimate, never
|
|
18
|
+
// summed in the client. Refreshed with every cart change; null while unknown or when the estimate
|
|
19
|
+
// fails, in which case surfaces fall back to cart.subtotal.
|
|
20
|
+
const [summary, setSummary] = useState(null);
|
|
16
21
|
const [isOpen, setIsOpen] = useState(false);
|
|
17
22
|
const [loading, setLoading] = useState(false);
|
|
18
23
|
// The cart helpers throw on refusal with a buyer-readable reason — an empty cart, or line items
|
|
@@ -20,7 +25,13 @@ export function CartProvider({ children }) {
|
|
|
20
25
|
// this the rejection is unhandled and the shopper sees the spinner stop with nothing said.
|
|
21
26
|
const [error, setError] = useState(null);
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
// Every cart write goes through here so the summary always describes the cart on screen.
|
|
29
|
+
const setCart = useCallback((next) => {
|
|
30
|
+
setCartState(next);
|
|
31
|
+
if (!next?.lineItems?.length) { setSummary(null); return; }
|
|
32
|
+
estimateCurrentCart().then((s) => setSummary(s)).catch(() => setSummary(null));
|
|
33
|
+
}, []);
|
|
34
|
+
const refreshCart = useCallback(async () => setCart(await getCurrentCart()), [setCart]);
|
|
24
35
|
useEffect(() => {
|
|
25
36
|
refreshCart();
|
|
26
37
|
const onVisible = () => document.visibilityState === "visible" && refreshCart();
|
|
@@ -59,7 +70,7 @@ export function CartProvider({ children }) {
|
|
|
59
70
|
run(async () => { window.location.href = await apiCheckout(); }, { reread: true });
|
|
60
71
|
|
|
61
72
|
return (
|
|
62
|
-
<CartContext.Provider value={{ cart, itemCount, isOpen, setIsOpen, loading, error, clearError: () => setError(null), addToCart, removeItem, updateQuantity, checkout, refreshCart }}>
|
|
73
|
+
<CartContext.Provider value={{ cart, summary, itemCount, isOpen, setIsOpen, loading, error, clearError: () => setError(null), addToCart, removeItem, updateQuantity, checkout, refreshCart }}>
|
|
63
74
|
{children}
|
|
64
75
|
</CartContext.Provider>
|
|
65
76
|
);
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
// Headless data layer for a product grid tile.
|
|
2
2
|
// Normalises raw Wix product fields into render-agnostic structures so you can build
|
|
3
3
|
// whatever card UI you want (image layout, badge style, price display, quick-add trigger)
|
|
4
|
-
// without touching the badge
|
|
4
|
+
// without touching the badge logic, price rules, or colour-dot extraction.
|
|
5
5
|
//
|
|
6
6
|
// Usage:
|
|
7
|
-
// const { isSoldOut, leftBadges, promoBadge, priceDisplay, compareAtDisplay,
|
|
8
|
-
// colors, optionLabel, isQuickAddable, image, hoverImage } = useProductCard(product);
|
|
9
|
-
// // then render however you want
|
|
7
|
+
// const { isSoldOut, leftBadges, ribbons, promoBadge, priceDisplay, compareAtDisplay,
|
|
8
|
+
// colors, optionLabel, isQuickAddable, directAddVariantId, image, hoverImage } = useProductCard(product);
|
|
9
|
+
// // then render however you want; quick-add: addToCart(product.id, directAddVariantId) from useCart().
|
|
10
10
|
|
|
11
11
|
import { useMemo } from "react";
|
|
12
12
|
import { productImage, productGallery } from "@/lib/storeImage";
|
|
13
|
-
|
|
14
|
-
function discountPercent(product) {
|
|
15
|
-
const now = Number(product?.actualPriceRange?.minValue?.amount);
|
|
16
|
-
const was = Number(product?.compareAtPriceRange?.minValue?.amount);
|
|
17
|
-
if (!now || !was || was <= now) return null;
|
|
18
|
-
return Math.round(((was - now) / was) * 100);
|
|
19
|
-
}
|
|
13
|
+
import { sellingPrice } from "@/rest/wix-store-catalog";
|
|
20
14
|
|
|
21
15
|
export function useProductCard(product) {
|
|
22
16
|
return useMemo(() => {
|
|
@@ -32,23 +26,31 @@ export function useProductCard(product) {
|
|
|
32
26
|
else if (isSoldOut) leftBadges.push({ type: "sold-out", label: "Sold out" });
|
|
33
27
|
if (isPartiallyOutOfStock) leftBadges.push({ type: "limited-stock", label: "Limited stock" });
|
|
34
28
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
? { type: "ribbon", label: ribbon }
|
|
43
|
-
: null;
|
|
29
|
+
// Merchant ribbons — the primary one plus every additional one. Render ALL of them, in one shared
|
|
30
|
+
// style (a "Sale" accent is fine, applied by label). Never compute a "-20%" badge from the price
|
|
31
|
+
// range: a range's minimum says nothing about the variant the buyer picks, and a ribbon is a
|
|
32
|
+
// label, not a price claim.
|
|
33
|
+
const ribbons = [product?.ribbon?.name, ...(product?.additionalRibbons ?? []).map((r) => r?.name)]
|
|
34
|
+
.filter((name, i, all) => name && all.indexOf(name) === i);
|
|
35
|
+
const promoBadge = ribbons[0] ? { type: "ribbon", label: ribbons[0] } : null;
|
|
44
36
|
|
|
45
|
-
// Price
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
const
|
|
37
|
+
// Price. A single-price product shows the price the buyer pays — the lowest-priced variant's
|
|
38
|
+
// discounted price when an automatic discount applies (priceAfterDiscount), else its regular
|
|
39
|
+
// price — with the struck "was" price beside it. Variants priced differently show a min–max
|
|
40
|
+
// range with NO struck price: one lone "was" against a range implies a saving that may not
|
|
41
|
+
// apply to the variant the buyer picks; the PDP shows the real comparison once a variant is chosen.
|
|
42
|
+
const min = product?.actualPriceRange?.minValue;
|
|
43
|
+
const max = product?.actualPriceRange?.maxValue;
|
|
44
|
+
const isRange = !!(min?.amount && max?.amount && min.amount !== max.amount);
|
|
45
|
+
const minVariant = product?.variantSummary?.minPriceVariant ?? null;
|
|
46
|
+
const { current, original } = sellingPrice(minVariant?.price);
|
|
47
|
+
const priceDisplay = isRange
|
|
48
|
+
? `${min?.formattedAmount ?? ""} – ${max?.formattedAmount ?? ""}`
|
|
49
|
+
: current?.formattedAmount ?? min?.formattedAmount;
|
|
50
|
+
const compareAtDisplay =
|
|
51
|
+
!isRange && original?.formattedAmount && Number(original.amount) > Number(current?.amount ?? min?.amount)
|
|
52
|
+
? original.formattedAmount
|
|
53
|
+
: null;
|
|
52
54
|
|
|
53
55
|
// Options preview for the tile summary row.
|
|
54
56
|
// Colour options → real hex dots (more informative than "3 colours").
|
|
@@ -68,6 +70,8 @@ export function useProductCard(product) {
|
|
|
68
70
|
// Quick-add is only safe for single-variant products (no option choices to resolve).
|
|
69
71
|
// Sold-out with pre-order still shows a CTA, but it links to the PDP, not quick-add.
|
|
70
72
|
const isQuickAddable = !hasOptions && !isSoldOut;
|
|
73
|
+
// The variant a direct add sends (a product with no options still has one variant).
|
|
74
|
+
const directAddVariantId = isQuickAddable ? minVariant?.id ?? null : null;
|
|
71
75
|
|
|
72
76
|
// Images: normalised through lib/storeImage so URLs are consistent across the tile,
|
|
73
77
|
// the PDP gallery, and the cart. Hover image is the second gallery shot (if one exists).
|
|
@@ -78,15 +82,17 @@ export function useProductCard(product) {
|
|
|
78
82
|
isSoldOut,
|
|
79
83
|
isPreorder,
|
|
80
84
|
isPartiallyOutOfStock,
|
|
81
|
-
leftBadges,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
leftBadges, // [{ type: 'pre-order'|'sold-out'|'limited-stock', label }]
|
|
86
|
+
ribbons, // every merchant ribbon label, primary first — render all of them
|
|
87
|
+
promoBadge, // { type: 'ribbon', label } | null — the primary ribbon, for a single-badge slot
|
|
88
|
+
priceDisplay, // formatted price the buyer pays, or a min–max range
|
|
89
|
+
compareAtDisplay, // formatted struck "was" price | null (never beside a range)
|
|
90
|
+
colors, // hex strings — render as dots; the tile shows up to however many you want
|
|
91
|
+
optionLabel, // "3 sizes · 2 materials" or empty string
|
|
87
92
|
isQuickAddable,
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
directAddVariantId, // pass as variantId to addToCart on quick-add
|
|
94
|
+
image, // primary image URL | null
|
|
95
|
+
hoverImage, // second image URL for hover effect | null
|
|
90
96
|
};
|
|
91
97
|
}, [product]);
|
|
92
98
|
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// useProductDetail — all PDP logic, no markup: load product by slug,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// renders what this returns.
|
|
1
|
+
// useProductDetail — all PDP logic, no markup: load product by slug, resolve the buyer's selections
|
|
2
|
+
// to a Wix variant, gate add-to-cart on availability + mandatory modifiers, and build the addToCart
|
|
3
|
+
// payload. The data paths here (variant resolution, price precedence, modifier keys, stock gating)
|
|
4
|
+
// are the bug-prone part — keep them verbatim; the PDP page only renders what this returns.
|
|
6
5
|
import { useState, useEffect, useMemo } from "react";
|
|
7
|
-
import { getProductBySlug } from "@/rest/wix-store-catalog";
|
|
6
|
+
import { getProductBySlug, sellingPrice } from "@/rest/wix-store-catalog";
|
|
8
7
|
import { choiceImage, variantImage } from "@/lib/storeImage";
|
|
9
8
|
import { useCart } from "@/context/CartContext";
|
|
10
9
|
|
|
10
|
+
// A variant is identified by the SET of its choice ids — sorted, so option order can't matter.
|
|
11
|
+
const choiceKey = (choiceIds) => [...choiceIds].sort().join("_");
|
|
12
|
+
|
|
11
13
|
export function useProductDetail(slug) {
|
|
12
14
|
const { addToCart } = useCart();
|
|
13
15
|
const [product, setProduct] = useState(null);
|
|
@@ -16,6 +18,8 @@ export function useProductDetail(slug) {
|
|
|
16
18
|
// link, the other is worth retrying. Without the split, a network blip reads as "product deleted".
|
|
17
19
|
const [error, setError] = useState(null);
|
|
18
20
|
const [adding, setAdding] = useState(false);
|
|
21
|
+
// Selections start EMPTY. Pre-picking the first choice lets a buyer who never noticed the default
|
|
22
|
+
// buy the wrong colour; the PDP shows a neutral "choose a size" until every option is picked.
|
|
19
23
|
const [selectedOptions, setSelectedOptions] = useState({});
|
|
20
24
|
const [modifierValues, setModifierValues] = useState({});
|
|
21
25
|
const [quantity, setQuantity] = useState(1);
|
|
@@ -26,17 +30,13 @@ export function useProductDetail(slug) {
|
|
|
26
30
|
setProduct(null);
|
|
27
31
|
setNotFound(false);
|
|
28
32
|
setError(null);
|
|
33
|
+
setSelectedOptions({});
|
|
34
|
+
setQuantity(1);
|
|
29
35
|
getProductBySlug(slug)
|
|
30
36
|
.then((p) => {
|
|
31
37
|
if (cancelled) return;
|
|
32
38
|
if (!p) return setNotFound(true);
|
|
33
39
|
setProduct(p);
|
|
34
|
-
const initial = {};
|
|
35
|
-
(p.options || []).forEach((o) => {
|
|
36
|
-
const first = o.choicesSettings?.choices?.find((c) => c.inStock !== false);
|
|
37
|
-
if (first) initial[o.id] = first.choiceId;
|
|
38
|
-
});
|
|
39
|
-
setSelectedOptions(initial);
|
|
40
40
|
})
|
|
41
41
|
.catch((e) => { if (!cancelled) setError(e?.message || "Couldn't load this product."); });
|
|
42
42
|
return () => { cancelled = true; };
|
|
@@ -44,29 +44,70 @@ export function useProductDetail(slug) {
|
|
|
44
44
|
|
|
45
45
|
const options = product?.options || [];
|
|
46
46
|
const modifiers = product?.modifiers || [];
|
|
47
|
-
const variants = product?.variantsInfo?.variants || [];
|
|
48
47
|
|
|
48
|
+
// Visible variants keyed by their sorted choice ids. Two variants with the same choices is a
|
|
49
|
+
// catalog defect — report it instead of silently letting one overwrite the other.
|
|
50
|
+
const { variantByKey, duplicateKey } = useMemo(() => {
|
|
51
|
+
const map = new Map();
|
|
52
|
+
let dup = null;
|
|
53
|
+
for (const v of product?.variantsInfo?.variants || []) {
|
|
54
|
+
if (v.visible === false) continue;
|
|
55
|
+
const k = choiceKey((v.choices || []).map((c) => c.optionChoiceIds?.choiceId));
|
|
56
|
+
if (map.has(k) && !dup) dup = k;
|
|
57
|
+
map.set(k, v);
|
|
58
|
+
}
|
|
59
|
+
return { variantByKey: map, duplicateKey: dup };
|
|
60
|
+
}, [product]);
|
|
61
|
+
|
|
62
|
+
const selectionComplete = options.length > 0 && options.every((o) => selectedOptions[o.id]);
|
|
49
63
|
const variant = useMemo(() => {
|
|
50
|
-
if (options.length === 0) return
|
|
51
|
-
if (!
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
}, [options, variants, selectedOptions]);
|
|
64
|
+
if (options.length === 0) return variantByKey.values().next().value || null; // the single variant
|
|
65
|
+
if (!selectionComplete || duplicateKey) return null;
|
|
66
|
+
return variantByKey.get(choiceKey(options.map((o) => selectedOptions[o.id]))) || null;
|
|
67
|
+
}, [options, variantByKey, duplicateKey, selectedOptions, selectionComplete]);
|
|
55
68
|
|
|
69
|
+
// In stock OR pre-orderable counts as buyable; a preorder add carries preOrderRequested.
|
|
56
70
|
const inStock = variant ? variant.inventoryStatus?.inStock !== false : true;
|
|
71
|
+
const isPreorder = !!variant && !inStock && variant.inventoryStatus?.preorderEnabled === true;
|
|
72
|
+
const available = inStock || isPreorder;
|
|
73
|
+
|
|
74
|
+
const missingModifier = modifiers.find((m) => m.mandatory && !(
|
|
75
|
+
m.modifierRenderType === "FREE_TEXT" ? modifierValues[m.freeTextSettings?.key] : modifierValues[m.key]));
|
|
76
|
+
|
|
57
77
|
const canAdd = useMemo(() => {
|
|
78
|
+
if (!product) return false;
|
|
58
79
|
if (options.length > 0 && !variant) return false;
|
|
59
|
-
if (variant && !
|
|
60
|
-
return
|
|
61
|
-
|
|
62
|
-
|
|
80
|
+
if (variant && !available) return false;
|
|
81
|
+
return !missingModifier;
|
|
82
|
+
}, [product, options, variant, available, missingModifier]);
|
|
83
|
+
|
|
84
|
+
// Why the buy button is disabled — neutral guidance to render next to the action ("Choose a
|
|
85
|
+
// size"), not an error; null when the buyer can add. Promote to error styling only after they try.
|
|
86
|
+
const blockedReason = useMemo(() => {
|
|
87
|
+
if (!product) return null;
|
|
88
|
+
if (duplicateKey) return "This product's variants can't be resolved right now.";
|
|
89
|
+
if (options.length > 0 && !selectionComplete) {
|
|
90
|
+
const missing = options.filter((o) => !selectedOptions[o.id]).map((o) => o.name);
|
|
91
|
+
return `Choose ${missing.join(" and ")}`;
|
|
92
|
+
}
|
|
93
|
+
if (options.length > 0 && !variant) return "This combination isn't available";
|
|
94
|
+
if (variant && !available) return "Out of stock";
|
|
95
|
+
if (missingModifier) return `Add ${missingModifier.name}`;
|
|
96
|
+
return null;
|
|
97
|
+
}, [product, duplicateKey, options, selectionComplete, selectedOptions, variant, available, missingModifier]);
|
|
63
98
|
|
|
64
|
-
// Everything the buyer sees follows the resolved `variant` (also returned below
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
const
|
|
69
|
-
const
|
|
99
|
+
// Everything the buyer sees follows the resolved `variant` (also returned below). The price obeys
|
|
100
|
+
// the exact precedence in sellingPrice(): an automatic discount beats the regular price and strikes
|
|
101
|
+
// it; else the regular price with the merchant's compare-at as the "was". Before every option is
|
|
102
|
+
// picked, the product's min–max range (one value when equal) — never an empty price.
|
|
103
|
+
const min = product?.actualPriceRange?.minValue, max = product?.actualPriceRange?.maxValue;
|
|
104
|
+
const rangeDisplay = min?.amount && max?.amount && min.amount !== max.amount
|
|
105
|
+
? `${min.formattedAmount} – ${max.formattedAmount}` : (min?.formattedAmount || "");
|
|
106
|
+
const resolved = variant ? sellingPrice(variant.price) : null;
|
|
107
|
+
const price = resolved?.current?.formattedAmount || rangeDisplay;
|
|
108
|
+
const compareAtPrice = resolved && resolved.original?.formattedAmount &&
|
|
109
|
+
Number(resolved.original.amount) > Number(resolved.current?.amount) ? resolved.original.formattedAmount : "";
|
|
110
|
+
const discountNames = product?.discountInfo?.discountRuleNames || []; // automatic discounts, product level
|
|
70
111
|
|
|
71
112
|
// The gallery follows the selection. Prefer the selected choice's own image (the per-swatch photo,
|
|
72
113
|
// choice.media.items[].mediaId) — Wix's per-variant media is often NOT differentiated (it defaults to
|
|
@@ -82,7 +123,11 @@ export function useProductDetail(slug) {
|
|
|
82
123
|
return variantImage(variant);
|
|
83
124
|
}, [variant, options, selectedOptions]);
|
|
84
125
|
|
|
85
|
-
|
|
126
|
+
// Changing an option resets the quantity — the ceiling belongs to the newly resolved variant.
|
|
127
|
+
const selectOption = (optionId, choiceId) => {
|
|
128
|
+
setSelectedOptions((s) => ({ ...s, [optionId]: choiceId }));
|
|
129
|
+
setQuantity(1);
|
|
130
|
+
};
|
|
86
131
|
const setModifier = (key, value) => setModifierValues((s) => ({ ...s, [key]: value }));
|
|
87
132
|
|
|
88
133
|
async function submit() {
|
|
@@ -101,6 +146,7 @@ export function useProductDetail(slug) {
|
|
|
101
146
|
return await addToCart(product.id, variant?.id, Math.max(1, Number(quantity) || 1), {
|
|
102
147
|
modifierChoices: Object.keys(modifierChoices).length ? modifierChoices : undefined,
|
|
103
148
|
customTextFields: Object.keys(customTextFields).length ? customTextFields : undefined,
|
|
149
|
+
preorder: isPreorder,
|
|
104
150
|
});
|
|
105
151
|
} finally {
|
|
106
152
|
setAdding(false);
|
|
@@ -111,6 +157,7 @@ export function useProductDetail(slug) {
|
|
|
111
157
|
product, notFound, error, retry: () => setReloadKey((k) => k + 1),
|
|
112
158
|
options, modifiers,
|
|
113
159
|
selectedOptions, selectOption, modifierValues, setModifier,
|
|
114
|
-
quantity, setQuantity, variant, inStock,
|
|
160
|
+
quantity, setQuantity, variant, inStock, isPreorder, canAdd, blockedReason, adding,
|
|
161
|
+
price, compareAtPrice, discountNames, submit, focusMediaUrl,
|
|
115
162
|
};
|
|
116
163
|
}
|