create-magic-storefront 0.5.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/AGENTS.md +32 -16
- package/template/DESIGN.md +117 -0
- package/template/PAGES.md +107 -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 +73 -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 +5 -1
- 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/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/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,282 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Image, useMagicStore, useStorefrontClient } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
7
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
|
|
8
|
+
|
|
9
|
+
import { Drawer } from '@/components/ui/drawer';
|
|
10
|
+
import { Icon } from '@/components/ui/icon';
|
|
11
|
+
import { Price } from '@/components/ui/price';
|
|
12
|
+
import { SEARCH_QUERY_MAX } from '@/lib/catalog-params';
|
|
13
|
+
import { messages } from '@/lib/i18n';
|
|
14
|
+
|
|
15
|
+
type Suggestions = Schema<'SearchSuggestions'>;
|
|
16
|
+
type Trending = Schema<'TrendingQuery'>;
|
|
17
|
+
|
|
18
|
+
/** What `searchSuggestions` is asked: its `q` has a `maxLength` of 120, so a longer query's start. */
|
|
19
|
+
function suggestQuery(query: string): string {
|
|
20
|
+
return query.slice(0, 120).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Typing pauses this long before a request: one call per word, not per key. */
|
|
24
|
+
export const SUGGEST_DELAY_MS = 250;
|
|
25
|
+
|
|
26
|
+
/** `/search?q=…` for a query; the results page owns everything past that. */
|
|
27
|
+
export function searchHref(query: string): string {
|
|
28
|
+
return `/search?${new URLSearchParams({ q: query.trim() })}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Option = { id: string; href: string };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Search from the header. The trigger is a link to `/search` (it works without JavaScript); with
|
|
35
|
+
* it, a panel opens over the page with a combobox: suggestions as you type (`searchSuggestions`,
|
|
36
|
+
* debounced, the previous request aborted when a newer one starts), popular searches while the
|
|
37
|
+
* field is empty (`searchTrending`). Arrow keys move through the results, Enter opens the active
|
|
38
|
+
* one or submits the query to `/search`, Esc closes.
|
|
39
|
+
*/
|
|
40
|
+
export function PredictiveSearch({ className }: { className?: string }) {
|
|
41
|
+
const client = useStorefrontClient();
|
|
42
|
+
const router = useRouter();
|
|
43
|
+
const pathname = usePathname();
|
|
44
|
+
const t = messages(useMagicStore().locale);
|
|
45
|
+
const id = useId();
|
|
46
|
+
const input = useRef<HTMLInputElement>(null);
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
const [query, setQuery] = useState('');
|
|
49
|
+
const [results, setResults] = useState<Suggestions | null>(null);
|
|
50
|
+
const [trending, setTrending] = useState<Trending[] | null>(null);
|
|
51
|
+
const [active, setActive] = useState(-1);
|
|
52
|
+
|
|
53
|
+
// Back/forward navigation; a search or a picked suggestion closes the panel itself, even when it
|
|
54
|
+
// leads to the page already open (a new query on /search keeps the pathname).
|
|
55
|
+
useEffect(() => setOpen(false), [pathname]);
|
|
56
|
+
|
|
57
|
+
// Popular searches, once, the first time the panel opens.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!open || trending !== null) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
client.searchTrending({ query: { perPage: 6 } }, { signal: controller.signal }).then(
|
|
64
|
+
({ data }) => setTrending(data),
|
|
65
|
+
() => setTrending((current) => (controller.signal.aborted ? current : [])),
|
|
66
|
+
);
|
|
67
|
+
return () => controller.abort();
|
|
68
|
+
}, [open, trending, client]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const q = suggestQuery(query);
|
|
72
|
+
setActive(-1);
|
|
73
|
+
if (q === '') {
|
|
74
|
+
setResults(null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
client.searchSuggestions({ query: { q } }, { signal: controller.signal }).then(
|
|
80
|
+
// Keyed by what was asked, so a late answer never shows under a newer query.
|
|
81
|
+
({ data }) => setResults({ ...data, query: q }),
|
|
82
|
+
// Aborted: a newer query owns the panel. Failed: no suggestions; Enter still searches.
|
|
83
|
+
() => undefined,
|
|
84
|
+
);
|
|
85
|
+
}, SUGGEST_DELAY_MS);
|
|
86
|
+
return () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
controller.abort();
|
|
89
|
+
};
|
|
90
|
+
}, [query, client]);
|
|
91
|
+
|
|
92
|
+
const q = query.trim();
|
|
93
|
+
const shown =
|
|
94
|
+
q !== '' && results !== null && results.query === suggestQuery(query) ? results : null;
|
|
95
|
+
const queries = shown
|
|
96
|
+
? shown.queries.map((entry) => entry.query)
|
|
97
|
+
: q === ''
|
|
98
|
+
? (trending ?? []).map((entry) => entry.query)
|
|
99
|
+
: [];
|
|
100
|
+
const options: Option[] = [
|
|
101
|
+
...queries.map((value, index) => ({ id: `${id}-q${index}`, href: searchHref(value) })),
|
|
102
|
+
...(shown?.collections ?? []).map((collection) => ({
|
|
103
|
+
id: `${id}-c${collection.id}`,
|
|
104
|
+
href: `/collections/${collection.handle}`,
|
|
105
|
+
})),
|
|
106
|
+
...(shown?.products ?? []).map((product) => ({
|
|
107
|
+
id: `${id}-p${product.id}`,
|
|
108
|
+
href: `/products/${product.handle}`,
|
|
109
|
+
})),
|
|
110
|
+
];
|
|
111
|
+
const activeId = options[active]?.id;
|
|
112
|
+
const listId = `${id}-list`;
|
|
113
|
+
const empty =
|
|
114
|
+
shown !== null && shown.queries.length + shown.collections.length + shown.products.length === 0;
|
|
115
|
+
|
|
116
|
+
const option = (index: number, children: ReactNode) => {
|
|
117
|
+
const entry = options[index]!;
|
|
118
|
+
return (
|
|
119
|
+
<li
|
|
120
|
+
key={entry.id}
|
|
121
|
+
id={entry.id}
|
|
122
|
+
role="option"
|
|
123
|
+
aria-selected={index === active}
|
|
124
|
+
className="predictive-search__option"
|
|
125
|
+
>
|
|
126
|
+
<Link
|
|
127
|
+
href={entry.href}
|
|
128
|
+
tabIndex={-1}
|
|
129
|
+
onMouseEnter={() => setActive(index)}
|
|
130
|
+
onClick={() => setOpen(false)}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</Link>
|
|
134
|
+
</li>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
let index = 0;
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
<Link
|
|
142
|
+
href="/search"
|
|
143
|
+
className={className ? `icon-button ${className}` : 'icon-button'}
|
|
144
|
+
aria-label={t.search}
|
|
145
|
+
aria-haspopup="dialog"
|
|
146
|
+
onClick={(event) => {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
setOpen(true);
|
|
149
|
+
requestAnimationFrame(() => input.current?.focus());
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<Icon name="search" />
|
|
153
|
+
</Link>
|
|
154
|
+
<Drawer
|
|
155
|
+
open={open}
|
|
156
|
+
onClose={() => setOpen(false)}
|
|
157
|
+
title={t.search}
|
|
158
|
+
closeLabel={t.close}
|
|
159
|
+
side="top"
|
|
160
|
+
className="predictive-search"
|
|
161
|
+
>
|
|
162
|
+
<form
|
|
163
|
+
role="search"
|
|
164
|
+
className="predictive-search__form"
|
|
165
|
+
action="/search"
|
|
166
|
+
onSubmit={(event) => {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
const href = options[active]?.href ?? (q !== '' ? searchHref(q) : null);
|
|
169
|
+
if (href !== null) {
|
|
170
|
+
setOpen(false);
|
|
171
|
+
router.push(href);
|
|
172
|
+
}
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<Icon name="search" className="predictive-search__icon" />
|
|
176
|
+
<input
|
|
177
|
+
ref={input}
|
|
178
|
+
name="q"
|
|
179
|
+
type="search"
|
|
180
|
+
role="combobox"
|
|
181
|
+
aria-label={t.search}
|
|
182
|
+
aria-expanded={options.length > 0}
|
|
183
|
+
aria-controls={listId}
|
|
184
|
+
aria-autocomplete="list"
|
|
185
|
+
aria-activedescendant={activeId}
|
|
186
|
+
autoComplete="off"
|
|
187
|
+
maxLength={SEARCH_QUERY_MAX}
|
|
188
|
+
enterKeyHint="search"
|
|
189
|
+
placeholder={t.searchPlaceholder}
|
|
190
|
+
value={query}
|
|
191
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
192
|
+
onKeyDown={(event) => {
|
|
193
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
if (options.length === 0) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
199
|
+
// -1 is the field itself: past either end, back to typing.
|
|
200
|
+
setActive((current) => {
|
|
201
|
+
const next = current + step;
|
|
202
|
+
return next < -1 ? options.length - 1 : next >= options.length ? -1 : next;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
</form>
|
|
208
|
+
<div
|
|
209
|
+
id={listId}
|
|
210
|
+
role="listbox"
|
|
211
|
+
aria-label={t.suggestions}
|
|
212
|
+
className="predictive-search__results"
|
|
213
|
+
>
|
|
214
|
+
{queries.length > 0 && (
|
|
215
|
+
<div role="group" aria-label={shown ? t.suggestions : t.popularSearches}>
|
|
216
|
+
<div className="predictive-search__heading" aria-hidden="true">
|
|
217
|
+
{shown ? t.suggestions : t.popularSearches}
|
|
218
|
+
</div>
|
|
219
|
+
<ul role="presentation" className="predictive-search__queries">
|
|
220
|
+
{queries.map((value) => option(index++, value))}
|
|
221
|
+
</ul>
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
{shown && shown.collections.length > 0 && (
|
|
225
|
+
<div role="group" aria-label={t.collections}>
|
|
226
|
+
<div className="predictive-search__heading" aria-hidden="true">
|
|
227
|
+
{t.collections}
|
|
228
|
+
</div>
|
|
229
|
+
<ul role="presentation" className="predictive-search__collections">
|
|
230
|
+
{shown.collections.map((collection) => option(index++, collection.title))}
|
|
231
|
+
</ul>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
{shown && shown.products.length > 0 && (
|
|
235
|
+
<div role="group" aria-label={t.products}>
|
|
236
|
+
<div className="predictive-search__heading" aria-hidden="true">
|
|
237
|
+
{t.products}
|
|
238
|
+
</div>
|
|
239
|
+
<ul role="presentation" className="predictive-search__products">
|
|
240
|
+
{shown.products.map((product) =>
|
|
241
|
+
option(
|
|
242
|
+
index++,
|
|
243
|
+
<>
|
|
244
|
+
<span className="predictive-search__thumb">
|
|
245
|
+
<Image
|
|
246
|
+
data={product.featuredImage}
|
|
247
|
+
alt=""
|
|
248
|
+
width={48}
|
|
249
|
+
height={64}
|
|
250
|
+
fallback={<span className="media-fallback" />}
|
|
251
|
+
/>
|
|
252
|
+
</span>
|
|
253
|
+
<span className="predictive-search__product">
|
|
254
|
+
<span>{product.title}</span>
|
|
255
|
+
<Price
|
|
256
|
+
price={product.price}
|
|
257
|
+
compareAtPrice={product.compareAtPrice}
|
|
258
|
+
size="sm"
|
|
259
|
+
/>
|
|
260
|
+
</span>
|
|
261
|
+
</>,
|
|
262
|
+
),
|
|
263
|
+
)}
|
|
264
|
+
</ul>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
{empty && <p className="predictive-search__empty">{t.noSuggestions(q)}</p>}
|
|
269
|
+
{q !== '' && (
|
|
270
|
+
<Link
|
|
271
|
+
href={searchHref(q)}
|
|
272
|
+
className="predictive-search__all"
|
|
273
|
+
onClick={() => setOpen(false)}
|
|
274
|
+
>
|
|
275
|
+
{t.searchFor(q)}
|
|
276
|
+
<Icon name="chevron-right" size={16} />
|
|
277
|
+
</Link>
|
|
278
|
+
)}
|
|
279
|
+
</Drawer>
|
|
280
|
+
</>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
@@ -4,7 +4,7 @@ export function AnnouncementBar({ section }: { section: SectionOf<'ANNOUNCEMENT_
|
|
|
4
4
|
const { text, backgroundColor, textColor, animation } = section.settings;
|
|
5
5
|
return (
|
|
6
6
|
<p
|
|
7
|
-
className={`announcement announcement
|
|
7
|
+
className={`announcement-bar announcement-bar--${animation.toLowerCase()}`}
|
|
8
8
|
style={{ background: backgroundColor ?? undefined, color: textColor ?? undefined }}
|
|
9
9
|
>
|
|
10
10
|
<span>{text}</span>
|
|
@@ -24,7 +24,7 @@ function slideHref(link: Slide['link']): string | null {
|
|
|
24
24
|
|
|
25
25
|
export function Banner({ section }: { section: SectionOf<'BANNER'> }) {
|
|
26
26
|
return (
|
|
27
|
-
<section className="banner">
|
|
27
|
+
<section className="banner-slider">
|
|
28
28
|
{section.settings.slides.map((slide, index) => {
|
|
29
29
|
const image = (
|
|
30
30
|
<Image
|
|
@@ -1,32 +1,59 @@
|
|
|
1
1
|
import { Image } from '@magicstoreai/hydrogen';
|
|
2
2
|
import type { Schema } from '@magicstoreai/storefront-client';
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
+
import type { CSSProperties } from 'react';
|
|
4
5
|
|
|
6
|
+
import { Icon } from '@/components/ui/icon';
|
|
5
7
|
import { api } from '@/lib/api';
|
|
6
8
|
|
|
7
9
|
import type { SectionOf } from './types';
|
|
8
10
|
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Collection tiles linking to each collection; renders nothing for none. With `count`, each tile
|
|
13
|
+
* also says how many products the collection holds. When no collection has an image, the tiles
|
|
14
|
+
* are compact text cards rather than rows of empty placeholders.
|
|
15
|
+
*/
|
|
10
16
|
export function CollectionTiles({
|
|
11
17
|
collections,
|
|
12
18
|
className = '',
|
|
19
|
+
count,
|
|
20
|
+
columns,
|
|
13
21
|
}: {
|
|
14
22
|
collections: Schema<'Collection'>[];
|
|
15
23
|
className?: string;
|
|
24
|
+
count?: ((n: number) => string) | undefined;
|
|
25
|
+
/** Columns at desktop width; the theme's default (4) without it. */
|
|
26
|
+
columns?: number;
|
|
16
27
|
}) {
|
|
17
28
|
if (collections.length === 0) {
|
|
18
29
|
return null;
|
|
19
30
|
}
|
|
31
|
+
const media = collections.some((collection) => collection.image);
|
|
20
32
|
return (
|
|
21
|
-
<div
|
|
33
|
+
<div
|
|
34
|
+
className={`collection-grid ${media ? '' : 'collection-grid--text'} ${className}`}
|
|
35
|
+
style={columns ? ({ '--columns': columns } as CSSProperties) : undefined}
|
|
36
|
+
>
|
|
22
37
|
{collections.map((collection) => (
|
|
23
|
-
<Link
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
<Link
|
|
39
|
+
key={collection.id}
|
|
40
|
+
href={`/collections/${collection.handle}`}
|
|
41
|
+
className="collection-card"
|
|
42
|
+
>
|
|
43
|
+
{media && (
|
|
44
|
+
<span className="collection-card__media">
|
|
45
|
+
<Image
|
|
46
|
+
data={collection.image}
|
|
47
|
+
alt={collection.title}
|
|
48
|
+
fallback={<span className="media-fallback" />}
|
|
49
|
+
/>
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
<span className="collection-card__title">{collection.title}</span>
|
|
53
|
+
{count && (
|
|
54
|
+
<span className="collection-card__count">{count(collection.productsCount)}</span>
|
|
55
|
+
)}
|
|
56
|
+
{!media && <Icon name="chevron-right" className="collection-card__arrow" />}
|
|
30
57
|
</Link>
|
|
31
58
|
))}
|
|
32
59
|
</div>
|
|
@@ -56,11 +83,21 @@ export async function Collections({ section }: { section: SectionOf<'COLLECTIONS
|
|
|
56
83
|
/** Tiles the merchant picked by hand, each with an optional image of its own. */
|
|
57
84
|
export function CollectionGrid({ section }: { section: SectionOf<'COLLECTION_GRID'> }) {
|
|
58
85
|
return (
|
|
59
|
-
<div className="grid">
|
|
86
|
+
<div className="collection-grid">
|
|
60
87
|
{section.settings.tiles.map(({ collection, image }) => (
|
|
61
|
-
<Link
|
|
62
|
-
|
|
63
|
-
|
|
88
|
+
<Link
|
|
89
|
+
key={collection.id}
|
|
90
|
+
href={`/collections/${collection.handle}`}
|
|
91
|
+
className="collection-card"
|
|
92
|
+
>
|
|
93
|
+
<span className="collection-card__media">
|
|
94
|
+
<Image
|
|
95
|
+
data={image}
|
|
96
|
+
alt={collection.title}
|
|
97
|
+
fallback={<span className="media-fallback" />}
|
|
98
|
+
/>
|
|
99
|
+
</span>
|
|
100
|
+
<span className="collection-card__title">{collection.title}</span>
|
|
64
101
|
</Link>
|
|
65
102
|
))}
|
|
66
103
|
</div>
|
|
@@ -10,9 +10,9 @@ export async function DealOfDay({ section }: { section: SectionOf<'DEAL_OF_DAY'>
|
|
|
10
10
|
const { product, image, price, compareAtPrice, endsAt, accentColor, badgeText, stock } =
|
|
11
11
|
section.settings;
|
|
12
12
|
return (
|
|
13
|
-
<section className="deal" style={{ borderColor: accentColor ?? undefined }}>
|
|
13
|
+
<section className="deal-of-day" style={{ borderColor: accentColor ?? undefined }}>
|
|
14
14
|
<Link href={`/products/${product.handle}`}>
|
|
15
|
-
<Image data={image} alt={product.title} fallback={<div className="
|
|
15
|
+
<Image data={image} alt={product.title} fallback={<div className="media-fallback" />} />
|
|
16
16
|
</Link>
|
|
17
17
|
<div className="stack">
|
|
18
18
|
{badgeText && (
|
|
@@ -13,11 +13,15 @@ export function Shelf({
|
|
|
13
13
|
title,
|
|
14
14
|
href,
|
|
15
15
|
products,
|
|
16
|
+
slider = false,
|
|
16
17
|
children,
|
|
17
18
|
}: {
|
|
18
19
|
title: string;
|
|
19
|
-
|
|
20
|
+
/** "See all" goes here; none without it. */
|
|
21
|
+
href?: string | undefined;
|
|
20
22
|
products: Schema<'Product'>[];
|
|
23
|
+
/** One sideways-scrolling row instead of the grid. */
|
|
24
|
+
slider?: boolean;
|
|
21
25
|
children?: ReactNode;
|
|
22
26
|
}) {
|
|
23
27
|
// The server drops a section that would show nothing, but a shelf can empty between the two reads.
|
|
@@ -25,13 +29,17 @@ export function Shelf({
|
|
|
25
29
|
return null;
|
|
26
30
|
}
|
|
27
31
|
return (
|
|
28
|
-
<section
|
|
29
|
-
<div className="
|
|
32
|
+
<section>
|
|
33
|
+
<div className="section-header">
|
|
30
34
|
<h2>{title}</h2>
|
|
31
35
|
{children}
|
|
32
|
-
{href &&
|
|
36
|
+
{href && (
|
|
37
|
+
<Link href={href} className="section-header__link">
|
|
38
|
+
{messages(locale).seeAll}
|
|
39
|
+
</Link>
|
|
40
|
+
)}
|
|
33
41
|
</div>
|
|
34
|
-
<ProductGrid products={products} />
|
|
42
|
+
<ProductGrid products={products} locale={locale} eager={false} slider={slider} />
|
|
35
43
|
</section>
|
|
36
44
|
);
|
|
37
45
|
}
|
|
@@ -10,7 +10,7 @@ import type { SectionOf } from './types';
|
|
|
10
10
|
export function ShoppableStories({ section }: { section: SectionOf<'SHOPPABLE_STORIES'> }) {
|
|
11
11
|
const { layout, stories } = section.settings;
|
|
12
12
|
return (
|
|
13
|
-
<section className={`stories stories
|
|
13
|
+
<section className={`stories stories--${layout.toLowerCase()}`}>
|
|
14
14
|
{stories.map((story, index) => {
|
|
15
15
|
const product = story.frames.find((frame) => frame.product !== null)?.product ?? null;
|
|
16
16
|
const bubble = (
|
|
@@ -18,7 +18,7 @@ export function ShoppableStories({ section }: { section: SectionOf<'SHOPPABLE_ST
|
|
|
18
18
|
<Image
|
|
19
19
|
data={story.thumbnail}
|
|
20
20
|
alt={story.title ?? ''}
|
|
21
|
-
fallback={<div className="
|
|
21
|
+
fallback={<div className="media-fallback" />}
|
|
22
22
|
/>
|
|
23
23
|
{story.title && <span>{story.title}</span>}
|
|
24
24
|
</>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
2
|
|
|
3
|
+
import { Rating } from '@/components/ui/rating';
|
|
3
4
|
import { api, locale } from '@/lib/api';
|
|
4
5
|
import { messages } from '@/lib/i18n';
|
|
5
6
|
|
|
@@ -42,16 +43,21 @@ export async function StoreReviews({ section }: { section: SectionOf<'STORE_REVI
|
|
|
42
43
|
<ul className="muted">
|
|
43
44
|
{rating.breakdown.map(({ rating: stars, count }) => (
|
|
44
45
|
<li key={stars}>
|
|
45
|
-
{stars}
|
|
46
|
+
{t.starsFilter(stars)} — {count}
|
|
46
47
|
</li>
|
|
47
48
|
))}
|
|
48
49
|
</ul>
|
|
49
50
|
)}
|
|
50
51
|
|
|
51
|
-
<div className="grid">
|
|
52
|
+
<div className="review-grid">
|
|
52
53
|
{reviews.map((review) => (
|
|
53
|
-
<blockquote key={review.id} className="
|
|
54
|
-
<
|
|
54
|
+
<blockquote key={review.id} className="review-card">
|
|
55
|
+
<Rating
|
|
56
|
+
average={review.rating}
|
|
57
|
+
count={1}
|
|
58
|
+
label={t.ratedOutOf(review.rating)}
|
|
59
|
+
showCount={false}
|
|
60
|
+
/>
|
|
55
61
|
<p>{review.body}</p>
|
|
56
62
|
<footer className="muted">{review.author.name}</footer>
|
|
57
63
|
</blockquote>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Icon } from '@/components/ui/icon';
|
|
7
|
+
import { messages } from '@/lib/i18n';
|
|
8
|
+
|
|
9
|
+
/** Shares the page with the system sheet where there is one, else copies its link. */
|
|
10
|
+
export function ShareButton({ title }: { title: string }) {
|
|
11
|
+
const t = messages(useMagicStore().locale);
|
|
12
|
+
const [copied, setCopied] = useState(false);
|
|
13
|
+
|
|
14
|
+
async function share() {
|
|
15
|
+
const url = window.location.href;
|
|
16
|
+
if (typeof navigator.share === 'function') {
|
|
17
|
+
try {
|
|
18
|
+
await navigator.share({ title, url });
|
|
19
|
+
return;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// The buyer closed the sheet: nothing to do. Anything else falls back to copying.
|
|
22
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
await navigator.clipboard.writeText(url);
|
|
28
|
+
setCopied(true);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="share">
|
|
33
|
+
<button type="button" className="button button--link share__button" onClick={share}>
|
|
34
|
+
<Icon name="share" size={18} />
|
|
35
|
+
{t.share}
|
|
36
|
+
</button>
|
|
37
|
+
<span className="share__status" role="status">
|
|
38
|
+
{copied ? t.linkCopied : ''}
|
|
39
|
+
</span>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Icon } from './icon';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A collapsible row: a native `<details>`, so it opens without JavaScript, by keyboard, and with
|
|
7
|
+
* the browser's find-in-page. Rows sharing a `name` close each other.
|
|
8
|
+
*/
|
|
9
|
+
export function Accordion({
|
|
10
|
+
title,
|
|
11
|
+
open = false,
|
|
12
|
+
name,
|
|
13
|
+
className,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
title: ReactNode;
|
|
17
|
+
open?: boolean;
|
|
18
|
+
name?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<details
|
|
24
|
+
className={className ? `accordion ${className}` : 'accordion'}
|
|
25
|
+
open={open || undefined}
|
|
26
|
+
name={name}
|
|
27
|
+
>
|
|
28
|
+
<summary className="accordion__summary">
|
|
29
|
+
<span>{title}</span>
|
|
30
|
+
<Icon name="chevron-down" className="accordion__icon" />
|
|
31
|
+
</summary>
|
|
32
|
+
<div className="accordion__content">{children}</div>
|
|
33
|
+
</details>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type BadgeKind = 'sale' | 'soldOut' | 'new' | 'preOrder' | 'neutral';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A short label on the merchant's label colors (`--label-bg` / `--label-fg`). Only ever for a fact
|
|
7
|
+
* the API sent: a compare-at price, `availableForSale: false`, `isNew`, `preOrder`. Sold out is
|
|
8
|
+
* neutral, so it never reads as a promotion.
|
|
9
|
+
*/
|
|
10
|
+
export function Badge({ kind = 'neutral', children }: { kind?: BadgeKind; children: ReactNode }) {
|
|
11
|
+
return <span className={`badge badge--${KIND_CLASS[kind]}`}>{children}</span>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const KIND_CLASS: Record<BadgeKind, string> = {
|
|
15
|
+
sale: 'sale',
|
|
16
|
+
soldOut: 'sold-out',
|
|
17
|
+
new: 'new',
|
|
18
|
+
preOrder: 'pre-order',
|
|
19
|
+
neutral: 'neutral',
|
|
20
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
|
|
3
|
+
export type Crumb = { name: string; href?: string };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the page sits: links to its parents, the page itself last (`aria-current`). Visual only:
|
|
7
|
+
* the page adds `breadcrumbJsonLd` from `@magicstoreai/hydrogen/seo` for search engines.
|
|
8
|
+
*/
|
|
9
|
+
export function Breadcrumbs({ items, label }: { items: Crumb[]; label: string }) {
|
|
10
|
+
if (items.length < 2) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return (
|
|
14
|
+
<nav className="breadcrumbs" aria-label={label}>
|
|
15
|
+
<ol className="breadcrumbs__list">
|
|
16
|
+
{items.map((item, index) => {
|
|
17
|
+
const last = index === items.length - 1;
|
|
18
|
+
return (
|
|
19
|
+
<li key={`${index}-${item.name}`} className="breadcrumbs__item">
|
|
20
|
+
{last || item.href === undefined ? (
|
|
21
|
+
<span aria-current={last ? 'page' : undefined}>{item.name}</span>
|
|
22
|
+
) : (
|
|
23
|
+
<Link href={item.href}>{item.name}</Link>
|
|
24
|
+
)}
|
|
25
|
+
</li>
|
|
26
|
+
);
|
|
27
|
+
})}
|
|
28
|
+
</ol>
|
|
29
|
+
</nav>
|
|
30
|
+
);
|
|
31
|
+
}
|