@wix/agent-skills 1.19.1 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +1 -1
  3. package/skills/wix-app/references/SERVICE_PLUGIN.md +14 -1
  4. package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +7 -2
  5. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +53 -28
  6. package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +17 -4
  7. package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
  8. package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +3 -4
  9. package/skills/wix-app/references/service-plugin/ADDITIONAL-FEES.md +4 -0
  10. package/skills/wix-app/references/service-plugin/AVAILABILITY-TIME-SLOTS-CONFIGURATION.md +118 -0
  11. package/skills/wix-app/references/service-plugin/BOOKING-POLICY.md +65 -0
  12. package/skills/wix-app/references/service-plugin/BOOKINGS-PRICING.md +71 -0
  13. package/skills/wix-app/references/service-plugin/BOOKINGS-STAFF-SORTING.md +11 -3
  14. package/skills/wix-app/references/service-plugin/BOOKINGS-VALIDATION.md +137 -0
  15. package/skills/wix-app/references/service-plugin/DISCOUNT-TRIGGERS.md +23 -16
  16. package/skills/wix-app/references/service-plugin/GIFT-CARDS.md +4 -0
  17. package/skills/wix-app/references/service-plugin/PAYMENT-SETTINGS.md +4 -2
  18. package/skills/wix-app/references/service-plugin/REALTIME-PERMISSIONS.md +45 -0
  19. package/skills/wix-app/references/service-plugin/SHIPPING-RATES.md +15 -0
  20. package/skills/wix-app/references/service-plugin/TOOLS_PROVIDER.md +4 -0
  21. package/skills/wix-app/references/service-plugin/VALIDATIONS.md +4 -0
  22. package/skills/wix-base44-connector/SKILL.md +46 -24
  23. package/skills/wix-headless/SKILL.md +1 -0
  24. package/skills/wix-headless/references/CAPABILITIES.md +7 -2
  25. package/skills/wix-headless/references/SDK_HANDOFF.md +2 -1
  26. package/skills/wix-headless/references/inline-recipes/experience-store.md +49 -0
  27. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +431 -115
  28. package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
  29. package/skills/wix-headless/references/managed/CREATE.md +5 -0
  30. package/skills/wix-headless/references/managed/DEPLOYMENT.md +8 -8
  31. package/skills/wix-headless-fast/install/fast-path.mjs +3 -1
  32. package/skills/wix-headless-fast/references/shared/app/wix/media.ts +13 -0
  33. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +227 -56
  34. package/skills/wix-headless-fast/references/storefront/app/components/storefront/CartDrawer.tsx +37 -3
  35. package/skills/wix-headless-fast/references/storefront/app/components/storefront/FilterPanel.tsx +266 -0
  36. package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +165 -0
  37. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +48 -15
  38. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +129 -11
  39. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +36 -6
  40. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +190 -24
  41. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +62 -4
  42. package/skills/wix-headless-fast/references/storefront/app-astro/layouts/SiteLayout.astro +21 -2
  43. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +5 -1
  44. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +59 -6
  45. package/skills/wix-manage/SKILL.md +8 -5
  46. package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +4 -1
  47. package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -1
  48. package/skills/wix-manage/references/bookings/create-class-service.md +3 -1
  49. package/skills/wix-manage/references/bookings/create-course-service.md +4 -2
  50. package/skills/wix-manage/references/bookings/end-to-end-booking-flow.md +68 -3
  51. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-seasonal-promotion.md +1 -1
  52. package/skills/wix-manage/references/events/create-wix-event.md +46 -0
  53. package/skills/wix-manage/references/events/manage-wix-events.md +51 -1
  54. package/skills/wix-manage/references/google-ads/manage-campaign-lifecycle.md +6 -6
  55. package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
  56. package/skills/wix-manage/references/seo/manage-content-plan.md +1 -1
  57. package/skills/wix-manage/references/seo/manage-seo-suggestions.md +351 -0
  58. package/skills/wix-manage/references/sites/upload-static-site.md +264 -34
  59. package/skills/wix-vibe-headless/install/deploy.cjs +33 -2
  60. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +1 -1
  61. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +13 -4
  62. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
  63. package/skills/wix-vibe-headless/platforms/base44.md +19 -4
  64. package/skills/wix-vibe-headless/platforms/generic.md +6 -0
  65. package/skills/wix-vibe-headless/references/_shared/nav/nav.tanstack.js +30 -0
  66. package/skills/wix-vibe-headless/references/_shared/routing.md +86 -0
  67. package/skills/wix-vibe-headless/references/blog/INSTRUCTIONS.md +44 -0
  68. package/skills/wix-vibe-headless/references/blog/app/components/PostCard.jsx +1 -1
  69. package/skills/wix-vibe-headless/references/blog/app/components/PostChips.jsx +1 -1
  70. package/skills/wix-vibe-headless/references/blog/app/pages/CategoryPage.jsx +1 -1
  71. package/skills/wix-vibe-headless/references/blog/app/pages/PostDetail.jsx +1 -1
  72. package/skills/wix-vibe-headless/references/blog/app/pages/TagPage.jsx +1 -1
  73. package/skills/wix-vibe-headless/references/bookings/INSTRUCTIONS.md +42 -0
  74. package/skills/wix-vibe-headless/references/bookings/app/components/ServiceCard.jsx +1 -1
  75. package/skills/wix-vibe-headless/references/bookings/app/pages/ServiceDetail.jsx +1 -1
  76. package/skills/wix-vibe-headless/references/events/INSTRUCTIONS.md +42 -0
  77. package/skills/wix-vibe-headless/references/events/app/components/EventCard.jsx +1 -1
  78. package/skills/wix-vibe-headless/references/events/app/pages/EventDetail.jsx +1 -1
  79. package/skills/wix-vibe-headless/references/members/INSTRUCTIONS.md +42 -0
  80. package/skills/wix-vibe-headless/references/members/app/components/MemberMenu.jsx +1 -1
  81. package/skills/wix-vibe-headless/references/members/app/components/RequireAuth.jsx +1 -1
  82. package/skills/wix-vibe-headless/references/members/app/pages/Login.jsx +1 -1
  83. package/skills/wix-vibe-headless/references/portfolio/INSTRUCTIONS.md +43 -0
  84. package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionCard.jsx +1 -1
  85. package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectCard.jsx +1 -1
  86. package/skills/wix-vibe-headless/references/portfolio/app/pages/CollectionPage.jsx +1 -1
  87. package/skills/wix-vibe-headless/references/portfolio/app/pages/ProjectDetail.jsx +1 -1
  88. package/skills/wix-vibe-headless/references/pricing-plans/INSTRUCTIONS.md +43 -0
  89. package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanCard.jsx +1 -1
  90. package/skills/wix-vibe-headless/references/pricing-plans/app/pages/PlanDetail.jsx +1 -1
  91. package/skills/wix-vibe-headless/references/restaurants/INSTRUCTIONS.md +36 -0
  92. package/skills/wix-vibe-headless/references/shared/app/lib/nav.js +9 -0
  93. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +110 -27
  94. package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +32 -14
  95. package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +14 -3
  96. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +47 -35
  97. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +77 -30
  98. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-cart.js +31 -2
  99. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-catalog.js +31 -6
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "How to Code Members (Astro)"
3
- description: The frontend contract for member sign-up / log-in / log-out and member-gated surfaces on a Wix-managed **Astro** frontend — the built-in `/api/auth/login` + `/api/auth/logout` routes that `@wix/astro` ships, the `returnToUrl` param, gating a page by resolving the member in SSR / a backend route, reading the current member with `@wix/members` `getCurrentMember`, and the two rules that keep this path from breaking: **no `OAuthStrategy`/no client** (that 500s under auto-auth) and **no `auth.elevate`** for a member reading their own data. Specifies the *how* for the Astro axis only — read `how-to-code-members-non-astro.md` for any non-Astro frontend.
3
+ description: "The frontend contract for member sign-up / log-in / log-out and member-gated surfaces on a Wix-managed **Astro** frontend — the built-in `/api/auth/login` + `/api/auth/logout` routes that `@wix/astro` ships, the `returnToUrl` param, gating a page by resolving the member in SSR / a backend route, reading the current member with `@wix/members` `getCurrentMember`, and the two rules that keep this path from breaking: **no `OAuthStrategy`/no client** (that 500s under auto-auth) and **no `auth.elevate`** for a member reading their own data. Specifies the *how* for the Astro axis only — read `how-to-code-members-non-astro.md` for any non-Astro frontend."
4
4
  ---
5
5
  **RECIPE**: How to Code Member Auth on a Wix-managed **Astro** Frontend (built-in `/api/auth/*`, `@wix/members`)
6
6
 
@@ -107,6 +107,11 @@ headlines, kickers, subtitles, and CTA labels per **`references/CONTENT.md`**
107
107
  Either way, `CONTENT.md` §1 (the business's category must read literally above the fold) always holds —
108
108
  a page a first-time visitor can't identify isn't done yet, regardless of voice.
109
109
 
110
+ **If a loaded vertical ships an experience overlay** — `references/inline-recipes/experience-<vertical>.md`
111
+ (today: `experience-store.md`) — read it after `DESIGN.md`/`CONTENT.md`. It carries that vertical's own
112
+ design and copy bar (what its surfaces must show, how to derive the direction from its content); the
113
+ general files stay the floor.
114
+
110
115
  Then build the pages the user's intent calls for, **wired to the live backend**, using
111
116
  **`references/SDK_HANDOFF.md`** for the per-capability packages, the SDK docs, and the seeded schema to
112
117
  bind (collection/form names + field keys; all other content is queried live). Install the SDK packages the loaded verticals need, author the pages/components directly in the
@@ -28,30 +28,30 @@ Live site: <published-url>
28
28
  Dashboard: https://manage.wix.com/dashboard/<SITE_ID>
29
29
  ```
30
30
 
31
- ## Suggest a custom domain (proactive, after release)
31
+ ## Suggest a custom domain (required, after release)
32
32
 
33
- After delivering the links above, **proactively offer a custom domain** — don't wait for the user to ask. The published URL is a generic Wix subdomain; a custom domain is the obvious next step, and the CLI handles the whole flow.
33
+ The published URL is a generic Wix subdomain, so a custom domain is the obvious next step and the CLI handles the whole flow. **Run step 1 on every managed release and put its result in the same message as the links above** — this is a step you execute, not a judgment call about whether the user seems interested. It applies in a non-interactive run too: there's no one to ask, so just include the suggestions and a checkout link in your final message and let the user act on them.
34
34
 
35
- 1. **Suggest options.** Build a search query from what you know about the site — its business name, topic, or purpose from this run (not a generic placeholder):
35
+ 1. **Run the suggestion command.** Build a search query from what you know about the site — its business name, topic, or purpose from this run (not a generic placeholder):
36
36
 
37
37
  ```bash
38
38
  npx @wix/cli@latest account domain suggest "<business name or site topic>" --limit 5 --json
39
39
  ```
40
40
 
41
- All returned suggestions are already available for purchase. Show the user a few of the best fits, and mention they can explore more options — re-run `suggest` with different keywords, a higher `--limit`, or `--tld com net ...` to filter extensions.
41
+ All returned suggestions are already available for purchase. Name a few of the best fits in your message, and mention they can explore more options — re-run `suggest` with different keywords, a higher `--limit`, or `--tld com net ...` to filter extensions.
42
42
 
43
- 2. **Offer to buy, with a direct purchase link.** For the domain(s) the user likes, generate a site-scoped deep link so the purchase is tied to this site and can be connected after payment:
43
+ 2. **Give a checkout link.** Generate a site-scoped deep link for the domain the user picks — or, if nobody is there to pick, for the best of the suggestions — so the purchase is tied to this site and can be connected after payment:
44
44
 
45
45
  ```bash
46
- npx @wix/cli@latest account --site-id <SITE_ID> domain buy <domain> --json
46
+ npx @wix/cli@latest account --site-id <SITE_ID> domain checkout-link <domain> --json
47
47
  ```
48
48
 
49
- Use the `siteId` from `wix.config.json`. `buy` does **not** charge anything — it returns a checkout URL (`https://manage.wix.com/get-domain?...`); the user completes payment in the browser.
49
+ Use the `siteId` from `wix.config.json`. `checkout-link` only returns a site-scoped checkout URL; it does **not** purchase a domain or charge the user. The user completes payment in the browser.
50
50
 
51
51
  > Your site is live at <published-url>, and you can manage it from your [dashboard](https://manage.wix.com/dashboard/<SITE_ID>).
52
52
  > To build a professional online presence, [connect a custom domain](<checkout-url>) — **mybusiness.com** is available.
53
53
 
54
- If the user isn't interested, drop it — one offer, no upselling.
54
+ Once the suggestions and the link are in front of the user, you're done: if they don't want a domain, drop it — one offer, no upselling.
55
55
 
56
56
  ## Before you sign off — feedback checkpoint
57
57
 
@@ -165,7 +165,9 @@ const seedDoneMarker = join(projectDir, ".seed-exit");
165
165
  const planAbs = resolve(planPath);
166
166
  const seedChild = spawn(
167
167
  "sh",
168
- ["-c", `node "${seedFile}" "${planAbs}" > seed-result.json 2> seed.log; echo $? > .seed-exit`],
168
+ // --fresh-site: this script created the site moments ago, so the only products in it are the
169
+ // Stores install's samples — the seed clears them first (a seed run by hand never deletes).
170
+ ["-c", `node "${seedFile}" "${planAbs}" --fresh-site > seed-result.json 2> seed.log; echo $? > .seed-exit`],
169
171
  { cwd: projectDir, detached: true, stdio: "ignore" },
170
172
  );
171
173
  seedChild.unref();
@@ -25,3 +25,16 @@ export function imgSrc(value: MediaLike, width = 600, height = 600): string {
25
25
  }
26
26
  return typeof v === "string" ? v : "";
27
27
  }
28
+
29
+ /**
30
+ * Responsive candidates for `srcset`: the same image at several widths through Wix's scaler, so a
31
+ * card never downloads a hero-sized file. Pair with `sizes`:
32
+ * <img src={imgSrc(m, 640, 640)} srcSet={imgSrcSet(m)} sizes="(min-width: 1024px) 25vw, 50vw"
33
+ * width={640} height={640} loading="lazy" alt={…} />
34
+ * `ratio` is height/width (1 = square). Returns "" for an absolute URL that isn't Wix media.
35
+ */
36
+ export function imgSrcSet(value: MediaLike, widths: number[] = [320, 480, 640, 960], ratio = 1): string {
37
+ const raw = typeof value === "object" && value !== null ? (value.image ?? value.url ?? "") : (value ?? "");
38
+ if (typeof raw !== "string" || !raw.startsWith("wix:image://")) return "";
39
+ return widths.map((w) => `${imgSrc(value, w, Math.round(w * ratio))} ${w}w`).join(", ");
40
+ }
@@ -2,8 +2,8 @@
2
2
 
3
3
  The commerce machinery ships as files — data layer, hooks, cart, checkout, SEO plumbing,
4
4
  typed end-to-end. **The presentation doesn't ship — you build it** on the shipped hooks/DTOs:
5
- the shop and product (PDP) pages with their islands, the home page, and the brand. The
6
- skeletons below carry each page's contract — including the SSR and SEO machinery, which must
5
+ the shop, category, and product (PDP) pages with their islands, the home page, and the brand.
6
+ The skeletons below carry each page's contract — including the SSR and SEO machinery, which must
7
7
  be exact. You never write commerce code; you never skip designing the store.
8
8
 
9
9
  ## The file map (deployed into `src/`)
@@ -11,21 +11,23 @@ be exact. You never write commerce code; you never skip designing the store.
11
11
  **Don't read the shipped files** — this table and the contracts below are everything you
12
12
  need. Open a shipped file's source **only** on a real fallback: a runtime error, or a field
13
13
  this playbook doesn't cover. Files you edit: `SiteLayout.astro` and `styles/global.css`.
14
- Files you **create** (skeletons below): the shop and PDP pages with their island components,
15
- plus your home page.
14
+ Files you **create** (skeletons below): the shop, category, and PDP pages with their island
15
+ components, plus your home page.
16
16
 
17
17
  | file | what it is |
18
18
  |---|---|
19
19
  | `wix/config.ts` · `wix/sdk.ts` | shared auth seam (deploy configures it — nothing to set by hand) |
20
- | `wix/media.ts` · `wix/money.ts` | `imgSrc()` / `formatMoney()` — already used by everything shipped |
21
- | `wix/storefront/types.ts` | the DTOs (`ProductSummary`, `ProductDetail`, `Cart`, `Category`) — contracts inlined below |
22
- | `wix/storefront/catalog.ts` | `searchCatalog` (sort/filter/search + cursor paging, all server-side), `fetchProducts`, `fetchProductsByCategory`, `fetchProductBySlug`, `fetchCategories`, `resolveVariant` |
20
+ | `wix/media.ts` · `wix/money.ts` | `imgSrc()` / `imgSrcSet()` / `formatMoney()` — already used by everything shipped; `imgSrcSet` + `sizes` for responsive tiles |
21
+ | `wix/storefront/types.ts` | the DTOs (`ProductSummary`, `ProductDetail`, `Cart`, `Category`, `Facet`) — contracts inlined below |
22
+ | `wix/storefront/catalog.ts` | `searchCatalog` (sort/filter/facets/search + cursor paging + result count, all server-side), `fetchFacets`, `fetchProducts`, `fetchProductsByCategory`, `fetchProductBySlug`, `fetchCategories`, `fetchCategoryBySlug`, `resolveVariant` |
23
23
  | `wix/storefront/cart.ts` · `cart-store.ts` | Cart V2 + shared cart state (module store — spans Astro islands) |
24
24
  | `hooks/storefront/useCart.ts` | cart state + actions — contract below |
25
- | `hooks/storefront/useShop.ts` | listing + live category filter — contract below |
25
+ | `hooks/storefront/useShop.ts` | listing: category scope, sort, filters, option facets, result count, paging — contract below |
26
26
  | `hooks/storefront/useProductDetail.ts` | option selection → variant resolution → add-to-cart — contract below |
27
27
  | `components/storefront/CartButton.tsx` · `CartDrawer.tsx` | header badge + slide-over cart — **wire as-is** (drawer once per page) |
28
- | `components/storefront/ShopView.tsx` · `ProductDetailView.tsx` | **don't ship — YOU create them** (skeletons below): the client islands your shop and PDP pages mount |
28
+ | `components/storefront/FilterPanel.tsx` | the gallery's filter LAYOUT — toolbar (result count, sort), active chips, then a 16rem sidebar of collapsible groups (price as a two-handle slider bounded by the catalog's real prices, availability, one group per option facet with swatches/pills) beside YOUR results; a bottom sheet under `md` — **wire as-is** in your `ShopView`, your grid as its children: `<FilterPanel shop={shop}>…grid…</FilterPanel>` |
29
+ | `components/storefront/QuickAdd.tsx` | the tile's purchase control — one click for a product with no options, a picker anchored to the tile (bottom sheet on small screens) for one with options, the product page for free-text customization — **wire as-is** as the last row of every tile's text block (`<QuickAdd product={p} />`) |
30
+ | `components/storefront/ShopView.tsx` · `ProductDetailView.tsx` | **don't ship — YOU create them** (skeletons below): the client islands your shop, category, and PDP pages mount |
29
31
  | `styles/global.css` | **the design system**: Tailwind v4 + the `@theme` token block (colors, radii, fonts — same token family as the official Wix templates). Everything, shipped and yours, styles from these tokens |
30
32
 
31
33
  Astro stack additionally gets:
@@ -33,7 +35,7 @@ Astro stack additionally gets:
33
35
  | file | what it is |
34
36
  |---|---|
35
37
  | `layouts/SiteLayout.astro` | the site chrome — **yours to brand**: header, footer, nav. Keep the `<slot name="seo-tags" />`, the global.css import, and the CartButton/CartDrawer mounts |
36
- | `pages/shop.astro` · `pages/products/[slug].astro` | **don't ship — YOU create them** (skeletons below): thin SSR pages that fetch server-side and mount your islands; the PDP page carries the owner-editable SEO machinery, exactly as its skeleton shows |
38
+ | `pages/shop.astro` · `pages/category/[slug].astro` · `pages/products/[slug].astro` | **don't ship — YOU create them** (skeletons below): thin SSR pages that fetch server-side and mount your islands; the category and PDP pages carry the owner-editable SEO machinery, exactly as their skeletons show |
37
39
 
38
40
  ## What you build — this is the design job, not optional polish
39
41
 
@@ -42,11 +44,13 @@ skeletons below — styled with Tailwind utilities on the `@theme` tokens, desig
42
44
  brief (the business, the tone, the audience — a toy brand and a jewelry house should not get
43
45
  the same store):
44
46
 
45
- 1. **The shop page + `ShopView`** (skeletons below) — category filter + your grid of your
46
- tiles: image treatment, badges, price/sale presentation, hover behavior, grid rhythm
47
- (columns, density, maybe an editorial featured tile). `products === null` → skeleton tiles,
47
+ 1. **The shop page + `ShopView`** (skeletons below) — category links, the shipped
48
+ `FilterPanel`, and your grid of your tiles: image treatment, badges, price/sale
49
+ presentation, hover behavior, grid rhythm (columns, density, maybe an editorial featured
50
+ tile), each tile carrying the shipped `QuickAdd`. `products === null` → skeleton tiles,
48
51
  `[]` → an honest empty state. Decompose into `ProductCard`/`ProductGrid` files if you like —
49
- your call, nothing prescribes it.
52
+ your call, nothing prescribes it. **The category page** (`/category/[slug]`) mounts the same
53
+ `ShopView` scoped to one category — one island, two pages.
50
54
  2. **The PDP page + `ProductDetailView`** (skeletons below) — gallery, price/sale,
51
55
  description, your option-selection UI (color options = real swatches), quantity,
52
56
  add-to-cart — on `useProductDetail`, which owns ALL selection/variant logic; you own how it
@@ -58,22 +62,71 @@ Plus the **theme** (edit the `@theme` block in `styles/global.css` — one edit;
58
62
  flipped token values; add brand fonts as extra tokens) and the **chrome** (header/footer in
59
63
  `SiteLayout.astro`, one edit pass; mount the shipped `CartButton` in your header).
60
64
 
65
+ ### What a complete storefront shows (recommended defaults)
66
+
67
+ These are the recommended defaults for a store whose brief says nothing about them. They are
68
+ not requirements: when the user's prompt asks for something different — a cart page instead of a
69
+ drawer, no shop page, a particular layout or look — the prompt wins, and the item here that
70
+ conflicts with it is dropped without discussion. Look at the catalog before designing (categories,
71
+ assortment size, media, options, sales, ribbons) and design for this store, not for a stereotype
72
+ of its category. Then, by default:
73
+
74
+ - **Home:** what the store sells and one shopping action in the first screen; real products under
75
+ truthful headings ("Best Sellers" needs data behind it); not a repeat of the shop page. A
76
+ category tile or link on the home page goes to that category's page (`/category/<slug>`) —
77
+ never to an anchor on the shop page that every tile shares.
78
+ - **Shop:** a real product card — image, name, price, link — in the first screen; the shipped
79
+ `FilterPanel` around the grid (toolbar, then a filter sidebar beside the results on desktop and
80
+ a sheet on phones) — a store with any filterable catalog ships it, not "when it fits"; the
81
+ shipped `QuickAdd` as the last row of every tile; each
82
+ category reachable by a real link (`/category/<slug>`) — from the nav, the home page, or the
83
+ shop's category row; loading, empty, no-results, and error states that look different.
84
+ - **Product page:** image, name, price, the first choice, and the buy button with `blockedReason`
85
+ in the first screen **at 390px wide too** — on a phone the image is a bounded band
86
+ (`max-h-[45vh]`), not a full-screen hero that pushes the price below the fold; every ribbon;
87
+ every image reachable in the gallery.
88
+ - **Cart:** the shipped drawer — it opens after every add, and checkout is a button in it.
89
+ - **Overlays you build** (quick-add, mobile nav, filters): mount at the document root (a fixed
90
+ panel inside the `backdrop-blur` header gets clipped), lock background scroll, close on Escape,
91
+ return focus on close — as the shipped `CartDrawer` does.
92
+ - **Copy:** nothing the merchant didn't supply — no invented reviews, scarcity, or delivery
93
+ promises; no Wix IDs or technical words in visible text.
94
+
95
+ Pre-order ships (`isPreorder`). Subscriptions, product groups, promotions, and notify-me are built
96
+ only when the catalog has them — never fabricated; `wix-docs` has their contracts.
97
+
61
98
  ### The contracts your components consume (everything you need — don't read the source)
62
99
 
63
100
  ```ts
64
101
  // ProductSummary (grid tiles) — all display-ready: prices formatted, images https URLs:
65
- // { id, slug, name, price, maxPrice, compareAtPrice|null, ribbon|null,
66
- // availability: "IN_STOCK"|"OUT_OF_STOCK"|"PARTIALLY_OUT_OF_STOCK", preorder: boolean,
67
- // imageUrl, hoverImageUrl, optionsSummary /* "2 colors · 3 sizes" */, quickAddable: boolean }
68
- // price vs maxPrice differ → show a range. quickAddable → useCart().addToCart(product.id).
69
-
70
- // useShop({ initialProducts?, initialCategories?, pageSize? /* 24 */ }) →
71
- // { products: ProductSummary[]|null /* null = loading → skeletons */, categories: Category[],
72
- // activeCategoryId: string|null, setActiveCategoryId(id|null),
102
+ // { id, slug, name, price, maxPrice, compareAtPrice|null, ribbon|null, ribbons: string[],
103
+ // minPriceVariantId|null, availability: "IN_STOCK"|"OUT_OF_STOCK"|"PARTIALLY_OUT_OF_STOCK",
104
+ // preorder: boolean, imageUrl, hoverImageUrl, optionsSummary /* "2 colors · 3 sizes" */,
105
+ // swatches: string[] /* hex colors of a color option's choices — dots on the tile, not a picker */,
106
+ // quickAddable: boolean }
107
+ // price !== maxPrice → the product is a RANGE: render "price – maxPrice" (compareAtPrice is null
108
+ // then — never a struck price beside a range). Otherwise price is what the buyer pays (a discount
109
+ // already applied) and compareAtPrice, when present, is the labelled "was".
110
+ // ribbons = EVERY merchant ribbon, primary first — render all, one shared style; a ribbon is a
111
+ // label, never proof of a discount. The tile's buy control is the shipped <QuickAdd product={p} />:
112
+ // it reads quickAddable / minPriceVariantId / preorder itself and routes to a direct add, an
113
+ // anchored option picker, or the product page — don't rebuild that decision in the tile.
114
+
115
+ // useShop({ initialProducts?, initialCategories?, initialCategoryId?, pageSize? /* 24 */ }) →
116
+ // { products: ProductSummary[]|null /* null = loading → skeletons */, total: number|null,
117
+ // categories: Category[], activeCategoryId: string|null, setActiveCategoryId(id|null),
73
118
  // sort: keyof SORTS, setSort(sort), filters, setFilters({ minPrice?, maxPrice?, inStockOnly?, search? }),
119
+ // facets: Facet[], selectedChoiceIds, toggleChoice(choiceId), clearFilters(), hasActiveFilters,
74
120
  // loading, error, retry(), hasMore, loadMore(), loadingMore }
75
- // Category = { id, slug, name }. Render the filter bar only when categories.length > 1.
76
- // Sort/filter/search/paging run on Wix across the WHOLE catalog (a change restarts the list).
121
+ // Category = { id, slug, name, description }. Facet = { name, isColor, choices: [{ id, name, colorCode|null }] }.
122
+ // The shipped <FilterPanel shop={shop}>{…your grid…}</FilterPanel> renders total/sort/chips, the
123
+ // filter sidebar (md+) or sheet, and lays your results out beside it — hand it the whole hook result
124
+ // and put your grid + states inside it; don't build a second filter UI or a second sort control. Categories are LINKS (`/category/${c.slug}`) — a category page is a URL
125
+ // a shopper can share and a search engine can index; setActiveCategoryId is for a live scope
126
+ // switch on /shop, not a substitute for the links.
127
+ // Sort/filter/facets/search/paging run on Wix across the WHOLE catalog (a change restarts the list).
128
+ // The selection is mirrored into the query string (?sort=&min=&max=&stock=1&q=&choice=…) and read
129
+ // back on load — a filtered gallery is a shareable link; nothing for you to wire.
77
130
  // SORTS (exported next to useShop) is Record<sortKey, { label: string }> — the value is an
78
131
  // OBJECT, so render entry.label, never the entry itself:
79
132
  // Object.entries(SORTS).map(([key, { label }]) => <option value={key}>{label}</option>)
@@ -84,14 +137,20 @@ flipped token values; add brand fonts as extra tokens) and the **chrome** (heade
84
137
  // optionGroups: [{ id, name, isColor, choices: [{ choiceId, name, colorCode|null, inStock, selected }] }],
85
138
  // selectOption(optionName, choiceName),
86
139
  // modifierValues, setModifier(key, value), // product.modifiers: pills or text input; "*" = mandatory
87
- // price, compareAtPrice, // live: variant price once resolved
88
- // canAdd, // gate the button; false until every option picked & in stock
89
- // quantity, setQuantity, add(), adding, error }
90
- // ProductDetail adds: descriptionHtml (render as HTML), gallery: string[] (urls, main first),
91
- // options, modifiers, variants — but selection ALWAYS goes through the hook above.
140
+ // price, compareAtPrice, // the RANGE until every option is picked, then the variant's price (+ labelled "was" when real)
141
+ // isPreorder, // resolved variant is out of stock but pre-orderable → label the action "Pre-order"
142
+ // canAdd, // gate the button; false until every option picked & in stock (or pre-orderable)
143
+ // blockedReason, // "Choose Size" / "Out of stock" / "Add Engraving" — render beside the button as
144
+ // // neutral guidance (not error styling) while it's disabled; null when addable
145
+ // quantity, setQuantity, add(), adding, error } // quantity resets to 1 when an option changes
146
+ // ProductDetail adds: descriptionHtml (render as HTML), infoSections: [{ title, html }] (sections
147
+ // or accordions), gallery: string[] (urls, main first), options, modifiers, variants — but
148
+ // selection ALWAYS goes through the hook above.
92
149
 
93
150
  // useCart() →
94
- // { cart: { lines, itemCount, subtotal, currency }|null, busy, error, open,
151
+ // { cart: { lines, itemCount, subtotal, discount /* "" when none */, currency }|null, busy, error, open,
152
+ // // a line: { lineItemId, productName, quantity, unitPrice, linePrice, imageUrl, descriptionLines,
153
+ // // status, subscription /* "Monthly plan · every month · 12 payments" or "" */ }
95
154
  // addToCart(productId, variantId?, qty?, extras?), updateQuantity(lineItemId, qty),
96
155
  // removeLine(lineItemId), checkout(), openCart(), closeCart(), refresh() }
97
156
  // addToCart rejects on refusal (out of stock, digital product with no file) AND records
@@ -135,6 +194,60 @@ try {
135
194
  </SiteLayout>
136
195
  ```
137
196
 
197
+ ```astro
198
+ ---
199
+ // src/pages/category/[slug].astro — YOU create it. A category is a Wix ITEM PAGE like a product:
200
+ // its own URL, its SEO owner-editable in the dashboard, registered in the sitemap. Same SEO
201
+ // machinery as the PDP with the CATEGORY identifiers. It mounts the SAME ShopView, scoped.
202
+ import SiteLayout from "../../layouts/SiteLayout.astro";
203
+ import ShopView from "../../components/storefront/ShopView";
204
+ import { fetchCategoryBySlug, fetchProductsByCategory, fetchCategories } from "../../wix/storefront/catalog";
205
+ import type { Category, ProductSummary } from "../../wix/storefront/types";
206
+ import { WIX_APPS } from "@wix/essentials";
207
+ import { SEO } from "@wix/seo/components";
208
+ import { loadSEOTagsServiceConfig } from "@wix/seo/services";
209
+ import { seoTags } from "@wix/seo";
210
+
211
+ export const wixMetadata = {
212
+ appDefId: WIX_APPS.checkoutAndOrders.id,
213
+ pageIdentifier: WIX_APPS.checkoutAndOrders.categoryPageMetadata.pageIdentifier,
214
+ identifiers: { slug: WIX_APPS.checkoutAndOrders.categoryPageMetadata.identifiers.handle },
215
+ };
216
+
217
+ const slug = Astro.params.slug!;
218
+ const forwardedUrl = Astro.request.headers.get("x-wix-forwarded-url");
219
+ const pageUrl =
220
+ forwardedUrl && URL.canParse(forwardedUrl) && /^https?:$/.test(new URL(forwardedUrl).protocol)
221
+ ? forwardedUrl
222
+ : Astro.url.href;
223
+
224
+ let category: Category | null = null;
225
+ let products: ProductSummary[] = [];
226
+ let categories: Category[] = [];
227
+ let seoTagsServiceConfig = null;
228
+ try {
229
+ [category, categories, seoTagsServiceConfig] = await Promise.all([
230
+ fetchCategoryBySlug(slug),
231
+ fetchCategories(),
232
+ loadSEOTagsServiceConfig({ pageUrl, itemType: seoTags.ItemType.STORES_CATEGORY, itemData: { slug } }),
233
+ ]);
234
+ if (category) products = await fetchProductsByCategory(category.id, { limit: 24 });
235
+ } catch {
236
+ // Guarded: an unhandled SSR throw truncates the response mid-stream.
237
+ }
238
+
239
+ // A missing or hidden category is a real 404 — never a fallback to all products.
240
+ if (!category) {
241
+ return new Response(null, { status: 404 });
242
+ }
243
+ ---
244
+ <SiteLayout title={category.name}>
245
+ <SEO.Tags seoTagsServiceConfig={seoTagsServiceConfig} slot="seo-tags" />
246
+ <!-- your heading: category.name, category.description when present, then: -->
247
+ <ShopView client:load initialProducts={products} initialCategories={categories} initialCategoryId={category.id} />
248
+ </SiteLayout>
249
+ ```
250
+
138
251
  ```astro
139
252
  ---
140
253
  // src/pages/products/[slug].astro — YOU create it. This is a Wix ITEM PAGE: the wixMetadata
@@ -191,26 +304,48 @@ if (!product) {
191
304
  ```
192
305
 
193
306
  ```tsx
194
- // src/components/storefront/ShopView.tsx — YOU build it; your shop.astro mounts it.
307
+ // src/components/storefront/ShopView.tsx — YOU build it; shop.astro AND category/[slug].astro mount it.
195
308
  import { useShop } from "../../hooks/storefront/useShop";
309
+ import FilterPanel from "./FilterPanel";
310
+ import QuickAdd from "./QuickAdd";
196
311
  import type { Category, ProductSummary } from "../../wix/storefront/types";
197
312
 
198
313
  export default function ShopView(props: {
199
- initialProducts?: ProductSummary[]; // SSR props from your shop.astro — pass straight
200
- initialCategories?: Category[]; // to useShop; omitted in a SPA (client fetch)
314
+ initialProducts?: ProductSummary[]; // SSR props from your page — pass straight to useShop;
315
+ initialCategories?: Category[]; // omitted in a SPA (client fetch)
316
+ initialCategoryId?: string | null; // the category page passes its category's id
201
317
  }) {
202
- const { products, categories, activeCategoryId, setActiveCategoryId, loading, error } =
203
- useShop(props);
318
+ const shop = useShop(props);
319
+ const { products, categories, activeCategoryId, loading, error } = shop;
204
320
  // …you implement the render:
205
- // • category pills when categories.length > 1 — All + one per category,
206
- // driving setActiveCategoryId(id | null)
207
- // • a sort control over SORTS driving setSort; filters (price bounds, in-stock,
208
- // name search) via setFilters — include the ones that fit the brief, not all of them
321
+ // • a category row when categories.length > 1: LINKS to `/category/${c.slug}` (plus "All" →
322
+ // /shop), the active one marked by activeCategoryId — real URLs, not only pills that
323
+ // swap state (setActiveCategoryId is fine for an additional live switch on /shop)
324
+ // • <FilterPanel shop={shop}> … </FilterPanel> WRAPS your results — shipped: the toolbar
325
+ // (result count, sort), active chips, a filter sidebar on md+ (price slider, availability,
326
+ // the option facets, collapsible) beside your grid, a sheet under md. Your loading / empty /
327
+ // error states and your grid go inside it as children. Always mounted; it shows only the
328
+ // facets this catalog actually has. No sort control or filter UI of your own.
209
329
  // • error → a short inline message (retry() re-runs the query)
210
- // • products === null (or loading) → skeleton tiles; [] → your honest empty state
211
- // • else YOUR grid of YOUR tiles (ProductSummary contract above): image
212
- // (hoverImageUrl on hover), name, price/compareAtPrice, ribbon, optionsSummary;
213
- // tile links to `/products/${p.slug}`; quickAddable → useCart().addToCart(p.id)
330
+ // • products === null (or loading) → skeleton tiles; [] → your honest empty state, and a
331
+ // distinct "no products match these filters" with clearFilters() when hasActiveFilters
332
+ // • else YOUR grid of YOUR tiles (ProductSummary contract above): image (hoverImageUrl on
333
+ // hover; imgSrcSet + sizes for responsive delivery), name, price — a range when
334
+ // price !== maxPrice, else price + labelled compareAtPrice — EVERY ribbon from ribbons,
335
+ // swatches as small color dots when present (else optionsSummary as text); tile links to
336
+ // `/products/${p.slug}`; and <QuickAdd product={p} /> as the LAST ROW of the tile's text
337
+ // block, under name and price, full width — the shipped buy control (direct add / option
338
+ // picker / product page, decided from the product). It is a direct child of the tile root,
339
+ // which carries `relative`: the picker anchors to that root and takes the tile's width.
340
+ // Never overlay the control on the image or float it between image and text, and never
341
+ // wrap it in a smaller positioned box (the picker would inherit that box's width).
342
+ // • badges come ONLY from p.ribbons. Do NOT render a "Sale" badge because compareAtPrice
343
+ // is set — the struck price already says it, and a product the merchant ribboned "Sale"
344
+ // would show the badge twice.
345
+ // • name and price on SEPARATE lines (`min-w-0`, the name may wrap) — never one flex row
346
+ // where a long name and a price range fight for width and the price gets clipped at 390px.
347
+ // Keep the page intro short enough that a full tile (image, name, price) is in the first
348
+ // screen, also on a short desktop window.
214
349
  // • hasMore → your "load more" control calling loadMore() (disabled while loadingMore)
215
350
  }
216
351
  ```
@@ -236,22 +371,30 @@ export default function ProductDetailView(props: {
236
371
  // to full-width images stacked down the column (products with per-color
237
372
  // linked media carry several gallery urls, so that stacks big duplicates).
238
373
  // A single-image gallery is just the one primary — no empty strip.
239
- // • name, live d.price / d.compareAtPrice (strike only when they differ),
240
- // descriptionHtml rendered as HTML
374
+ // • name, EVERY ribbon (d.product.ribbons), live d.price (the range until every option
375
+ // is picked) with d.compareAtPrice as a labelled "was" when present — never invent one;
376
+ // descriptionHtml rendered as HTML, then d.product.infoSections as sections/accordions
241
377
  // • option controls from d.optionGroups → d.selectOption(optionName, choiceName)
242
378
  // (isColor → real swatches via colorCode; disable out-of-stock choices);
243
379
  // modifiers from d.product.modifiers → d.setModifier(key, value), "*" = mandatory
244
380
  // • quantity (d.quantity / d.setQuantity), then the buy button gated by d.canAdd
245
- // ONLY — never resolve variants yourself — calling d.add(); d.adding disables,
246
- // d.error renders inline
381
+ // ONLY — never resolve variants yourself — calling d.add(); label it "Pre-order" when
382
+ // d.isPreorder; while disabled, render d.blockedReason beside it as neutral guidance
383
+ // (not error styling); d.adding disables, d.error renders inline
384
+ // • in the first screen at mobile AND desktop: the image, name, price, the first choice,
385
+ // and the button with its reason — a shopper decides without scrolling. On a phone that
386
+ // means the primary image is a bounded band, not a full-height hero: e.g. the gallery
387
+ // column `max-h-[45vh] md:max-h-none` with `object-contain`, thumbnails as a row under
388
+ // it, and the two-column split only from md (`md:grid md:grid-cols-2`); description and
389
+ // infoSections come AFTER the buy button, never between the price and the action.
247
390
  }
248
391
  ```
249
392
 
250
393
  ### Wiring — Astro (default)
251
394
 
252
395
  1. Set the `@theme` tokens (one edit); brand `SiteLayout.astro` (one pass).
253
- 2. Create the shop and PDP pages and their islands per the skeletons above — frontmatter
254
- machinery exact, presentation yours. Primary-content islands mount `client:load` with the
396
+ 2. Create the shop, category, and PDP pages and their islands per the skeletons above —
397
+ frontmatter machinery exact, presentation yours. Primary-content islands mount `client:load` with the
255
398
  SSR props; browser-state widgets (cart) are `client:only="react"`.
256
399
  3. Write `pages/index.astro` (home) on `SiteLayout`.
257
400
 
@@ -259,8 +402,10 @@ export default function ProductDetailView(props: {
259
402
 
260
403
  Import `./styles/global.css` once at the app entry (needs `@tailwindcss/vite` in the vite
261
404
  config plugins — deploy already added the dep). Write route wrappers in the project's router:
262
- `/shop` → your shop component; `/products/:slug` → your detail component
263
- (`useProductDetail({ slug })` — components fetch client-side when no `initial` is passed).
405
+ `/shop` → your shop component; `/category/:slug` → the same shop component with
406
+ `initialCategoryId` resolved from `fetchCategoryBySlug(slug)` (null → your 404 view);
407
+ `/products/:slug` → your detail component (`useProductDetail({ slug })` — components fetch
408
+ client-side when no `initial` is passed).
264
409
  Mount the shipped `CartButton` in the header and `CartDrawer` once. Deploy wrote the public
265
410
  client id into `wix/config.ts`; nothing else to configure.
266
411
 
@@ -270,12 +415,22 @@ client id into `wix/config.ts`; nothing else to configure.
270
415
  internals or re-derive a request shape. Extend by calling the exports or adding a new
271
416
  function in `wix/storefront/` for what they don't cover (API contracts: the `wix-docs` skill).
272
417
  - **Selection→cart goes through `useProductDetail`** — never add a product with options by
273
- picking `variants[0]`, and never gate `canAdd` yourself.
418
+ picking `variants[0]`, and never gate `canAdd` yourself. In the gallery that is the shipped
419
+ `QuickAdd` — a tile never adds a product with options itself, and never hides the buy path
420
+ behind "go to the product page" for a product that has no options.
421
+ - **Filters are the shipped `FilterPanel`** — mounted in the gallery whenever the store has a
422
+ catalog to filter; not rebuilt with fewer controls, not dropped because the brief didn't ask.
423
+ - **Categories are pages** — `/category/[slug]` with its SEO block, linked from the chrome; a
424
+ category that exists only as a state toggle on `/shop` has no URL to share or index.
274
425
  - Don't wrap shipped calls in your own API routes — they run client-side by design.
275
426
  - Theme via the `@theme` tokens; your markup uses Tailwind utilities on the same tokens. No
276
427
  parallel theme files, no hardcoded palette values in components.
277
428
  - Checkout only through the shipped cart (`checkout()`) — never a hand-built checkout URL.
278
429
  - Live data or an honest empty state — never mock products, prices, reviews, or counts.
430
+ - **Prices and ribbons come from the DTOs as-is** — no computed percent-off, no "Sale" badge
431
+ inferred from `compareAtPrice`, no struck price beside a range (the DTO already withholds it).
432
+ - **Cart totals come from `cart`** — never summed or hardcoded in the client; shipping and tax say
433
+ "calculated at checkout" (the drawer already does).
279
434
  - Your PDP page carries the SEO pieces (`wixMetadata` + `loadSEOTagsServiceConfig` +
280
435
  `<SEO.Tags>`) exactly as the skeleton shows — owners edit those tags in their dashboard.
281
436
  - **Browsing, cart and checkout need no login.** They run on the Wix visitor session the
@@ -305,10 +460,26 @@ a color option, ≥1 on sale, an image per product) unless the brief says otherw
305
460
  ## Verify (before declaring done)
306
461
 
307
462
  - [ ] `/shop` renders live products SSR (view-source shows product names) through **your**
308
- grid/card; category bar filters when categories exist; empty catalog shows your honest
309
- empty state.
310
- - [ ] Your PDP: color options render as swatches, add is disabled until every option is
311
- picked (`canAdd`), the resolved variant's price shows, a sale shows the strikethrough.
463
+ grid/card; the `FilterPanel` shows the result count, and on desktop a sidebar beside the
464
+ grid with the price slider and this catalog's facets (a Color option → swatches); category links lead to `/category/<slug>`, which renders scoped and
465
+ carries the SEO tags in view-source; an unknown slug is a 404; empty catalog shows your
466
+ honest empty state.
467
+ - [ ] Shop first screen: at 1280×720 and at 390px wide, at least one full tile — image, name,
468
+ price — is visible without scrolling; the tile with the longest product name and a price
469
+ range shows the whole price at 390px, nothing clipped.
470
+ - [ ] Tiles: the buy control sits under name and price (not over the image); a product with no
471
+ options adds to the cart in one click; a product with options opens the picker across the
472
+ full tile width (a bottom sheet at 390px wide) and adds the chosen variant; the drawer
473
+ opens after either.
474
+ - [ ] Your PDP at 390px wide: image, name, price, the first option, and the buy button are all
475
+ visible before scrolling. Color options render as swatches, the button shows
476
+ `blockedReason` ("Choose Size") until every option is picked, the price is the range until then and the variant's
477
+ price after, a sale shows the labelled "was", a sold-out combination reads "Out of stock",
478
+ a pre-orderable one reads "Pre-order".
479
+ - [ ] Cards: every ribbon renders; a multi-price product shows a range with no struck price; a
480
+ product with a color option shows its swatches.
481
+ - [ ] PDP gallery: one thumbnail per distinct photo — never two selectors leading to the same image.
482
+ - [ ] Cart: a subscription line shows its plan terms under the product name.
312
483
  - [ ] Cart: add / quantity ± / remove work; badge count is live; subtotal shows; cart survives
313
484
  a reload (same visitor token).
314
485
  - [ ] Checkout button redirects to Wix-hosted checkout.
@@ -1,10 +1,31 @@
1
1
  // Slide-over cart with quantity stepper, remove, live subtotal, and Wix-hosted checkout.
2
2
  // Mount ONCE per page, as-is; opens via useCart().openCart() / CartButton. Styled from the
3
3
  // @theme tokens.
4
+ import { useEffect, useRef } from "react";
4
5
  import { useCart } from "../../hooks/storefront/useCart";
5
6
 
6
7
  export default function CartDrawer() {
7
8
  const { cart, open, closeCart, busy, error, updateQuantity, removeLine, checkout } = useCart();
9
+
10
+ // The overlay contract, done here rather than assumed from CSS: Escape closes (the backdrop
11
+ // click is pointer-only), the page behind stops scrolling, focus moves into the panel on open
12
+ // and returns to whatever opened it on close. Hooks run before the early return below.
13
+ const panelRef = useRef<HTMLElement>(null);
14
+ useEffect(() => {
15
+ if (!open) return;
16
+ const opener = document.activeElement;
17
+ const previousOverflow = document.body.style.overflow;
18
+ document.body.style.overflow = "hidden";
19
+ panelRef.current?.focus();
20
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && closeCart();
21
+ document.addEventListener("keydown", onKey);
22
+ return () => {
23
+ document.removeEventListener("keydown", onKey);
24
+ document.body.style.overflow = previousOverflow;
25
+ if (opener instanceof HTMLElement) opener.focus();
26
+ };
27
+ }, [open, closeCart]);
28
+
8
29
  if (!open) return null;
9
30
  const lines = cart?.lines ?? [];
10
31
 
@@ -14,10 +35,13 @@ export default function CartDrawer() {
14
35
  onClick={closeCart}
15
36
  >
16
37
  <aside
38
+ ref={panelRef}
39
+ tabIndex={-1}
17
40
  role="dialog"
41
+ aria-modal="true"
18
42
  aria-label="Cart"
19
43
  onClick={(e) => e.stopPropagation()}
20
- className="flex h-full w-full max-w-md flex-col bg-background text-foreground shadow-2xl"
44
+ className="flex h-full w-full max-w-md flex-col bg-background text-foreground shadow-2xl outline-none"
21
45
  >
22
46
  <div className="flex items-center justify-between border-b border-border px-6 py-5">
23
47
  <span className="text-base font-semibold">
@@ -56,6 +80,9 @@ export default function CartDrawer() {
56
80
  {d}
57
81
  </p>
58
82
  ))}
83
+ {line.subscription && (
84
+ <p className="mt-0.5 text-xs text-muted-foreground">Subscription: {line.subscription}</p>
85
+ )}
59
86
  {line.status !== "IN_STOCK" && (
60
87
  <p className="mt-1 text-xs text-red-600">No longer available at this quantity</p>
61
88
  )}
@@ -98,12 +125,19 @@ export default function CartDrawer() {
98
125
 
99
126
  {lines.length > 0 && (
100
127
  <div className="border-t border-border px-6 py-5">
128
+ {cart?.discount && (
129
+ <div className="mb-2 flex justify-between text-sm">
130
+ <span className="text-muted-foreground">Discount</span>
131
+ <span>−{cart.discount}</span>
132
+ </div>
133
+ )}
101
134
  {cart?.subtotal && (
102
- <div className="mb-4 flex justify-between text-sm">
135
+ <div className="mb-1 flex justify-between text-sm">
103
136
  <span className="text-muted-foreground">Subtotal</span>
104
137
  <strong className="font-semibold">{cart.subtotal}</strong>
105
138
  </div>
106
139
  )}
140
+ <p className="mb-4 text-xs text-muted-foreground">Shipping and taxes are calculated at checkout.</p>
107
141
  {error && <p className="mb-3 text-xs text-red-600">{error}</p>}
108
142
  <button
109
143
  type="button"
@@ -111,7 +145,7 @@ export default function CartDrawer() {
111
145
  onClick={() => checkout().catch(() => {})}
112
146
  className="w-full rounded-full bg-primary py-3.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
113
147
  >
114
- {busy ? "One moment…" : "Checkout"}
148
+ {busy ? "One moment…" : "Continue to secure checkout"}
115
149
  </button>
116
150
  </div>
117
151
  )}