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.
- package/package.json +3 -3
- package/template/.claude/skills/storefront-design/SKILL.md +14 -10
- package/template/.env.example +5 -0
- package/template/AGENTS.md +36 -17
- package/template/DESIGN.md +117 -0
- package/template/PAGES.md +116 -29
- package/template/README.md +15 -3
- package/template/app/account/addresses/page.tsx +16 -0
- package/template/app/account/layout.tsx +14 -0
- package/template/app/account/loading.tsx +15 -0
- package/template/app/account/orders/[id]/page.tsx +15 -0
- package/template/app/account/page.tsx +12 -244
- package/template/app/account/profile/page.tsx +16 -0
- package/template/app/blog/(index)/loading.tsx +22 -0
- package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
- package/template/app/blog/[handle]/page.tsx +92 -56
- package/template/app/blog/author/[handle]/page.tsx +1 -1
- package/template/app/cart/layout.tsx +14 -0
- package/template/app/cart/page.tsx +49 -101
- package/template/app/checkout/layout.tsx +14 -0
- package/template/app/checkout/page.tsx +98 -281
- package/template/app/collections/[handle]/layout.tsx +20 -0
- package/template/app/collections/[handle]/loading.tsx +23 -0
- package/template/app/collections/[handle]/page.tsx +131 -15
- package/template/app/collections/page.tsx +49 -0
- package/template/app/contacts/page.tsx +196 -0
- package/template/app/error.tsx +19 -8
- package/template/app/fonts/OFL.txt +93 -0
- package/template/app/fonts/index.ts +14 -0
- package/template/app/fonts/onest-variable.woff2 +0 -0
- package/template/app/globals.css +14 -518
- package/template/app/layout.tsx +74 -50
- package/template/app/not-found.tsx +34 -4
- package/template/app/page.tsx +1 -1
- package/template/app/pages/[handle]/page.tsx +1 -1
- package/template/app/products/[handle]/layout.tsx +20 -0
- package/template/app/products/[handle]/loading.tsx +25 -0
- package/template/app/products/[handle]/page.tsx +112 -15
- package/template/app/providers.tsx +20 -7
- package/template/app/search/loading.tsx +12 -0
- package/template/app/search/page.tsx +138 -25
- package/template/app/sitemap.ts +5 -1
- package/template/app/storefront-api/[...path]/route.ts +11 -66
- package/template/app/styles/base.css +138 -0
- package/template/app/styles/components.css +1108 -0
- package/template/app/styles/layout.css +455 -0
- package/template/app/styles/pages.css +1839 -0
- package/template/app/styles/reset.css +55 -0
- package/template/app/styles/sections.css +415 -0
- package/template/app/styles/tokens.css +167 -0
- package/template/app/styles/utilities.css +37 -0
- package/template/app/wishlist/page.tsx +26 -0
- package/template/components/account/account-shell.tsx +100 -0
- package/template/components/account/addresses.tsx +247 -0
- package/template/components/account/order-detail.tsx +281 -0
- package/template/components/account/order-list.tsx +94 -0
- package/template/components/account/order-status.tsx +19 -0
- package/template/components/account/profile.tsx +129 -0
- package/template/components/account/sign-in.tsx +164 -0
- package/template/components/article-body.tsx +3 -2
- package/template/components/article-card.tsx +37 -12
- package/template/components/cart/cart-drawer.tsx +149 -0
- package/template/components/cart/cart-lines.tsx +89 -0
- package/template/components/cart/cart-summary.tsx +191 -0
- package/template/components/catalog/catalog-form.tsx +107 -0
- package/template/components/catalog/catalog-layout.tsx +82 -0
- package/template/components/catalog/facets-shell.tsx +60 -0
- package/template/components/catalog/facets.tsx +216 -0
- package/template/components/checkout/order-summary.tsx +84 -0
- package/template/components/checkout/radio-card.tsx +34 -0
- package/template/components/checkout/step-indicator.tsx +56 -0
- package/template/components/checkout/steps.tsx +264 -0
- package/template/components/checkout/thank-you.tsx +48 -0
- package/template/components/checkout/use-checkout.ts +90 -0
- package/template/components/footer/site-footer.tsx +132 -0
- package/template/components/header/cart-button.tsx +43 -0
- package/template/components/header/desktop-nav.tsx +133 -0
- package/template/components/header/mobile-menu.tsx +102 -0
- package/template/components/header/site-header.tsx +78 -0
- package/template/components/page-loading.tsx +20 -0
- package/template/components/pager.tsx +3 -1
- package/template/components/product/buy-box.tsx +148 -0
- package/template/components/product/gallery.tsx +179 -0
- package/template/components/product/pre-order-form.tsx +93 -0
- package/template/components/product/product-selection.tsx +31 -0
- package/template/components/product/recommendations.tsx +26 -0
- package/template/components/product/review-list.tsx +159 -0
- package/template/components/product/reviews.tsx +80 -0
- package/template/components/product/variant-picker.tsx +75 -0
- package/template/components/product-card.tsx +103 -0
- package/template/components/product-grid.tsx +27 -57
- package/template/components/quick-add.tsx +62 -0
- package/template/components/search/predictive-search.tsx +282 -0
- package/template/components/sections/announcement-bar.tsx +1 -1
- package/template/components/sections/banner.tsx +1 -1
- package/template/components/sections/collections.tsx +50 -13
- package/template/components/sections/deal-of-day.tsx +2 -2
- package/template/components/sections/product-shelves.tsx +13 -5
- package/template/components/sections/shoppable-stories.tsx +2 -2
- package/template/components/sections/store-reviews.tsx +10 -4
- package/template/components/share-button.tsx +42 -0
- package/template/components/ui/accordion.tsx +35 -0
- package/template/components/ui/badge.tsx +20 -0
- package/template/components/ui/breadcrumbs.tsx +31 -0
- package/template/components/ui/button.tsx +90 -0
- package/template/components/ui/drawer.tsx +84 -0
- package/template/components/ui/empty-state.tsx +27 -0
- package/template/components/ui/field.tsx +33 -0
- package/template/components/ui/icon.tsx +112 -0
- package/template/components/ui/price.tsx +35 -0
- package/template/components/ui/quantity-stepper.tsx +91 -0
- package/template/components/ui/rating.tsx +48 -0
- package/template/components/ui/skeleton.tsx +46 -0
- package/template/components/wishlist/wishlist-view.tsx +134 -0
- package/template/lib/article-outline.ts +35 -0
- package/template/lib/catalog-params.ts +170 -0
- package/template/lib/contacts.ts +34 -0
- package/template/lib/errors.ts +29 -0
- package/template/lib/i18n.ts +484 -0
- package/template/lib/menu.ts +70 -0
- package/template/lib/theme-settings.ts +36 -0
- package/template/lib/upstream.ts +21 -0
- package/template/llms.txt +13 -0
- package/template/theme/index.ts +7 -1
- package/template/theme/sections/collapsible-content.tsx +75 -0
- package/template/theme/sections/collection-list.tsx +32 -4
- package/template/theme/sections/featured-collection.tsx +22 -2
- package/template/theme/sections/hero.tsx +94 -7
- package/template/theme/sections/image-with-text.tsx +19 -3
- package/template/theme/sections/multicolumn.tsx +113 -0
- package/template/theme/sections/page-content.tsx +10 -4
- package/template/theme/sections/product-shelf.tsx +26 -4
- package/template/theme/sections/rich-text.tsx +13 -1
- package/template/theme/sections/testimonials.tsx +14 -9
- package/template/theme/settings.ts +85 -1
- package/template/theme/templates/index.json +4 -2
- package/template/.screenshots/home-1280.png +0 -0
- package/template/.screenshots/home-390.png +0 -0
- package/template/.screenshots/home-filled-1280.png +0 -0
- package/template/.screenshots/home-filled-390.png +0 -0
- package/template/.screenshots/page-1280.png +0 -0
- package/template/components/buy-box.tsx +0 -89
- 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
|
+
}
|