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.
Files changed (139) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/AGENTS.md +32 -16
  4. package/template/DESIGN.md +117 -0
  5. package/template/PAGES.md +107 -29
  6. package/template/README.md +15 -3
  7. package/template/app/account/addresses/page.tsx +16 -0
  8. package/template/app/account/layout.tsx +14 -0
  9. package/template/app/account/loading.tsx +15 -0
  10. package/template/app/account/orders/[id]/page.tsx +15 -0
  11. package/template/app/account/page.tsx +12 -244
  12. package/template/app/account/profile/page.tsx +16 -0
  13. package/template/app/blog/(index)/loading.tsx +22 -0
  14. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  15. package/template/app/blog/[handle]/page.tsx +92 -56
  16. package/template/app/blog/author/[handle]/page.tsx +1 -1
  17. package/template/app/cart/layout.tsx +14 -0
  18. package/template/app/cart/page.tsx +49 -101
  19. package/template/app/checkout/layout.tsx +14 -0
  20. package/template/app/checkout/page.tsx +98 -281
  21. package/template/app/collections/[handle]/layout.tsx +20 -0
  22. package/template/app/collections/[handle]/loading.tsx +23 -0
  23. package/template/app/collections/[handle]/page.tsx +131 -15
  24. package/template/app/collections/page.tsx +49 -0
  25. package/template/app/contacts/page.tsx +196 -0
  26. package/template/app/error.tsx +19 -8
  27. package/template/app/fonts/OFL.txt +93 -0
  28. package/template/app/fonts/index.ts +14 -0
  29. package/template/app/fonts/onest-variable.woff2 +0 -0
  30. package/template/app/globals.css +14 -518
  31. package/template/app/layout.tsx +73 -50
  32. package/template/app/not-found.tsx +34 -4
  33. package/template/app/page.tsx +1 -1
  34. package/template/app/pages/[handle]/page.tsx +1 -1
  35. package/template/app/products/[handle]/layout.tsx +20 -0
  36. package/template/app/products/[handle]/loading.tsx +25 -0
  37. package/template/app/products/[handle]/page.tsx +112 -15
  38. package/template/app/providers.tsx +5 -1
  39. package/template/app/search/loading.tsx +12 -0
  40. package/template/app/search/page.tsx +138 -25
  41. package/template/app/sitemap.ts +5 -1
  42. package/template/app/styles/base.css +138 -0
  43. package/template/app/styles/components.css +1108 -0
  44. package/template/app/styles/layout.css +455 -0
  45. package/template/app/styles/pages.css +1839 -0
  46. package/template/app/styles/reset.css +55 -0
  47. package/template/app/styles/sections.css +415 -0
  48. package/template/app/styles/tokens.css +167 -0
  49. package/template/app/styles/utilities.css +37 -0
  50. package/template/app/wishlist/page.tsx +26 -0
  51. package/template/components/account/account-shell.tsx +100 -0
  52. package/template/components/account/addresses.tsx +247 -0
  53. package/template/components/account/order-detail.tsx +281 -0
  54. package/template/components/account/order-list.tsx +94 -0
  55. package/template/components/account/order-status.tsx +19 -0
  56. package/template/components/account/profile.tsx +129 -0
  57. package/template/components/account/sign-in.tsx +164 -0
  58. package/template/components/article-body.tsx +3 -2
  59. package/template/components/article-card.tsx +37 -12
  60. package/template/components/cart/cart-drawer.tsx +149 -0
  61. package/template/components/cart/cart-lines.tsx +89 -0
  62. package/template/components/cart/cart-summary.tsx +191 -0
  63. package/template/components/catalog/catalog-form.tsx +107 -0
  64. package/template/components/catalog/catalog-layout.tsx +82 -0
  65. package/template/components/catalog/facets-shell.tsx +60 -0
  66. package/template/components/catalog/facets.tsx +216 -0
  67. package/template/components/checkout/order-summary.tsx +84 -0
  68. package/template/components/checkout/radio-card.tsx +34 -0
  69. package/template/components/checkout/step-indicator.tsx +56 -0
  70. package/template/components/checkout/steps.tsx +264 -0
  71. package/template/components/checkout/thank-you.tsx +48 -0
  72. package/template/components/checkout/use-checkout.ts +90 -0
  73. package/template/components/footer/site-footer.tsx +132 -0
  74. package/template/components/header/cart-button.tsx +43 -0
  75. package/template/components/header/desktop-nav.tsx +133 -0
  76. package/template/components/header/mobile-menu.tsx +102 -0
  77. package/template/components/header/site-header.tsx +78 -0
  78. package/template/components/page-loading.tsx +20 -0
  79. package/template/components/pager.tsx +3 -1
  80. package/template/components/product/buy-box.tsx +148 -0
  81. package/template/components/product/gallery.tsx +179 -0
  82. package/template/components/product/pre-order-form.tsx +93 -0
  83. package/template/components/product/product-selection.tsx +31 -0
  84. package/template/components/product/recommendations.tsx +26 -0
  85. package/template/components/product/review-list.tsx +159 -0
  86. package/template/components/product/reviews.tsx +80 -0
  87. package/template/components/product/variant-picker.tsx +75 -0
  88. package/template/components/product-card.tsx +103 -0
  89. package/template/components/product-grid.tsx +27 -57
  90. package/template/components/quick-add.tsx +62 -0
  91. package/template/components/search/predictive-search.tsx +282 -0
  92. package/template/components/sections/announcement-bar.tsx +1 -1
  93. package/template/components/sections/banner.tsx +1 -1
  94. package/template/components/sections/collections.tsx +50 -13
  95. package/template/components/sections/deal-of-day.tsx +2 -2
  96. package/template/components/sections/product-shelves.tsx +13 -5
  97. package/template/components/sections/shoppable-stories.tsx +2 -2
  98. package/template/components/sections/store-reviews.tsx +10 -4
  99. package/template/components/share-button.tsx +42 -0
  100. package/template/components/ui/accordion.tsx +35 -0
  101. package/template/components/ui/badge.tsx +20 -0
  102. package/template/components/ui/breadcrumbs.tsx +31 -0
  103. package/template/components/ui/button.tsx +90 -0
  104. package/template/components/ui/drawer.tsx +84 -0
  105. package/template/components/ui/empty-state.tsx +27 -0
  106. package/template/components/ui/field.tsx +33 -0
  107. package/template/components/ui/icon.tsx +112 -0
  108. package/template/components/ui/price.tsx +35 -0
  109. package/template/components/ui/quantity-stepper.tsx +91 -0
  110. package/template/components/ui/rating.tsx +48 -0
  111. package/template/components/ui/skeleton.tsx +46 -0
  112. package/template/components/wishlist/wishlist-view.tsx +134 -0
  113. package/template/lib/article-outline.ts +35 -0
  114. package/template/lib/catalog-params.ts +170 -0
  115. package/template/lib/contacts.ts +34 -0
  116. package/template/lib/errors.ts +29 -0
  117. package/template/lib/i18n.ts +484 -0
  118. package/template/lib/menu.ts +70 -0
  119. package/template/lib/theme-settings.ts +36 -0
  120. package/template/theme/index.ts +7 -1
  121. package/template/theme/sections/collapsible-content.tsx +75 -0
  122. package/template/theme/sections/collection-list.tsx +32 -4
  123. package/template/theme/sections/featured-collection.tsx +22 -2
  124. package/template/theme/sections/hero.tsx +94 -7
  125. package/template/theme/sections/image-with-text.tsx +19 -3
  126. package/template/theme/sections/multicolumn.tsx +113 -0
  127. package/template/theme/sections/page-content.tsx +10 -4
  128. package/template/theme/sections/product-shelf.tsx +26 -4
  129. package/template/theme/sections/rich-text.tsx +13 -1
  130. package/template/theme/sections/testimonials.tsx +14 -9
  131. package/template/theme/settings.ts +85 -1
  132. package/template/theme/templates/index.json +4 -2
  133. package/template/.screenshots/home-1280.png +0 -0
  134. package/template/.screenshots/home-390.png +0 -0
  135. package/template/.screenshots/home-filled-1280.png +0 -0
  136. package/template/.screenshots/home-filled-390.png +0 -0
  137. package/template/.screenshots/page-1280.png +0 -0
  138. package/template/components/buy-box.tsx +0 -89
  139. 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-${animation.toLowerCase()}`}
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
- /** Collection tiles linking to each collection; renders nothing for none. */
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 className={`grid ${className}`}>
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 key={collection.id} href={`/collections/${collection.handle}`} className="card">
24
- <Image
25
- data={collection.image}
26
- alt={collection.title}
27
- fallback={<div className="noimg" />}
28
- />
29
- <span>{collection.title}</span>
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 key={collection.id} href={`/collections/${collection.handle}`} className="card">
62
- <Image data={image} alt={collection.title} fallback={<div className="noimg" />} />
63
- <span>{collection.title}</span>
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="noimg" />} />
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
- href?: string;
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 className="stack">
29
- <div className="row">
32
+ <section>
33
+ <div className="section-header">
30
34
  <h2>{title}</h2>
31
35
  {children}
32
- {href && <Link href={href}>{messages(locale).seeAll}</Link>}
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-${layout.toLowerCase()}`}>
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="noimg" />}
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}★ — {count}
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="stack">
54
- <span>{'★'.repeat(review.rating)}</span>
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
+ }