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
@@ -1,253 +1,21 @@
1
1
  'use client';
2
2
 
3
- import {
4
- Money,
5
- useCustomer,
6
- useMagicStore,
7
- useStorefrontClient,
8
- useTelegramSignIn,
9
- useTelegramWebApp,
10
- useWishlist,
11
- } from '@magicstoreai/hydrogen';
12
- import type { Schema } from '@magicstoreai/storefront-client';
13
- import { useEffect, useState } from 'react';
14
- import { describe, describePhoneShare } from '@/lib/errors';
3
+ import { useMagicStore } from '@magicstoreai/hydrogen';
4
+ import { AccountShell } from '@/components/account/account-shell';
5
+ import { OrderList } from '@/components/account/order-list';
15
6
  import { messages } from '@/lib/i18n';
16
7
 
8
+ /** The account's front page: the customer's orders. Signed out, the sign-in card. */
17
9
  export default function AccountPage() {
18
- const { customer, isSignedIn, signOut } = useCustomer();
19
- const { status, isTelegram, canRequestContact } = useTelegramWebApp();
20
- const { locale, shop } = useMagicStore();
21
- const t = messages(locale);
22
- // Until the app has looked (`unknown`, server render included), a shop with a bot may be open in
23
- // Telegram: hold the Telegram block's place so the phone form does not jump down after mount.
24
- const maybeTelegram = status === 'unknown' && shop?.features.telegramBot === true;
25
- return isSignedIn && customer ? (
26
- <div className="stack">
27
- <h1>{customer.name || customer.phone}</h1>
28
- {/* A customer who only ever signed in through Telegram has no phone yet; older Telegram
29
- clients cannot share it. */}
30
- {canRequestContact && !customer.phone && <SharePhone />}
31
- <Orders />
32
- <SavedCount />
33
- <div>
34
- <button onClick={() => void signOut()}>{t.signOut}</button>
35
- </div>
36
- </div>
37
- ) : (
38
- <div className="stack" style={{ maxWidth: 360 }}>
39
- <h1>{t.signIn}</h1>
40
- {isTelegram && <TelegramBlock />}
41
- {maybeTelegram && <TelegramBlock placeholder />}
42
- <PhoneSignIn />
43
- </div>
44
- );
45
- }
46
-
47
- /**
48
- * The Telegram sign-in above the phone form. As a `placeholder` it renders the same markup, hidden,
49
- * so the reserved space always matches the real block.
50
- */
51
- function TelegramBlock({ placeholder = false }: { placeholder?: boolean }) {
52
- const t = messages(useMagicStore().locale);
53
- return (
54
- <div
55
- className="stack"
56
- aria-hidden={placeholder || undefined}
57
- style={placeholder ? { visibility: 'hidden' } : undefined}
58
- >
59
- <TelegramSignIn />
60
- <p className="muted">{t.orWithPhone}</p>
61
- </div>
62
- );
63
- }
64
-
65
- /**
66
- * Inside a Mini App: sign in with the launch data. The automatic sign-in runs in
67
- * components/telegram-shell.tsx; this is the manual retry. The hook shares the attempt's state, so
68
- * this shows the automatic attempt too: "Signing in…" while it runs, the relaunch hint when it failed.
69
- */
70
- function TelegramSignIn() {
71
- const { status, error, signIn } = useTelegramSignIn({ auto: false });
72
- const t = messages(useMagicStore().locale);
73
- // FORBIDDEN: the shop has no bot, so retrying is pointless.
74
- const unavailable = error?.code === 'FORBIDDEN';
75
- return (
76
- <div className="stack">
77
- {!unavailable && (
78
- <button
79
- className="primary"
80
- disabled={status === 'signing-in'}
81
- onClick={() => void signIn()}
82
- >
83
- {status === 'signing-in' ? t.signingIn : t.signInWithTelegram}
84
- </button>
85
- )}
86
- {status === 'failed' && (
87
- <p className="error" role="alert">
88
- {error?.code === 'UNAUTHENTICATED'
89
- ? t.telegramRelaunch
90
- : unavailable
91
- ? t.telegramUnavailable
92
- : describe(error, t.tryAgainLater)}
93
- </p>
94
- )}
95
- </div>
96
- );
97
- }
98
-
99
- /**
100
- * Phone sign-in: a code by SMS, or — on a shop whose sign-in code is off
101
- * (`shop.features.otpLogin === false`) — the phone alone.
102
- */
103
- function PhoneSignIn() {
104
- const { requestOtp, verifyOtp, signInWithPhone } = useCustomer();
105
- const { locale, shop } = useMagicStore();
106
- const t = messages(locale);
107
- const withoutCode = shop?.features.otpLogin === false;
108
- const [phone, setPhone] = useState('');
109
- const [sent, setSent] = useState(false);
110
- const [code, setCode] = useState('');
111
- const [error, setError] = useState<string | null>(null);
112
-
113
- async function attempt(step: () => Promise<unknown>) {
114
- setError(null);
115
- try {
116
- await step();
117
- } catch (e) {
118
- setError(describe(e, t.tryAgainLater));
119
- }
120
- }
121
-
122
- return (
123
- <form
124
- className="stack"
125
- onSubmit={(event) => {
126
- event.preventDefault();
127
- void attempt(async () => {
128
- if (withoutCode) {
129
- await signInWithPhone(phone);
130
- } else if (!sent) {
131
- await requestOtp(phone);
132
- setSent(true);
133
- } else {
134
- await verifyOtp(phone, code);
135
- }
136
- });
137
- }}
138
- >
139
- <input
140
- value={phone}
141
- onChange={(e) => setPhone(e.target.value)}
142
- placeholder="+998 90 123 45 67"
143
- aria-label={t.phone}
144
- type="tel"
145
- autoComplete="tel"
146
- required
147
- disabled={sent}
148
- />
149
- {sent && (
150
- <input
151
- value={code}
152
- onChange={(e) => setCode(e.target.value)}
153
- placeholder={t.smsCode}
154
- aria-label={t.smsCode}
155
- inputMode="numeric"
156
- autoComplete="one-time-code"
157
- required
158
- />
159
- )}
160
- <button className="primary">{sent || withoutCode ? t.signIn : t.sendCode}</button>
161
- {error && (
162
- <p className="error" role="alert">
163
- {error}
164
- </p>
165
- )}
166
- </form>
167
- );
168
- }
169
-
170
- /**
171
- * Signed in inside a Mini App without a phone: share the Telegram contact, which proves the phone.
172
- * A declined request changes nothing.
173
- */
174
- function SharePhone() {
175
- const { shareTelegramPhone } = useCustomer();
176
- const t = messages(useMagicStore().locale);
177
- const [busy, setBusy] = useState(false);
178
- const [error, setError] = useState<string | null>(null);
179
-
180
- async function share() {
181
- setBusy(true);
182
- setError(null);
183
- try {
184
- await shareTelegramPhone();
185
- } catch (e) {
186
- setError(describePhoneShare(e, t));
187
- } finally {
188
- setBusy(false);
189
- }
190
- }
191
-
192
- return (
193
- <section className="stack">
194
- <p className="muted">{t.sharePhoneHint}</p>
195
- <div>
196
- <button className="primary" disabled={busy} onClick={() => void share()}>
197
- {t.sharePhone}
198
- </button>
199
- </div>
200
- {error && (
201
- <p className="error" role="alert">
202
- {error}
203
- </p>
204
- )}
205
- </section>
206
- );
207
- }
208
-
209
- function Orders() {
210
- const client = useStorefrontClient();
211
- const [orders, setOrders] = useState<Schema<'Order'>[] | null>(null);
212
- const t = messages(useMagicStore().locale);
213
-
214
- useEffect(() => {
215
- void client.customerOrdersIndex({ query: { perPage: 20 } }).then(
216
- ({ data }) => setOrders(data),
217
- () => setOrders([]),
218
- );
219
- }, [client]);
220
-
221
- if (orders === null) {
222
- return <p className="muted">{t.loadingOrders}</p>;
223
- }
224
- return (
225
- <section className="stack">
226
- <h2>{t.orders}</h2>
227
- {orders.length === 0 && <p className="muted">{t.noOrders}</p>}
228
- <table>
229
- <tbody>
230
- {orders.map((order) => (
231
- <tr key={order.id}>
232
- <td>#{order.number}</td>
233
- <td>{t.orderStatus[order.status]}</td>
234
- <td>
235
- <Money data={(order.cost as { total?: Schema<'Money'> | null }).total ?? null} />
236
- </td>
237
- </tr>
238
- ))}
239
- </tbody>
240
- </table>
241
- </section>
242
- );
243
- }
244
-
245
- function SavedCount() {
246
- const { productIds } = useWishlist();
247
10
  const t = messages(useMagicStore().locale);
248
11
  return (
249
- <p className="muted">
250
- {t.savedProducts}: {productIds.length}
251
- </p>
12
+ <AccountShell>
13
+ <section aria-labelledby="account-orders">
14
+ <h2 id="account-orders" className="account__section-title">
15
+ {t.orders}
16
+ </h2>
17
+ <OrderList />
18
+ </section>
19
+ </AccountShell>
252
20
  );
253
21
  }
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+
3
+ import { useMagicStore } from '@magicstoreai/hydrogen';
4
+ import { AccountShell } from '@/components/account/account-shell';
5
+ import { Profile } from '@/components/account/profile';
6
+ import { messages } from '@/lib/i18n';
7
+
8
+ export default function ProfilePage() {
9
+ const t = messages(useMagicStore().locale);
10
+ return (
11
+ <AccountShell>
12
+ <h2 className="account__section-title">{t.profile}</h2>
13
+ <Profile />
14
+ </AccountShell>
15
+ );
16
+ }
@@ -0,0 +1,22 @@
1
+ import { PageLoading } from '@/components/page-loading';
2
+ import { Skeleton, TitleSkeleton } from '@/components/ui/skeleton';
3
+
4
+ export default function BlogLoading() {
5
+ return (
6
+ <PageLoading>
7
+ <div className="blog">
8
+ <TitleSkeleton width="25%" />
9
+ <Skeleton height="2.75rem" width="60%" />
10
+ <div className="article-grid">
11
+ {[0, 1, 2].map((index) => (
12
+ <div key={index} className="stack">
13
+ <Skeleton ratio="16 / 9" />
14
+ <Skeleton height="1.25rem" width="80%" />
15
+ <Skeleton height="1rem" width="50%" />
16
+ </div>
17
+ ))}
18
+ </div>
19
+ </div>
20
+ </PageLoading>
21
+ );
22
+ }
@@ -1,8 +1,9 @@
1
1
  import type { Metadata } from 'next';
2
2
  import Link from 'next/link';
3
3
  import { breadcrumbJsonLd, jsonLdScript, pageMeta } from '@magicstoreai/hydrogen/seo';
4
- import { ArticleGrid } from '@/components/article-card';
4
+ import { ArticleCard, ArticleGrid } from '@/components/article-card';
5
5
  import { ArticleListing, CategoryNav, pageParam, sortParam } from '@/components/blog-listing';
6
+ import { Icon } from '@/components/ui/icon';
6
7
  import { api, locale } from '@/lib/api';
7
8
  import { messages } from '@/lib/i18n';
8
9
 
@@ -45,7 +46,7 @@ export default async function BlogPage({ searchParams }: Props) {
45
46
  };
46
47
 
47
48
  return (
48
- <div className="stack">
49
+ <div className="blog">
49
50
  <script
50
51
  type="application/ld+json"
51
52
  dangerouslySetInnerHTML={{
@@ -57,31 +58,47 @@ export default async function BlogPage({ searchParams }: Props) {
57
58
  ),
58
59
  }}
59
60
  />
60
- <h1>{shop.blog?.title || t.blog}</h1>
61
- {shop.blog?.description && <p className="muted">{shop.blog.description}</p>}
61
+ <header className="blog-header">
62
+ <h1>{shop.blog?.title || t.blog}</h1>
63
+ {shop.blog?.description && <p className="muted">{shop.blog.description}</p>}
64
+ </header>
62
65
  <CategoryNav />
63
- <form className="row" action="/blog">
64
- <input name="q" defaultValue={q} placeholder={t.searchArticles} maxLength={200} />
65
- <button className="primary">{t.search}</button>
66
- </form>
67
- <nav className="row chips" aria-label={t.sortNewest}>
68
- <Link
69
- href={q ? `/blog?q=${encodeURIComponent(q)}` : '/blog'}
70
- aria-current={!sort ? 'page' : undefined}
71
- >
72
- {t.sortNewest}
73
- </Link>
74
- <Link
75
- href={`/blog?${new URLSearchParams({ ...(q && { q }), sort: '-recentViews' })}`}
76
- aria-current={sort === '-recentViews' ? 'page' : undefined}
77
- >
78
- {t.sortPopular}
79
- </Link>
80
- </nav>
66
+ <div className="blog-toolbar">
67
+ <form role="search" action="/blog" className="search-form blog-toolbar__search">
68
+ <Icon name="search" className="search-form__icon" />
69
+ <input
70
+ type="search"
71
+ name="q"
72
+ defaultValue={q}
73
+ placeholder={t.searchArticles}
74
+ aria-label={t.searchArticles}
75
+ maxLength={200}
76
+ />
77
+ <button type="submit" className="button">
78
+ {t.search}
79
+ </button>
80
+ </form>
81
+ <nav className="chips" aria-label={t.sortBy}>
82
+ <Link
83
+ href={q ? `/blog?q=${encodeURIComponent(q)}` : '/blog'}
84
+ aria-current={!sort ? 'page' : undefined}
85
+ >
86
+ {t.sortNewest}
87
+ </Link>
88
+ <Link
89
+ href={`/blog?${new URLSearchParams({ ...(q && { q }), sort: '-recentViews' })}`}
90
+ aria-current={sort === '-recentViews' ? 'page' : undefined}
91
+ >
92
+ {t.sortPopular}
93
+ </Link>
94
+ </nav>
95
+ </div>
81
96
  {featured.length > 0 && (
82
- <section className="stack">
83
- <h2>{t.featured}</h2>
84
- <ArticleGrid articles={featured} timeZone={shop.timezone} />
97
+ <section className="blog-featured" aria-label={t.featured}>
98
+ <ArticleCard article={featured[0]!} timeZone={shop.timezone} feature />
99
+ {featured.length > 1 && (
100
+ <ArticleGrid articles={featured.slice(1)} timeZone={shop.timezone} />
101
+ )}
85
102
  </section>
86
103
  )}
87
104
  {/* The picks are not repeated below; a blog of only picks shows no second list. */}
@@ -12,12 +12,18 @@ import { ArticleView } from '@/components/analytics-views';
12
12
  import { ArticleBody } from '@/components/article-body';
13
13
  import { ArticleDate, ArticleGrid } from '@/components/article-card';
14
14
  import { ArticleVote } from '@/components/article-vote';
15
+ import { ShareButton } from '@/components/share-button';
16
+ import { Breadcrumbs } from '@/components/ui/breadcrumbs';
15
17
  import { api, locale, orNotFound } from '@/lib/api';
18
+ import { outlineArticle } from '@/lib/article-outline';
16
19
  import { messages } from '@/lib/i18n';
17
20
  import { siteOrigin } from '@/lib/site';
18
21
 
19
22
  type Props = { params: Promise<{ handle: string }> };
20
23
 
24
+ /** A table of contents from this many headings up; a short article reads without one. */
25
+ const TOC_MIN_HEADINGS = 3;
26
+
21
27
  /**
22
28
  * An article is served only in the languages it is written in: in any other one the API answers
23
29
  * 404, and so does this page.
@@ -51,9 +57,20 @@ export default async function ArticlePage({ params }: Props) {
51
57
  );
52
58
  const url = `${siteOrigin(shop)}/blog/${found.handle}`;
53
59
  const blogTitle = shop.blog?.title || t.blog;
60
+ const crumbs = [
61
+ { name: t.home, href: '/' },
62
+ { name: blogTitle, href: '/blog' },
63
+ ...(found.category
64
+ ? [{ name: found.category.title, href: `/blog/category/${found.category.handle}` }]
65
+ : []),
66
+ { name: found.title, href: `/blog/${found.handle}` },
67
+ ];
68
+ // Anchors for the table of contents, shown only for a body long enough to need one.
69
+ const body = outlineArticle(found.bodyHtml);
70
+ const toc = body.headings.length >= TOC_MIN_HEADINGS ? body.headings : [];
54
71
 
55
72
  return (
56
- <article className="stack article">
73
+ <article className="article">
57
74
  <script
58
75
  type="application/ld+json"
59
76
  dangerouslySetInnerHTML={{
@@ -64,70 +81,89 @@ export default async function ArticlePage({ params }: Props) {
64
81
  type="application/ld+json"
65
82
  dangerouslySetInnerHTML={{
66
83
  __html: jsonLdScript(
67
- breadcrumbJsonLd([
68
- { name: t.home, url: '/' },
69
- { name: blogTitle, url: '/blog' },
70
- ...(found.category
71
- ? [{ name: found.category.title, url: `/blog/category/${found.category.handle}` }]
72
- : []),
73
- { name: found.title, url: `/blog/${found.handle}` },
74
- ]),
84
+ breadcrumbJsonLd(crumbs.map((crumb) => ({ name: crumb.name, url: crumb.href }))),
75
85
  ),
76
86
  }}
77
87
  />
78
88
  <ArticleView articleId={found.id} />
79
- <nav className="row muted" aria-label={blogTitle}>
80
- <Link href="/blog">{blogTitle}</Link>
89
+ <Breadcrumbs items={crumbs} label={t.breadcrumbs} />
90
+ <header className="article__header">
81
91
  {found.category && (
82
- <>
83
- <span aria-hidden="true">/</span>
84
- <Link href={`/blog/category/${found.category.handle}`}>{found.category.title}</Link>
85
- </>
86
- )}
87
- </nav>
88
- <h1>{found.h1 ?? found.title}</h1>
89
- <p className="row muted">
90
- {found.author ? (
91
- <Link href={`/blog/author/${found.author.handle}`}>{found.authorName}</Link>
92
- ) : (
93
- found.authorName && <span>{found.authorName}</span>
92
+ <Link href={`/blog/category/${found.category.handle}`} className="article__category">
93
+ {found.category.title}
94
+ </Link>
94
95
  )}
95
- <ArticleDate iso={found.publishedAt} timeZone={shop.timezone} />
96
- <span>{t.readingTime(found.readingMinutes)}</span>
97
- </p>
96
+ <h1 className="article__title">{found.h1 ?? found.title}</h1>
97
+ <p className="article__meta">
98
+ {found.author ? (
99
+ <Link href={`/blog/author/${found.author.handle}`}>{found.authorName}</Link>
100
+ ) : (
101
+ found.authorName && <span>{found.authorName}</span>
102
+ )}
103
+ <ArticleDate iso={found.publishedAt} timeZone={shop.timezone} />
104
+ <span>{t.readingTime(found.readingMinutes)}</span>
105
+ </p>
106
+ </header>
98
107
  {/* The cover is the page's largest image: loaded at once, not lazily. */}
99
- <Image data={found.image} alt={found.title} loading="eager" className="article-cover" />
100
- <div className="prose">
101
- <ArticleBody
102
- bodyHtml={found.bodyHtml}
103
- products={new Map(products.map((product) => [product.handle, product]))}
104
- />
105
- </div>
106
- {found.tags.length > 0 && (
107
- <nav className="row chips" aria-label={t.tag}>
108
- {found.tags.map((tag) => (
109
- <Link key={tag.handle} href={`/blog/tag/${tag.handle}`}>
110
- #{tag.title}
111
- </Link>
112
- ))}
113
- </nav>
114
- )}
115
- {found.author && (
116
- <aside className="row author">
117
- <Image data={found.author.avatar} alt={found.author.name} width={64} height={64} />
118
- <div className="stack">
119
- <Link href={`/blog/author/${found.author.handle}`}>
120
- <strong>{found.author.name}</strong>
121
- </Link>
122
- {found.author.role && <span className="muted">{found.author.role}</span>}
123
- {found.authorBio && <p>{found.authorBio}</p>}
108
+ <Image
109
+ data={found.image}
110
+ alt={found.title}
111
+ loading="eager"
112
+ fetchPriority="high"
113
+ className="article__cover"
114
+ />
115
+ <div className={toc.length > 0 ? 'article__layout article__layout--toc' : 'article__layout'}>
116
+ {toc.length > 0 && (
117
+ <nav className="article__toc" aria-label={t.tableOfContents}>
118
+ <p className="article__toc-title">{t.tableOfContents}</p>
119
+ <ol>
120
+ {toc.map((heading) => (
121
+ <li key={heading.id} className={`article__toc-item--h${heading.level}`}>
122
+ <a href={`#${heading.id}`}>{heading.text}</a>
123
+ </li>
124
+ ))}
125
+ </ol>
126
+ </nav>
127
+ )}
128
+ <div className="article__main">
129
+ <div className="prose">
130
+ <ArticleBody
131
+ bodyHtml={body.html}
132
+ products={new Map(products.map((product) => [product.handle, product]))}
133
+ />
124
134
  </div>
125
- </aside>
126
- )}
127
- <ArticleVote handle={found.handle} helpfulCount={found.helpfulCount} />
135
+ {found.tags.length > 0 && (
136
+ <nav className="chips article__tags" aria-label={t.tag}>
137
+ {found.tags.map((tag) => (
138
+ <Link key={tag.handle} href={`/blog/tag/${tag.handle}`}>
139
+ #{tag.title}
140
+ </Link>
141
+ ))}
142
+ </nav>
143
+ )}
144
+ <div className="article__actions">
145
+ <ArticleVote handle={found.handle} helpfulCount={found.helpfulCount} />
146
+ <ShareButton title={found.title} />
147
+ </div>
148
+ {found.author && (
149
+ <aside className="author-card">
150
+ <Image data={found.author.avatar} alt={found.author.name} width={64} height={64} />
151
+ <div className="stack">
152
+ <Link href={`/blog/author/${found.author.handle}`}>
153
+ <strong>{found.author.name}</strong>
154
+ </Link>
155
+ {found.author.role && <span className="muted">{found.author.role}</span>}
156
+ {found.authorBio && <p>{found.authorBio}</p>}
157
+ </div>
158
+ </aside>
159
+ )}
160
+ </div>
161
+ </div>
128
162
  {related.data.length > 0 && (
129
- <section className="stack">
130
- <h2>{t.relatedArticles}</h2>
163
+ <section className="article__related">
164
+ <div className="section-header">
165
+ <h2>{t.relatedArticles}</h2>
166
+ </div>
131
167
  <ArticleGrid articles={related.data} timeZone={shop.timezone} />
132
168
  </section>
133
169
  )}
@@ -27,7 +27,7 @@ export default async function BlogAuthorPage({ params, searchParams }: Props) {
27
27
 
28
28
  return (
29
29
  <div className="stack">
30
- <header className="row author">
30
+ <header className="author-card">
31
31
  <Image data={found.avatar} alt={found.name} width={64} height={64} />
32
32
  <div>
33
33
  <h1>{found.name}</h1>
@@ -0,0 +1,14 @@
1
+ import type { Metadata } from 'next';
2
+ import type { ReactNode } from 'react';
3
+ import { locale } from '@/lib/api';
4
+ import { messages } from '@/lib/i18n';
5
+
6
+ // The cart page is a client component; its title and robots come from here. Personal: not indexed.
7
+ export const metadata: Metadata = {
8
+ title: messages(locale).cart,
9
+ robots: { index: false, follow: false },
10
+ };
11
+
12
+ export default function CartLayout({ children }: { children: ReactNode }) {
13
+ return children;
14
+ }