create-magic-storefront 0.1.2 → 0.2.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/README.md +6 -0
- package/dist/index.js +40 -3
- package/package.json +1 -1
- package/template/.claude/skills/storefront-design/SKILL.md +27 -22
- package/template/.claude/skills/storefront-verify/SKILL.md +10 -9
- package/template/AGENTS.md +16 -13
- package/template/PAGES.md +66 -11
- package/template/README.md +1 -1
- package/template/app/blog/[handle]/page.tsx +136 -0
- package/template/app/blog/author/[handle]/page.tsx +44 -0
- package/template/app/blog/category/[handle]/page.tsx +52 -0
- package/template/app/blog/page.tsx +97 -0
- package/template/app/blog/tag/[handle]/page.tsx +51 -0
- package/template/app/globals.css +68 -0
- package/template/app/layout.tsx +2 -0
- package/template/app/sitemap.ts +16 -6
- package/template/components/analytics-views.tsx +7 -0
- package/template/components/article-body.tsx +58 -0
- package/template/components/article-card.tsx +54 -0
- package/template/components/article-vote.tsx +43 -0
- package/template/components/blog-listing.tsx +93 -0
- package/template/components/pager.tsx +8 -2
- package/template/components/product-grid.tsx +27 -20
- package/template/lib/errors.ts +2 -1
- package/template/lib/i18n.ts +46 -0
- package/template/lib/site.ts +12 -0
- package/template/llms.txt +36 -10
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { breadcrumbJsonLd, jsonLdScript, pageMeta } from '@magicstoreai/hydrogen/seo';
|
|
4
|
+
import { ArticleGrid } from '@/components/article-card';
|
|
5
|
+
import { ArticleListing, CategoryNav, pageParam, sortParam } from '@/components/blog-listing';
|
|
6
|
+
import { api, locale } from '@/lib/api';
|
|
7
|
+
import { messages } from '@/lib/i18n';
|
|
8
|
+
|
|
9
|
+
type Props = { searchParams: Promise<{ q?: string; sort?: string; page?: string }> };
|
|
10
|
+
|
|
11
|
+
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
|
|
12
|
+
const { data: shop } = await api.shop();
|
|
13
|
+
const { q, sort } = await searchParams;
|
|
14
|
+
const meta = pageMeta({
|
|
15
|
+
title: shop.blog?.title || messages(locale).blog,
|
|
16
|
+
description: shop.blog?.description ?? null,
|
|
17
|
+
shop,
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
title: meta.title,
|
|
21
|
+
description: meta.description,
|
|
22
|
+
// Search results are endless and a sort repeats the same articles: keep both out of the index.
|
|
23
|
+
...((q || sort) && { robots: { index: false, follow: true } }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The blog home: the merchant's heading, categories, editor's picks, then every article. */
|
|
28
|
+
export default async function BlogPage({ searchParams }: Props) {
|
|
29
|
+
const { q = '', sort: sortValue, page: pageValue } = await searchParams;
|
|
30
|
+
const sort = sortParam(sortValue);
|
|
31
|
+
const page = pageParam(pageValue);
|
|
32
|
+
const t = messages(locale);
|
|
33
|
+
const { data: shop } = await api.shop();
|
|
34
|
+
const showFeatured = page === 1 && !q && !sort;
|
|
35
|
+
const featured = showFeatured
|
|
36
|
+
? (await api.blogArticlesIndex({ query: { 'filter[featured]': 'true', perPage: 3 } })).data
|
|
37
|
+
: [];
|
|
38
|
+
const href = (p: number) => {
|
|
39
|
+
const params = new URLSearchParams({
|
|
40
|
+
...(q && { q }),
|
|
41
|
+
...(sort && { sort }),
|
|
42
|
+
...(p > 1 && { page: String(p) }),
|
|
43
|
+
});
|
|
44
|
+
return params.size > 0 ? `/blog?${params}` : '/blog';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="stack">
|
|
49
|
+
<script
|
|
50
|
+
type="application/ld+json"
|
|
51
|
+
dangerouslySetInnerHTML={{
|
|
52
|
+
__html: jsonLdScript(
|
|
53
|
+
breadcrumbJsonLd([
|
|
54
|
+
{ name: t.home, url: '/' },
|
|
55
|
+
{ name: shop.blog?.title || t.blog, url: '/blog' },
|
|
56
|
+
]),
|
|
57
|
+
),
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
<h1>{shop.blog?.title || t.blog}</h1>
|
|
61
|
+
{shop.blog?.description && <p className="muted">{shop.blog.description}</p>}
|
|
62
|
+
<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>
|
|
81
|
+
{featured.length > 0 && (
|
|
82
|
+
<section className="stack">
|
|
83
|
+
<h2>{t.featured}</h2>
|
|
84
|
+
<ArticleGrid articles={featured} timeZone={shop.timezone} />
|
|
85
|
+
</section>
|
|
86
|
+
)}
|
|
87
|
+
{/* The picks are not repeated below; a blog of only picks shows no second list. */}
|
|
88
|
+
<ArticleListing
|
|
89
|
+
query={{ page, ...(q && { q }), ...(sort && { sort }) }}
|
|
90
|
+
exclude={featured.map((article) => article.id)}
|
|
91
|
+
{...(featured.length > 0 && { heading: t.allArticles })}
|
|
92
|
+
href={href}
|
|
93
|
+
timeZone={shop.timezone}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { ArticleGrid } from '@/components/article-card';
|
|
4
|
+
import { ARTICLES_PER_PAGE, pageParam } from '@/components/blog-listing';
|
|
5
|
+
import { Pager } from '@/components/pager';
|
|
6
|
+
import { api, locale } from '@/lib/api';
|
|
7
|
+
import { messages } from '@/lib/i18n';
|
|
8
|
+
|
|
9
|
+
type Props = { params: Promise<{ handle: string }>; searchParams: Promise<{ page?: string }> };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Articles with a tag. There is no tag list in the API: the tag's title comes from its articles,
|
|
13
|
+
* and a tag without any (in this language) is a 404.
|
|
14
|
+
*/
|
|
15
|
+
async function tagged(handle: string, page: number) {
|
|
16
|
+
const articles = await api.blogArticlesIndex({
|
|
17
|
+
query: { page, perPage: ARTICLES_PER_PAGE, 'filter[tag]': handle },
|
|
18
|
+
});
|
|
19
|
+
if (articles.data.length === 0) {
|
|
20
|
+
notFound();
|
|
21
|
+
}
|
|
22
|
+
const detail = await api.blogArticlesShow({ path: { handle: articles.data[0]!.handle } });
|
|
23
|
+
const title = detail.data.tags.find((tag) => tag.handle === handle)?.title ?? handle;
|
|
24
|
+
return { articles, title };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
|
|
28
|
+
const { handle } = await params;
|
|
29
|
+
const { title } = await tagged(handle, pageParam((await searchParams).page));
|
|
30
|
+
return { title: `${messages(locale).tag}: ${title}`, robots: { index: false, follow: true } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default async function BlogTagPage({ params, searchParams }: Props) {
|
|
34
|
+
const { handle } = await params;
|
|
35
|
+
const page = pageParam((await searchParams).page);
|
|
36
|
+
const [{ articles, title }, { data: shop }] = await Promise.all([
|
|
37
|
+
tagged(handle, page),
|
|
38
|
+
api.shop(),
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="stack">
|
|
43
|
+
<h1>#{title}</h1>
|
|
44
|
+
<ArticleGrid articles={articles.data} timeZone={shop.timezone} />
|
|
45
|
+
<Pager
|
|
46
|
+
meta={articles.meta.pagination}
|
|
47
|
+
href={(p) => `/blog/tag/${handle}${p > 1 ? `?page=${p}` : ''}`}
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
package/template/app/globals.css
CHANGED
|
@@ -72,6 +72,8 @@ header.site .brand img {
|
|
|
72
72
|
.card img,
|
|
73
73
|
.card .noimg {
|
|
74
74
|
width: 100%;
|
|
75
|
+
/* The intrinsic width/height attributes (no layout shift) must not override the ratio. */
|
|
76
|
+
height: auto;
|
|
75
77
|
aspect-ratio: 1;
|
|
76
78
|
object-fit: cover;
|
|
77
79
|
background: #f3f3f6;
|
|
@@ -308,3 +310,69 @@ footer.site nav {
|
|
|
308
310
|
gap: 16px;
|
|
309
311
|
flex-wrap: wrap;
|
|
310
312
|
}
|
|
313
|
+
.articles {
|
|
314
|
+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
315
|
+
}
|
|
316
|
+
.article-card img,
|
|
317
|
+
.article-card .noimg {
|
|
318
|
+
aspect-ratio: 16 / 9;
|
|
319
|
+
}
|
|
320
|
+
.article-card .badge {
|
|
321
|
+
align-self: flex-start;
|
|
322
|
+
}
|
|
323
|
+
.chips a {
|
|
324
|
+
display: inline-flex;
|
|
325
|
+
align-items: center;
|
|
326
|
+
gap: 6px;
|
|
327
|
+
min-height: 44px;
|
|
328
|
+
padding: 0 14px;
|
|
329
|
+
border: 1px solid var(--line);
|
|
330
|
+
border-radius: 999px;
|
|
331
|
+
text-decoration: none;
|
|
332
|
+
}
|
|
333
|
+
.chips a[aria-current='page'] {
|
|
334
|
+
background: var(--accent);
|
|
335
|
+
border-color: var(--accent);
|
|
336
|
+
color: var(--on-accent);
|
|
337
|
+
}
|
|
338
|
+
.article {
|
|
339
|
+
max-width: 760px;
|
|
340
|
+
margin: 0 auto;
|
|
341
|
+
}
|
|
342
|
+
.article-cover {
|
|
343
|
+
width: 100%;
|
|
344
|
+
height: auto;
|
|
345
|
+
border-radius: 8px;
|
|
346
|
+
}
|
|
347
|
+
.prose {
|
|
348
|
+
line-height: 1.65;
|
|
349
|
+
}
|
|
350
|
+
.prose img {
|
|
351
|
+
max-width: 100%;
|
|
352
|
+
height: auto;
|
|
353
|
+
}
|
|
354
|
+
.prose table {
|
|
355
|
+
display: block;
|
|
356
|
+
overflow-x: auto;
|
|
357
|
+
}
|
|
358
|
+
.article-product {
|
|
359
|
+
max-width: 260px;
|
|
360
|
+
margin: 16px 0;
|
|
361
|
+
}
|
|
362
|
+
.callout {
|
|
363
|
+
margin: 16px 0;
|
|
364
|
+
padding: 12px 16px;
|
|
365
|
+
border-left: 4px solid var(--accent);
|
|
366
|
+
border-radius: 8px;
|
|
367
|
+
background: #f6f7fb;
|
|
368
|
+
}
|
|
369
|
+
.callout[data-callout='warning'] {
|
|
370
|
+
border-left-color: var(--danger);
|
|
371
|
+
}
|
|
372
|
+
.author img {
|
|
373
|
+
border-radius: 50%;
|
|
374
|
+
object-fit: cover;
|
|
375
|
+
}
|
|
376
|
+
.author h1 {
|
|
377
|
+
margin: 0;
|
|
378
|
+
}
|
package/template/app/layout.tsx
CHANGED
|
@@ -49,6 +49,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|
|
49
49
|
{collection.title}
|
|
50
50
|
</Link>
|
|
51
51
|
))}
|
|
52
|
+
{/* Only when an article is readable in this language (`features.blog`). */}
|
|
53
|
+
{shop.features.blog && <Link href="/blog">{t.blog}</Link>}
|
|
52
54
|
<Link href="/search">{t.search}</Link>
|
|
53
55
|
<Link href="/account">{t.account}</Link>
|
|
54
56
|
<CartLink />
|
package/template/app/sitemap.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { MetadataRoute } from 'next';
|
|
2
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
2
3
|
import { api } from '@/lib/api';
|
|
4
|
+
import { siteOrigin } from '@/lib/site';
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
/** URL prefix per `SitemapEntry.type`; a type added to the API later is skipped, not linked. */
|
|
7
|
+
const PATHS: Partial<Record<string, string>> & Record<Schema<'SitemapEntry'>['type'], string> = {
|
|
8
|
+
PAGE: 'pages',
|
|
9
|
+
COLLECTION: 'collections',
|
|
10
|
+
PRODUCT: 'products',
|
|
11
|
+
ARTICLE: 'blog',
|
|
12
|
+
};
|
|
5
13
|
/** The sitemap protocol allows 50 000 URLs in one file: 50 pages of `GET /sitemap`. */
|
|
6
14
|
const PER_PAGE = 1000;
|
|
7
15
|
const MAX_PAGES = 50;
|
|
@@ -10,21 +18,23 @@ const MAX_PAGES = 50;
|
|
|
10
18
|
export const dynamic = 'force-dynamic';
|
|
11
19
|
|
|
12
20
|
/**
|
|
13
|
-
* Every published product, collection and
|
|
21
|
+
* Every published product, collection, page and blog article (`GET /sitemap`, up to 1000 rows a page). URLs are
|
|
14
22
|
* absolute on `SITE_URL`, else the shop's primary domain.
|
|
15
23
|
*/
|
|
16
24
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
17
25
|
const { data: shop } = await api.shop();
|
|
18
|
-
const origin =
|
|
19
|
-
process.env.SITE_URL?.replace(/\/+$/, '') ??
|
|
20
|
-
`https://${shop.primaryDomain ?? process.env.MAGICSTORE_SHOP_DOMAIN}`;
|
|
26
|
+
const origin = siteOrigin(shop);
|
|
21
27
|
|
|
22
28
|
const entries: MetadataRoute.Sitemap = [{ url: `${origin}/` }];
|
|
23
29
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
24
30
|
const { data, meta } = await api.sitemap({ query: { page, perPage: PER_PAGE } });
|
|
25
31
|
for (const row of data) {
|
|
32
|
+
const prefix = PATHS[row.type];
|
|
33
|
+
if (!prefix) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
26
36
|
entries.push({
|
|
27
|
-
url: `${origin}/${
|
|
37
|
+
url: `${origin}/${prefix}/${row.handle}`,
|
|
28
38
|
...(row.updatedAt && { lastModified: row.updatedAt }),
|
|
29
39
|
});
|
|
30
40
|
}
|
|
@@ -16,3 +16,10 @@ export function SearchView({ query, resultsCount }: { query: string; resultsCoun
|
|
|
16
16
|
useEffect(() => analytics.search(query, resultsCount), [analytics, query, resultsCount]);
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/** Reports an article read once per article (`ARTICLE_VIEW`); renders nothing. */
|
|
21
|
+
export function ArticleView({ articleId }: { articleId: string }) {
|
|
22
|
+
const analytics = useAnalytics();
|
|
23
|
+
useEffect(() => analytics.articleView(articleId), [analytics, articleId]);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { articleBodyBlocks, type ArticleBlock } from '@magicstoreai/hydrogen/core';
|
|
2
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
3
|
+
import { ProductCard } from '@/components/product-grid';
|
|
4
|
+
|
|
5
|
+
type Product = Schema<'Product'>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An article body (`bodyHtml`, sanitized by the server) with its inert markup turned into
|
|
9
|
+
* components: product cards from `products` (a marker whose product the list did not return is
|
|
10
|
+
* dropped), media embeds as links, callouts as boxes.
|
|
11
|
+
*/
|
|
12
|
+
export function ArticleBody({
|
|
13
|
+
bodyHtml,
|
|
14
|
+
products,
|
|
15
|
+
}: {
|
|
16
|
+
bodyHtml: string;
|
|
17
|
+
products: ReadonlyMap<string, Product>;
|
|
18
|
+
}) {
|
|
19
|
+
return <Blocks blocks={articleBodyBlocks(bodyHtml)} products={products} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Blocks({
|
|
23
|
+
blocks,
|
|
24
|
+
products,
|
|
25
|
+
}: {
|
|
26
|
+
blocks: ArticleBlock[];
|
|
27
|
+
products: ReadonlyMap<string, Product>;
|
|
28
|
+
}) {
|
|
29
|
+
return blocks.map((block, index) => {
|
|
30
|
+
switch (block.type) {
|
|
31
|
+
case 'html':
|
|
32
|
+
return <div key={index} dangerouslySetInnerHTML={{ __html: block.html }} />;
|
|
33
|
+
case 'product': {
|
|
34
|
+
const product = products.get(block.handle);
|
|
35
|
+
return product ? (
|
|
36
|
+
<div key={index} className="article-product">
|
|
37
|
+
<ProductCard product={product} />
|
|
38
|
+
</div>
|
|
39
|
+
) : null;
|
|
40
|
+
}
|
|
41
|
+
case 'embed':
|
|
42
|
+
return (
|
|
43
|
+
<p key={index}>
|
|
44
|
+
<a href={block.url} target="_blank" rel="noopener nofollow">
|
|
45
|
+
{block.url}
|
|
46
|
+
</a>
|
|
47
|
+
</p>
|
|
48
|
+
);
|
|
49
|
+
case 'callout':
|
|
50
|
+
return (
|
|
51
|
+
<aside key={index} className="callout" data-callout={block.kind}>
|
|
52
|
+
{block.title && <strong>{block.title}</strong>}
|
|
53
|
+
<Blocks blocks={block.blocks} products={products} />
|
|
54
|
+
</aside>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Image } from '@magicstoreai/hydrogen';
|
|
2
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { locale } from '@/lib/api';
|
|
5
|
+
import { messages } from '@/lib/i18n';
|
|
6
|
+
|
|
7
|
+
type Article = Schema<'BlogArticle'>;
|
|
8
|
+
|
|
9
|
+
/** A date in the shop's time zone, so a server elsewhere does not move it by a day. */
|
|
10
|
+
export function ArticleDate({ iso, timeZone }: { iso: string; timeZone: string }) {
|
|
11
|
+
return <time dateTime={iso}>{formatDate(iso, timeZone)}</time>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatDate(iso: string, timeZone: string): string {
|
|
15
|
+
try {
|
|
16
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone }).format(new Date(iso));
|
|
17
|
+
} catch {
|
|
18
|
+
// A time zone this runtime does not know: the date in UTC beats a failed page.
|
|
19
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(
|
|
20
|
+
new Date(iso),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One article as a card: cover, category, title, excerpt, author, date and reading time. */
|
|
26
|
+
export function ArticleCard({ article, timeZone }: { article: Article; timeZone: string }) {
|
|
27
|
+
const t = messages(locale);
|
|
28
|
+
return (
|
|
29
|
+
<Link href={`/blog/${article.handle}`} className="card article-card">
|
|
30
|
+
<Image data={article.image} alt={article.title} fallback={<div className="noimg" />} />
|
|
31
|
+
{article.category && <span className="badge">{article.category.title}</span>}
|
|
32
|
+
<strong>{article.title}</strong>
|
|
33
|
+
{article.excerpt && <span className="muted">{article.excerpt}</span>}
|
|
34
|
+
<span className="row muted">
|
|
35
|
+
{article.authorName && <span>{article.authorName}</span>}
|
|
36
|
+
<ArticleDate iso={article.publishedAt} timeZone={timeZone} />
|
|
37
|
+
<span>{t.readingTime(article.readingMinutes)}</span>
|
|
38
|
+
</span>
|
|
39
|
+
</Link>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ArticleGrid({ articles, timeZone }: { articles: Article[]; timeZone: string }) {
|
|
44
|
+
if (articles.length === 0) {
|
|
45
|
+
return <p className="muted">{messages(locale).nothingHere}</p>;
|
|
46
|
+
}
|
|
47
|
+
return (
|
|
48
|
+
<div className="grid articles">
|
|
49
|
+
{articles.map((article) => (
|
|
50
|
+
<ArticleCard key={article.id} article={article} timeZone={timeZone} />
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useArticleVote, useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { describe } from '@/lib/errors';
|
|
5
|
+
import { messages } from '@/lib/i18n';
|
|
6
|
+
|
|
7
|
+
/** «Was this helpful?»: a guest votes as this browser, a signed-in customer as themselves. */
|
|
8
|
+
export function ArticleVote({ handle, helpfulCount }: { handle: string; helpfulCount: number }) {
|
|
9
|
+
const t = messages(useMagicStore().locale);
|
|
10
|
+
const vote = useArticleVote(handle, helpfulCount);
|
|
11
|
+
const choose = (value: 'HELPFUL' | 'NOT_HELPFUL') =>
|
|
12
|
+
void (vote.viewerVote === value ? vote.clear() : vote.vote(value)).catch(() => undefined);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<section className="stack" aria-live="polite">
|
|
16
|
+
<strong>{t.wasHelpful}</strong>
|
|
17
|
+
<div className="row">
|
|
18
|
+
<button
|
|
19
|
+
aria-pressed={vote.viewerVote === 'HELPFUL'}
|
|
20
|
+
disabled={vote.status === 'pending'}
|
|
21
|
+
onClick={() => choose('HELPFUL')}
|
|
22
|
+
>
|
|
23
|
+
{t.helpful}
|
|
24
|
+
</button>
|
|
25
|
+
<button
|
|
26
|
+
aria-pressed={vote.viewerVote === 'NOT_HELPFUL'}
|
|
27
|
+
disabled={vote.status === 'pending'}
|
|
28
|
+
onClick={() => choose('NOT_HELPFUL')}
|
|
29
|
+
>
|
|
30
|
+
{t.notHelpful}
|
|
31
|
+
</button>
|
|
32
|
+
{vote.helpfulCount > 0 && (
|
|
33
|
+
<span className="muted">{t.helpfulCount(vote.helpfulCount)}</span>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
{vote.error ? (
|
|
37
|
+
<p className="error">{describe(vote.error, t.tryAgainLater)}</p>
|
|
38
|
+
) : (
|
|
39
|
+
vote.viewerVote !== null && <p className="muted">{t.thanksForVote}</p>
|
|
40
|
+
)}
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { MagicStoreError } from '@magicstoreai/storefront-client';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { ArticleGrid } from '@/components/article-card';
|
|
4
|
+
import { Pager } from '@/components/pager';
|
|
5
|
+
import { api, locale } from '@/lib/api';
|
|
6
|
+
import { describe } from '@/lib/errors';
|
|
7
|
+
import { messages } from '@/lib/i18n';
|
|
8
|
+
|
|
9
|
+
export type ArticlesQuery = NonNullable<
|
|
10
|
+
NonNullable<Parameters<typeof api.blogArticlesIndex>[0]>['query']
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
/** 12 a page, the API's default: a blog page reads better short. */
|
|
14
|
+
export const ARTICLES_PER_PAGE = 12;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One page of articles with its pager. A query the API refuses (`422`: an unknown `sort`, a `q`
|
|
18
|
+
* over 200 characters) shows the API's message instead of failing the page. Articles in
|
|
19
|
+
* `exclude` (already shown above, e.g. editor's picks) are left out; when that leaves nothing on a
|
|
20
|
+
* single page, nothing renders — not even `heading`.
|
|
21
|
+
*/
|
|
22
|
+
export async function ArticleListing({
|
|
23
|
+
query,
|
|
24
|
+
href,
|
|
25
|
+
timeZone,
|
|
26
|
+
exclude = [],
|
|
27
|
+
heading,
|
|
28
|
+
}: {
|
|
29
|
+
query: ArticlesQuery;
|
|
30
|
+
href: (page: number) => string;
|
|
31
|
+
timeZone: string;
|
|
32
|
+
exclude?: readonly string[];
|
|
33
|
+
heading?: string;
|
|
34
|
+
}) {
|
|
35
|
+
try {
|
|
36
|
+
const { data, meta } = await api.blogArticlesIndex({
|
|
37
|
+
query: { perPage: ARTICLES_PER_PAGE, ...query },
|
|
38
|
+
});
|
|
39
|
+
const articles = data.filter((article) => !exclude.includes(article.id));
|
|
40
|
+
if (articles.length === 0 && data.length > 0 && meta.pagination.totalPages <= 1) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
{heading && <h2>{heading}</h2>}
|
|
46
|
+
<ArticleGrid articles={articles} timeZone={timeZone} />
|
|
47
|
+
<Pager meta={meta.pagination} href={href} />
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof MagicStoreError && error.code === 'VALIDATION_FAILED') {
|
|
52
|
+
return <p className="error">{describe(error, messages(locale).tryAgainLater)}</p>;
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The blog's categories as links; the current one is marked. Nothing when there are none. */
|
|
59
|
+
export async function CategoryNav({ current }: { current?: string }) {
|
|
60
|
+
const t = messages(locale);
|
|
61
|
+
const { data: categories } = await api.blogCategoriesIndex();
|
|
62
|
+
if (categories.length === 0) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
<nav className="row chips" aria-label={t.blog}>
|
|
67
|
+
<Link href="/blog" aria-current={current === undefined ? 'page' : undefined}>
|
|
68
|
+
{t.allArticles}
|
|
69
|
+
</Link>
|
|
70
|
+
{categories.map((category) => (
|
|
71
|
+
<Link
|
|
72
|
+
key={category.id}
|
|
73
|
+
href={`/blog/category/${category.handle}`}
|
|
74
|
+
aria-current={current === category.handle ? 'page' : undefined}
|
|
75
|
+
>
|
|
76
|
+
{category.title} <span className="muted">{category.articlesCount}</span>
|
|
77
|
+
</Link>
|
|
78
|
+
))}
|
|
79
|
+
</nav>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const SORTS = ['-publishedAt', 'publishedAt', 'relevance', '-recentViews'] as const;
|
|
84
|
+
|
|
85
|
+
/** `?sort=` when it is one the API knows; anything else means the default order. */
|
|
86
|
+
export function sortParam(value: string | undefined): ArticlesQuery['sort'] {
|
|
87
|
+
return SORTS.find((sort) => sort === value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `?page=` as a page number, 1 for anything else. */
|
|
91
|
+
export function pageParam(value: string | undefined): number {
|
|
92
|
+
return Math.max(1, Number(value) || 1);
|
|
93
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { paginationState
|
|
1
|
+
import { paginationState } from '@magicstoreai/hydrogen/core';
|
|
2
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
2
3
|
import Link from 'next/link';
|
|
3
4
|
import { locale } from '@/lib/api';
|
|
4
5
|
import { messages } from '@/lib/i18n';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
type Pagination = Schema<'Pagination'>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Server-rendered page links. `paginationState` comes from `/core`: the root entry is a client
|
|
11
|
+
* module, and a server component cannot call its functions.
|
|
12
|
+
*/
|
|
7
13
|
export function Pager({ meta, href }: { meta: Pagination; href: (page: number) => string }) {
|
|
8
14
|
if (meta.totalPages <= 1) {
|
|
9
15
|
return null;
|
|
@@ -28,32 +28,39 @@ function ProductBadges({ product }: { product: Schema<'Product'> }) {
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** One product as a card: the whole card is one link. */
|
|
32
|
+
export function ProductCard({ product }: { product: Schema<'Product'> }) {
|
|
32
33
|
const t = messages(locale);
|
|
34
|
+
return (
|
|
35
|
+
<Link href={`/products/${product.handle}`} className="card">
|
|
36
|
+
<Image
|
|
37
|
+
data={product.featuredImage}
|
|
38
|
+
alt={product.title}
|
|
39
|
+
fallback={<div className="noimg" />}
|
|
40
|
+
/>
|
|
41
|
+
<ProductBadges product={product} />
|
|
42
|
+
<span>{product.title}</span>
|
|
43
|
+
<span className="row">
|
|
44
|
+
<Money data={product.price} fallback={<span className="muted">—</span>} />
|
|
45
|
+
{product.compareAtPrice && (
|
|
46
|
+
<s className="muted">
|
|
47
|
+
<Money data={product.compareAtPrice} />
|
|
48
|
+
</s>
|
|
49
|
+
)}
|
|
50
|
+
</span>
|
|
51
|
+
{!product.availableForSale && <span className="muted">{t.soldOut}</span>}
|
|
52
|
+
</Link>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
|
|
33
57
|
if (products.length === 0) {
|
|
34
|
-
return <p className="muted">{
|
|
58
|
+
return <p className="muted">{messages(locale).nothingHere}</p>;
|
|
35
59
|
}
|
|
36
60
|
return (
|
|
37
61
|
<div className="grid">
|
|
38
62
|
{products.map((product) => (
|
|
39
|
-
<
|
|
40
|
-
<Image
|
|
41
|
-
data={product.featuredImage}
|
|
42
|
-
alt={product.title}
|
|
43
|
-
fallback={<div className="noimg" />}
|
|
44
|
-
/>
|
|
45
|
-
<ProductBadges product={product} />
|
|
46
|
-
<span>{product.title}</span>
|
|
47
|
-
<span className="row">
|
|
48
|
-
<Money data={product.price} fallback={<span className="muted">—</span>} />
|
|
49
|
-
{product.compareAtPrice && (
|
|
50
|
-
<s className="muted">
|
|
51
|
-
<Money data={product.compareAtPrice} />
|
|
52
|
-
</s>
|
|
53
|
-
)}
|
|
54
|
-
</span>
|
|
55
|
-
{!product.availableForSale && <span className="muted">{t.soldOut}</span>}
|
|
56
|
-
</Link>
|
|
63
|
+
<ProductCard key={product.id} product={product} />
|
|
57
64
|
))}
|
|
58
65
|
</div>
|
|
59
66
|
);
|
package/template/lib/errors.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
// From the client package, not the "use client" hydrogen entry: this runs in server components too.
|
|
2
|
+
import { MagicStoreError } from '@magicstoreai/storefront-client';
|
|
2
3
|
|
|
3
4
|
/** What to tell the buyer: the API's localized detail when there is one. */
|
|
4
5
|
export function describe(error: unknown, fallback: string): string {
|