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.
@@ -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
+ }
@@ -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
+ }
@@ -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 />
@@ -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
- const PATHS = { PAGE: 'pages', COLLECTION: 'collections', PRODUCT: 'products' } as const;
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 page (`GET /sitemap`, up to 1000 rows a page). URLs are
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}/${PATHS[row.type]}/${row.handle}`,
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, type Pagination } from '@magicstoreai/hydrogen';
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
- /** Server-rendered page links (`paginationState` is plain arithmetic, no hooks). */
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
- export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
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">{t.nothingHere}</p>;
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
- <Link key={product.id} href={`/products/${product.handle}`} className="card">
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
  );
@@ -1,4 +1,5 @@
1
- import { MagicStoreError } from '@magicstoreai/hydrogen';
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 {