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
|
@@ -1,253 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
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="
|
|
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
|
-
<
|
|
61
|
-
|
|
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
|
-
<
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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="
|
|
83
|
-
<
|
|
84
|
-
|
|
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="
|
|
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
|
-
<
|
|
80
|
-
|
|
89
|
+
<Breadcrumbs items={crumbs} label={t.breadcrumbs} />
|
|
90
|
+
<header className="article__header">
|
|
81
91
|
{found.category && (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
<
|
|
96
|
-
<
|
|
97
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
{
|
|
107
|
-
|
|
108
|
-
{
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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="
|
|
130
|
-
<
|
|
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="
|
|
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
|
+
}
|