create-magic-storefront 0.4.0 → 0.6.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 (143) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/.env.example +5 -0
  4. package/template/AGENTS.md +36 -17
  5. package/template/DESIGN.md +117 -0
  6. package/template/PAGES.md +116 -29
  7. package/template/README.md +15 -3
  8. package/template/app/account/addresses/page.tsx +16 -0
  9. package/template/app/account/layout.tsx +14 -0
  10. package/template/app/account/loading.tsx +15 -0
  11. package/template/app/account/orders/[id]/page.tsx +15 -0
  12. package/template/app/account/page.tsx +12 -244
  13. package/template/app/account/profile/page.tsx +16 -0
  14. package/template/app/blog/(index)/loading.tsx +22 -0
  15. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  16. package/template/app/blog/[handle]/page.tsx +92 -56
  17. package/template/app/blog/author/[handle]/page.tsx +1 -1
  18. package/template/app/cart/layout.tsx +14 -0
  19. package/template/app/cart/page.tsx +49 -101
  20. package/template/app/checkout/layout.tsx +14 -0
  21. package/template/app/checkout/page.tsx +98 -281
  22. package/template/app/collections/[handle]/layout.tsx +20 -0
  23. package/template/app/collections/[handle]/loading.tsx +23 -0
  24. package/template/app/collections/[handle]/page.tsx +131 -15
  25. package/template/app/collections/page.tsx +49 -0
  26. package/template/app/contacts/page.tsx +196 -0
  27. package/template/app/error.tsx +19 -8
  28. package/template/app/fonts/OFL.txt +93 -0
  29. package/template/app/fonts/index.ts +14 -0
  30. package/template/app/fonts/onest-variable.woff2 +0 -0
  31. package/template/app/globals.css +14 -518
  32. package/template/app/layout.tsx +74 -50
  33. package/template/app/not-found.tsx +34 -4
  34. package/template/app/page.tsx +1 -1
  35. package/template/app/pages/[handle]/page.tsx +1 -1
  36. package/template/app/products/[handle]/layout.tsx +20 -0
  37. package/template/app/products/[handle]/loading.tsx +25 -0
  38. package/template/app/products/[handle]/page.tsx +112 -15
  39. package/template/app/providers.tsx +20 -7
  40. package/template/app/search/loading.tsx +12 -0
  41. package/template/app/search/page.tsx +138 -25
  42. package/template/app/sitemap.ts +5 -1
  43. package/template/app/storefront-api/[...path]/route.ts +11 -66
  44. package/template/app/styles/base.css +138 -0
  45. package/template/app/styles/components.css +1108 -0
  46. package/template/app/styles/layout.css +455 -0
  47. package/template/app/styles/pages.css +1839 -0
  48. package/template/app/styles/reset.css +55 -0
  49. package/template/app/styles/sections.css +415 -0
  50. package/template/app/styles/tokens.css +167 -0
  51. package/template/app/styles/utilities.css +37 -0
  52. package/template/app/wishlist/page.tsx +26 -0
  53. package/template/components/account/account-shell.tsx +100 -0
  54. package/template/components/account/addresses.tsx +247 -0
  55. package/template/components/account/order-detail.tsx +281 -0
  56. package/template/components/account/order-list.tsx +94 -0
  57. package/template/components/account/order-status.tsx +19 -0
  58. package/template/components/account/profile.tsx +129 -0
  59. package/template/components/account/sign-in.tsx +164 -0
  60. package/template/components/article-body.tsx +3 -2
  61. package/template/components/article-card.tsx +37 -12
  62. package/template/components/cart/cart-drawer.tsx +149 -0
  63. package/template/components/cart/cart-lines.tsx +89 -0
  64. package/template/components/cart/cart-summary.tsx +191 -0
  65. package/template/components/catalog/catalog-form.tsx +107 -0
  66. package/template/components/catalog/catalog-layout.tsx +82 -0
  67. package/template/components/catalog/facets-shell.tsx +60 -0
  68. package/template/components/catalog/facets.tsx +216 -0
  69. package/template/components/checkout/order-summary.tsx +84 -0
  70. package/template/components/checkout/radio-card.tsx +34 -0
  71. package/template/components/checkout/step-indicator.tsx +56 -0
  72. package/template/components/checkout/steps.tsx +264 -0
  73. package/template/components/checkout/thank-you.tsx +48 -0
  74. package/template/components/checkout/use-checkout.ts +90 -0
  75. package/template/components/footer/site-footer.tsx +132 -0
  76. package/template/components/header/cart-button.tsx +43 -0
  77. package/template/components/header/desktop-nav.tsx +133 -0
  78. package/template/components/header/mobile-menu.tsx +102 -0
  79. package/template/components/header/site-header.tsx +78 -0
  80. package/template/components/page-loading.tsx +20 -0
  81. package/template/components/pager.tsx +3 -1
  82. package/template/components/product/buy-box.tsx +148 -0
  83. package/template/components/product/gallery.tsx +179 -0
  84. package/template/components/product/pre-order-form.tsx +93 -0
  85. package/template/components/product/product-selection.tsx +31 -0
  86. package/template/components/product/recommendations.tsx +26 -0
  87. package/template/components/product/review-list.tsx +159 -0
  88. package/template/components/product/reviews.tsx +80 -0
  89. package/template/components/product/variant-picker.tsx +75 -0
  90. package/template/components/product-card.tsx +103 -0
  91. package/template/components/product-grid.tsx +27 -57
  92. package/template/components/quick-add.tsx +62 -0
  93. package/template/components/search/predictive-search.tsx +282 -0
  94. package/template/components/sections/announcement-bar.tsx +1 -1
  95. package/template/components/sections/banner.tsx +1 -1
  96. package/template/components/sections/collections.tsx +50 -13
  97. package/template/components/sections/deal-of-day.tsx +2 -2
  98. package/template/components/sections/product-shelves.tsx +13 -5
  99. package/template/components/sections/shoppable-stories.tsx +2 -2
  100. package/template/components/sections/store-reviews.tsx +10 -4
  101. package/template/components/share-button.tsx +42 -0
  102. package/template/components/ui/accordion.tsx +35 -0
  103. package/template/components/ui/badge.tsx +20 -0
  104. package/template/components/ui/breadcrumbs.tsx +31 -0
  105. package/template/components/ui/button.tsx +90 -0
  106. package/template/components/ui/drawer.tsx +84 -0
  107. package/template/components/ui/empty-state.tsx +27 -0
  108. package/template/components/ui/field.tsx +33 -0
  109. package/template/components/ui/icon.tsx +112 -0
  110. package/template/components/ui/price.tsx +35 -0
  111. package/template/components/ui/quantity-stepper.tsx +91 -0
  112. package/template/components/ui/rating.tsx +48 -0
  113. package/template/components/ui/skeleton.tsx +46 -0
  114. package/template/components/wishlist/wishlist-view.tsx +134 -0
  115. package/template/lib/article-outline.ts +35 -0
  116. package/template/lib/catalog-params.ts +170 -0
  117. package/template/lib/contacts.ts +34 -0
  118. package/template/lib/errors.ts +29 -0
  119. package/template/lib/i18n.ts +484 -0
  120. package/template/lib/menu.ts +70 -0
  121. package/template/lib/theme-settings.ts +36 -0
  122. package/template/lib/upstream.ts +21 -0
  123. package/template/llms.txt +13 -0
  124. package/template/theme/index.ts +7 -1
  125. package/template/theme/sections/collapsible-content.tsx +75 -0
  126. package/template/theme/sections/collection-list.tsx +32 -4
  127. package/template/theme/sections/featured-collection.tsx +22 -2
  128. package/template/theme/sections/hero.tsx +94 -7
  129. package/template/theme/sections/image-with-text.tsx +19 -3
  130. package/template/theme/sections/multicolumn.tsx +113 -0
  131. package/template/theme/sections/page-content.tsx +10 -4
  132. package/template/theme/sections/product-shelf.tsx +26 -4
  133. package/template/theme/sections/rich-text.tsx +13 -1
  134. package/template/theme/sections/testimonials.tsx +14 -9
  135. package/template/theme/settings.ts +85 -1
  136. package/template/theme/templates/index.json +4 -2
  137. package/template/.screenshots/home-1280.png +0 -0
  138. package/template/.screenshots/home-390.png +0 -0
  139. package/template/.screenshots/home-filled-1280.png +0 -0
  140. package/template/.screenshots/home-filled-390.png +0 -0
  141. package/template/.screenshots/page-1280.png +0 -0
  142. package/template/components/buy-box.tsx +0 -89
  143. package/template/components/cart-link.tsx +0 -15
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import { Image, Money, useCart, useMagicStore } from '@magicstoreai/hydrogen';
4
+ import Link from 'next/link';
5
+
6
+ import { IconButton } from '@/components/ui/button';
7
+ import { QuantityStepper } from '@/components/ui/quantity-stepper';
8
+ import { messages } from '@/lib/i18n';
9
+
10
+ /** A variant title worth showing: not the placeholder of a product without options. */
11
+ function variantTitle(title: string | undefined): string | null {
12
+ return title && !/^Default( Title)?$/.test(title) ? title : null;
13
+ }
14
+
15
+ /**
16
+ * The cart's lines: thumbnail, title (a link to the product), variant, a quantity stepper bounded by
17
+ * the line's stock, remove, and the line total from the cart. A line's `issues` (sold out, too many,
18
+ * unpublished) are shown under it as the API reported them. Shared by the cart drawer and the cart
19
+ * page. A failed change stays in `useCart().error`; the parent shows it.
20
+ */
21
+ export function CartLines({ onNavigate }: { onNavigate?: () => void }) {
22
+ const { cart, status, updateLine, removeLine } = useCart();
23
+ const t = messages(useMagicStore().locale);
24
+ if (cart === null) {
25
+ return null;
26
+ }
27
+ const busy = status === 'updating';
28
+
29
+ return (
30
+ <ul className="cart-lines">
31
+ {cart.lines.map((line) => {
32
+ const product = line.merchandise.product;
33
+ const href = product ? `/products/${product.handle}` : null;
34
+ const variant = variantTitle(line.merchandise.variant?.title);
35
+ const issues = [...new Set(line.issues.map((issue) => t.lineIssues[issue]))];
36
+ return (
37
+ <li key={line.id} className="cart-line">
38
+ <span className="cart-line__media">
39
+ <Image
40
+ data={line.merchandise.image}
41
+ width={80}
42
+ height={80}
43
+ alt=""
44
+ fallback={<span className="media-fallback" />}
45
+ />
46
+ </span>
47
+ <div className="cart-line__info">
48
+ {href ? (
49
+ <Link href={href} className="cart-line__title" onClick={onNavigate}>
50
+ {product?.title}
51
+ </Link>
52
+ ) : (
53
+ <span className="cart-line__title">{product?.title}</span>
54
+ )}
55
+ {variant && <span className="cart-line__variant">{variant}</span>}
56
+ {!line.available && issues.length === 0 && (
57
+ <span className="cart-line__issue">{t.notAvailable}</span>
58
+ )}
59
+ {issues.map((issue) => (
60
+ <span key={issue} className="cart-line__issue">
61
+ {issue}
62
+ </span>
63
+ ))}
64
+ </div>
65
+ <div className="cart-line__controls">
66
+ <QuantityStepper
67
+ value={line.quantity}
68
+ max={line.quantityAvailable}
69
+ label={t.quantity}
70
+ decreaseLabel={t.decreaseQuantity}
71
+ increaseLabel={t.increaseQuantity}
72
+ disabled={busy}
73
+ onChange={(quantity) => void updateLine(line.id, quantity).catch(() => undefined)}
74
+ />
75
+ <IconButton
76
+ icon="trash"
77
+ label={`${t.remove}: ${product?.title ?? ''}`}
78
+ className="cart-line__remove"
79
+ disabled={busy}
80
+ onClick={() => void removeLine(line.id).catch(() => undefined)}
81
+ />
82
+ </div>
83
+ <Money data={line.cost.total} className="cart-line__total" />
84
+ </li>
85
+ );
86
+ })}
87
+ </ul>
88
+ );
89
+ }
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import { Money, moneyAmount, useCart, useMagicStore } from '@magicstoreai/hydrogen';
4
+ import type { Schema } from '@magicstoreai/storefront-client';
5
+ import Link from 'next/link';
6
+ import { useId, useState, type FormEvent } from 'react';
7
+
8
+ import { buttonClass, Button } from '@/components/ui/button';
9
+ import { Icon } from '@/components/ui/icon';
10
+ import { describe } from '@/lib/errors';
11
+ import { messages, type Messages } from '@/lib/i18n';
12
+
13
+ type Cart = Schema<'Cart'>;
14
+ type MoneyValue = Schema<'Money'>;
15
+
16
+ /**
17
+ * The cart page's summary: the discount code, the amounts exactly as `cart.cost` has them (a row
18
+ * per discount only when there is one), the note to the shop, and checkout. Nothing is added up
19
+ * here.
20
+ */
21
+ export function CartSummary({ cart }: { cart: Cart }) {
22
+ const { error } = useCart();
23
+ const t = messages(useMagicStore().locale);
24
+ const { cost } = cart;
25
+
26
+ return (
27
+ <aside className="cart-summary" aria-labelledby="cart-summary-title">
28
+ <h2 id="cart-summary-title" className="cart-summary__title">
29
+ {t.summary}
30
+ </h2>
31
+ <DiscountCodes cart={cart} t={t} />
32
+ <dl className="cart-summary__rows">
33
+ <Row label={t.subtotal} value={cost.subtotal} />
34
+ <Row label={t.discount} value={cost.discountTotal} negative />
35
+ <Row label={t.welcomeDiscount} value={cost.welcomeDiscount} negative />
36
+ <Row label={t.pointsDiscount} value={cost.pointsDiscount} negative />
37
+ <div className="cart-summary__row cart-summary__row--total">
38
+ <dt>{t.total}</dt>
39
+ <dd>{cost.total && <Money data={cost.total} />}</dd>
40
+ </div>
41
+ </dl>
42
+ <p className="muted text-sm">{t.deliveryAtCheckout}</p>
43
+ <CartNote note={cart.note} t={t} />
44
+ {error && (
45
+ <p className="error" role="alert">
46
+ {describe(error, t.tryAgainLater)}
47
+ </p>
48
+ )}
49
+ <Link
50
+ href="/checkout"
51
+ className={buttonClass({ variant: 'primary', size: 'lg', full: true })}
52
+ >
53
+ {t.checkout}
54
+ </Link>
55
+ <Link href="/collections" className="cart-summary__continue">
56
+ {t.continueShopping}
57
+ </Link>
58
+ </aside>
59
+ );
60
+ }
61
+
62
+ /** A discount row, only when the cart has that discount. */
63
+ function Row({
64
+ label,
65
+ value,
66
+ negative = false,
67
+ }: {
68
+ label: string;
69
+ value: MoneyValue | null;
70
+ negative?: boolean;
71
+ }) {
72
+ if (value === null || (negative && moneyAmount(value) <= 0)) {
73
+ return null;
74
+ }
75
+ return (
76
+ <div className="cart-summary__row">
77
+ <dt>{label}</dt>
78
+ <dd>
79
+ {negative && '−'}
80
+ <Money data={value} />
81
+ </dd>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The code field and the codes on the cart. A code that does not apply says why, next to the field
88
+ * (`reasonCode`), and can be removed.
89
+ */
90
+ function DiscountCodes({ cart, t }: { cart: Cart; t: Messages }) {
91
+ const { setDiscountCodes, status } = useCart();
92
+ const [code, setCode] = useState('');
93
+ const errorId = useId();
94
+ const codes = cart.discountCodes;
95
+ const refused = codes.find((entry) => !entry.applicable);
96
+
97
+ function submit(event: FormEvent<HTMLFormElement>) {
98
+ event.preventDefault();
99
+ const next = code.trim();
100
+ if (next === '') {
101
+ return;
102
+ }
103
+ // A failed change lands in `useCart().error`, shown in the summary.
104
+ void setDiscountCodes([...codes.map((entry) => entry.code), next]).then(
105
+ () => setCode(''),
106
+ () => undefined,
107
+ );
108
+ }
109
+
110
+ return (
111
+ <div className="discount-codes">
112
+ <form className="discount-codes__form" onSubmit={submit}>
113
+ <label className="visually-hidden" htmlFor={`${errorId}-input`}>
114
+ {t.discountCode}
115
+ </label>
116
+ <input
117
+ id={`${errorId}-input`}
118
+ value={code}
119
+ onChange={(event) => setCode(event.target.value)}
120
+ placeholder={t.discountCode}
121
+ autoComplete="off"
122
+ autoCapitalize="characters"
123
+ aria-invalid={refused ? true : undefined}
124
+ aria-describedby={refused ? errorId : undefined}
125
+ />
126
+ <Button type="submit" loading={status === 'updating'}>
127
+ {t.apply}
128
+ </Button>
129
+ </form>
130
+ {refused?.reasonCode && (
131
+ <p id={errorId} className="error text-sm" role="alert">
132
+ {refused.code}: {t.discountReasons[refused.reasonCode]}
133
+ </p>
134
+ )}
135
+ {codes.length > 0 && (
136
+ <ul className="chips discount-codes__list">
137
+ {codes.map((entry) => (
138
+ <li key={entry.code}>
139
+ <button
140
+ type="button"
141
+ className={entry.applicable ? undefined : 'discount-codes__refused'}
142
+ aria-label={t.removeCode(entry.code)}
143
+ onClick={() =>
144
+ void setDiscountCodes(
145
+ codes.filter((other) => other.code !== entry.code).map((other) => other.code),
146
+ ).catch(() => undefined)
147
+ }
148
+ >
149
+ {entry.code}
150
+ <Icon name="close" size={14} />
151
+ </button>
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ )}
156
+ </div>
157
+ );
158
+ }
159
+
160
+ /** The note to the shop, saved when the field is left (`cartsNote`). */
161
+ function CartNote({ note, t }: { note: string | null; t: Messages }) {
162
+ const { setNote } = useCart();
163
+ const [saved, setSaved] = useState(false);
164
+ const id = useId();
165
+ return (
166
+ <div className="field">
167
+ <label className="field__label" htmlFor={id}>
168
+ {t.orderNote}
169
+ </label>
170
+ <textarea
171
+ id={id}
172
+ rows={2}
173
+ maxLength={1000}
174
+ defaultValue={note ?? ''}
175
+ onChange={() => setSaved(false)}
176
+ onBlur={(event) => {
177
+ const next = event.target.value.trim() || null;
178
+ if (next !== note) {
179
+ void setNote(next).then(
180
+ () => setSaved(true),
181
+ () => undefined,
182
+ );
183
+ }
184
+ }}
185
+ />
186
+ <span className="text-sm muted" role="status">
187
+ {saved ? t.noteSaved : ''}
188
+ </span>
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+
3
+ import { usePathname, useRouter } from 'next/navigation';
4
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
5
+
6
+ /** Above this width a change applies at once; below it the drawer's button applies the lot. */
7
+ const DESKTOP = '(min-width: 990px)';
8
+
9
+ /**
10
+ * The facets' GET form, enhanced. Without JavaScript it is a plain form: submitting loads the page
11
+ * with the filters in the URL. With it, a submit becomes a client navigation (empty fields left out,
12
+ * back to page 1), and on desktop every change submits at once. The sort `<select>` sits outside the
13
+ * form (`form={id}`) and always applies at once.
14
+ */
15
+ export function CatalogForm({
16
+ id,
17
+ className,
18
+ children,
19
+ }: {
20
+ id: string;
21
+ className?: string;
22
+ children: ReactNode;
23
+ }) {
24
+ const router = useRouter();
25
+ const pathname = usePathname();
26
+ // After hydration the form applies changes itself: its plain submit button can go (CSS).
27
+ const [enhanced, setEnhanced] = useState(false);
28
+ const form = useRef<HTMLFormElement>(null);
29
+
30
+ function apply(form: HTMLFormElement) {
31
+ const params = new URLSearchParams();
32
+ for (const [name, value] of new FormData(form)) {
33
+ if (typeof value === 'string' && value.trim() !== '' && name !== 'page') {
34
+ params.append(name, value.trim());
35
+ }
36
+ }
37
+ const query = params.toString();
38
+ router.push(query ? `${pathname}?${query}` : pathname, { scroll: false });
39
+ }
40
+
41
+ // The native `change` (React's onChange fires on every keystroke): a typed price applies when
42
+ // the field is left, a checkbox when it is ticked.
43
+ const applyRef = useRef(apply);
44
+ applyRef.current = apply;
45
+ useEffect(() => {
46
+ setEnhanced(true);
47
+ const element = form.current!;
48
+ const changed = () => {
49
+ if (window.matchMedia(DESKTOP).matches) {
50
+ applyRef.current(element);
51
+ }
52
+ };
53
+ element.addEventListener('change', changed);
54
+ return () => element.removeEventListener('change', changed);
55
+ }, []);
56
+
57
+ return (
58
+ <form
59
+ ref={form}
60
+ id={id}
61
+ method="get"
62
+ action={pathname}
63
+ className={className}
64
+ data-enhanced={enhanced || undefined}
65
+ onSubmit={(event) => {
66
+ event.preventDefault();
67
+ apply(event.currentTarget);
68
+ }}
69
+ >
70
+ {children}
71
+ </form>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * The sort `<select>`, outside the form in the toolbar. A change event on a control associated by
77
+ * `form=` does not bubble to the form element, so this one submits the form itself.
78
+ */
79
+ export function SortSelect({
80
+ formId,
81
+ label,
82
+ value,
83
+ options,
84
+ }: {
85
+ formId: string;
86
+ label: string;
87
+ value: string;
88
+ options: { value: string; label: string }[];
89
+ }) {
90
+ return (
91
+ <label className="sort-select">
92
+ <span className="sort-select__label">{label}</span>
93
+ <select
94
+ name="sort"
95
+ form={formId}
96
+ defaultValue={value}
97
+ onChange={(event) => event.currentTarget.form?.requestSubmit()}
98
+ >
99
+ {options.map((option) => (
100
+ <option key={option.value} value={option.value}>
101
+ {option.label}
102
+ </option>
103
+ ))}
104
+ </select>
105
+ </label>
106
+ );
107
+ }
@@ -0,0 +1,82 @@
1
+ import type { MoneyFormat } from '@magicstoreai/hydrogen/core';
2
+ import type { Schema } from '@magicstoreai/storefront-client';
3
+ import type { ReactNode } from 'react';
4
+
5
+ import { Pager } from '@/components/pager';
6
+ import { ProductGrid } from '@/components/product-grid';
7
+ import { SORT_KEYS, catalogQuery, onPage, type Catalog, type Filter } from '@/lib/catalog-params';
8
+ import type { Messages } from '@/lib/i18n';
9
+
10
+ import { SortSelect } from './catalog-form';
11
+ import { ActiveFilters, Facets } from './facets';
12
+
13
+ const FORM_ID = 'catalog-facets';
14
+
15
+ /**
16
+ * A catalog page's body, shared by the collection and search pages: facets (sidebar or drawer),
17
+ * the toolbar with the count and the sort, the active filters, the grid and the pager. `empty`
18
+ * renders in place of the grid when the page has no products; the page decides what it says.
19
+ */
20
+ export function CatalogLayout({
21
+ filters,
22
+ catalog,
23
+ products,
24
+ path,
25
+ extra = {},
26
+ money,
27
+ locale,
28
+ t,
29
+ empty,
30
+ }: {
31
+ filters: Filter[];
32
+ catalog: Catalog;
33
+ products: { data: Schema<'Product'>[]; meta: { pagination: Schema<'Pagination'> } };
34
+ path: string;
35
+ extra?: Record<string, string>;
36
+ money: { locale: string; format: MoneyFormat | null };
37
+ locale: string;
38
+ t: Messages;
39
+ empty: ReactNode;
40
+ }) {
41
+ const total = products.meta.pagination.total;
42
+ return (
43
+ <div className={filters.length > 0 ? 'catalog catalog--with-facets' : 'catalog'}>
44
+ <aside className="catalog__facets" aria-label={t.filters}>
45
+ <Facets filters={filters} catalog={catalog} formId={FORM_ID} hidden={extra} t={t} />
46
+ </aside>
47
+ <div className="catalog__main">
48
+ <div className="catalog__toolbar">
49
+ <p className="catalog__count" aria-live="polite">
50
+ {t.productsCount(total)}
51
+ </p>
52
+ <SortSelect
53
+ formId={FORM_ID}
54
+ label={t.sortBy}
55
+ value={catalog.sort ?? ''}
56
+ options={[
57
+ { value: '', label: t.sortOptions.default },
58
+ ...SORT_KEYS.map((key) => ({ value: key, label: t.sortOptions[key] })),
59
+ ]}
60
+ />
61
+ </div>
62
+ <ActiveFilters
63
+ filters={filters}
64
+ catalog={catalog}
65
+ path={path}
66
+ extra={extra}
67
+ money={money}
68
+ t={t}
69
+ />
70
+ {products.data.length > 0 ? (
71
+ <ProductGrid products={products.data} locale={locale} />
72
+ ) : (
73
+ empty
74
+ )}
75
+ <Pager
76
+ meta={products.meta.pagination}
77
+ href={(page) => `${path}${catalogQuery(onPage(catalog, page), extra)}`}
78
+ />
79
+ </div>
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ import { useMagicStore } from '@magicstoreai/hydrogen';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import { useEffect, useState, type ReactNode } from 'react';
6
+
7
+ import { Drawer } from '@/components/ui/drawer';
8
+ import { Icon } from '@/components/ui/icon';
9
+ import { messages } from '@/lib/i18n';
10
+
11
+ /**
12
+ * Where the facets live: a sidebar at desktop width, a drawer from the left below it, opened by the
13
+ * "Filter and sort" button (with the number of active filters). The same form is rendered once:
14
+ * at desktop width the closed `<dialog>` is laid out in place by CSS (`.facets-drawer`).
15
+ * The drawer closes when the filters in the URL change.
16
+ */
17
+ export function FacetsShell({
18
+ activeCount,
19
+ formId,
20
+ children,
21
+ }: {
22
+ activeCount: number;
23
+ formId: string;
24
+ children: ReactNode;
25
+ }) {
26
+ const t = messages(useMagicStore().locale);
27
+ const [open, setOpen] = useState(false);
28
+ const query = useSearchParams().toString();
29
+ useEffect(() => setOpen(false), [query]);
30
+
31
+ return (
32
+ <>
33
+ <button
34
+ type="button"
35
+ className="facets-button"
36
+ aria-haspopup="dialog"
37
+ onClick={() => setOpen(true)}
38
+ >
39
+ <Icon name="filter" size={18} />
40
+ {t.filters}
41
+ {activeCount > 0 && <span className="facets-button__count">{activeCount}</span>}
42
+ </button>
43
+ <Drawer
44
+ open={open}
45
+ onClose={() => setOpen(false)}
46
+ title={t.filters}
47
+ closeLabel={t.close}
48
+ side="left"
49
+ className="facets-drawer"
50
+ footer={
51
+ <button type="submit" form={formId} className="button button--primary button--full">
52
+ {t.showResults}
53
+ </button>
54
+ }
55
+ >
56
+ {children}
57
+ </Drawer>
58
+ </>
59
+ );
60
+ }