create-magic-storefront 0.1.3 → 0.3.1

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,136 @@
1
+ import type { Metadata } from 'next';
2
+ import Link from 'next/link';
3
+ import { Image } from '@magicstoreai/hydrogen';
4
+ import { embeddedProductFilters, orderEmbeddedProducts } from '@magicstoreai/hydrogen/core';
5
+ import {
6
+ articleJsonLd,
7
+ articleMeta,
8
+ breadcrumbJsonLd,
9
+ jsonLdScript,
10
+ } from '@magicstoreai/hydrogen/seo';
11
+ import { ArticleView } from '@/components/analytics-views';
12
+ import { ArticleBody } from '@/components/article-body';
13
+ import { ArticleDate, ArticleGrid } from '@/components/article-card';
14
+ import { ArticleVote } from '@/components/article-vote';
15
+ import { api, locale, orNotFound } from '@/lib/api';
16
+ import { messages } from '@/lib/i18n';
17
+ import { siteOrigin } from '@/lib/site';
18
+
19
+ type Props = { params: Promise<{ handle: string }> };
20
+
21
+ /**
22
+ * An article is served only in the languages it is written in: in any other one the API answers
23
+ * 404, and so does this page.
24
+ */
25
+ async function article(handle: string) {
26
+ return (await orNotFound(api.blogArticlesShow({ path: { handle } }))).data;
27
+ }
28
+
29
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
30
+ const { handle } = await params;
31
+ const [found, { data: shop }] = await Promise.all([article(handle), api.shop()]);
32
+ // A single-language storefront: no `urlForLocale`, so no hreflang alternates.
33
+ return articleMeta(found, { url: `${siteOrigin(shop)}/blog/${found.handle}`, shop });
34
+ }
35
+
36
+ export default async function ArticlePage({ params }: Props) {
37
+ const { handle } = await params;
38
+ const t = messages(locale);
39
+ const [found, { data: shop }] = await Promise.all([article(handle), api.shop()]);
40
+ // Prices and stock of the embedded products come live from /products, in one call (rarely two);
41
+ // that list has its own order, so put it back in the article's.
42
+ const [related, ...productPages] = await Promise.all([
43
+ api.blogArticlesRelated({ path: { handle }, query: { perPage: 3 } }),
44
+ ...embeddedProductFilters(found.products).map((handles) =>
45
+ api.productsIndex({ query: { 'filter[handles]': handles, perPage: 100 } }),
46
+ ),
47
+ ]);
48
+ const products = orderEmbeddedProducts(
49
+ found.products,
50
+ productPages.flatMap((result) => result.data),
51
+ );
52
+ const url = `${siteOrigin(shop)}/blog/${found.handle}`;
53
+ const blogTitle = shop.blog?.title || t.blog;
54
+
55
+ return (
56
+ <article className="stack article">
57
+ <script
58
+ type="application/ld+json"
59
+ dangerouslySetInnerHTML={{
60
+ __html: jsonLdScript(articleJsonLd(found, { url, shop, locale })),
61
+ }}
62
+ />
63
+ <script
64
+ type="application/ld+json"
65
+ dangerouslySetInnerHTML={{
66
+ __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
+ ]),
75
+ ),
76
+ }}
77
+ />
78
+ <ArticleView articleId={found.id} />
79
+ <nav className="row muted" aria-label={blogTitle}>
80
+ <Link href="/blog">{blogTitle}</Link>
81
+ {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>
94
+ )}
95
+ <ArticleDate iso={found.publishedAt} timeZone={shop.timezone} />
96
+ <span>{t.readingTime(found.readingMinutes)}</span>
97
+ </p>
98
+ {/* 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>}
124
+ </div>
125
+ </aside>
126
+ )}
127
+ <ArticleVote handle={found.handle} helpfulCount={found.helpfulCount} />
128
+ {related.data.length > 0 && (
129
+ <section className="stack">
130
+ <h2>{t.relatedArticles}</h2>
131
+ <ArticleGrid articles={related.data} timeZone={shop.timezone} />
132
+ </section>
133
+ )}
134
+ </article>
135
+ );
136
+ }
@@ -0,0 +1,44 @@
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import { Image } from '@magicstoreai/hydrogen';
4
+ import { pageMeta } from '@magicstoreai/hydrogen/seo';
5
+ import { ArticleListing, pageParam } from '@/components/blog-listing';
6
+ import { api } from '@/lib/api';
7
+
8
+ type Props = { params: Promise<{ handle: string }>; searchParams: Promise<{ page?: string }> };
9
+
10
+ /** The author, or 404: the list holds only authors with an article in this language. */
11
+ async function author(handle: string) {
12
+ const { data } = await api.blogAuthorsIndex();
13
+ return data.find((item) => item.handle === handle) ?? notFound();
14
+ }
15
+
16
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
17
+ const { handle } = await params;
18
+ const [found, { data: shop }] = await Promise.all([author(handle), api.shop()]);
19
+ const meta = pageMeta({ title: found.name, description: found.role, shop });
20
+ return { title: meta.title, description: meta.description };
21
+ }
22
+
23
+ export default async function BlogAuthorPage({ params, searchParams }: Props) {
24
+ const { handle } = await params;
25
+ const page = pageParam((await searchParams).page);
26
+ const [found, { data: shop }] = await Promise.all([author(handle), api.shop()]);
27
+
28
+ return (
29
+ <div className="stack">
30
+ <header className="row author">
31
+ <Image data={found.avatar} alt={found.name} width={64} height={64} />
32
+ <div>
33
+ <h1>{found.name}</h1>
34
+ {found.role && <p className="muted">{found.role}</p>}
35
+ </div>
36
+ </header>
37
+ <ArticleListing
38
+ query={{ page, 'filter[author]': handle }}
39
+ href={(p) => `/blog/author/${handle}${p > 1 ? `?page=${p}` : ''}`}
40
+ timeZone={shop.timezone}
41
+ />
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,52 @@
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import { breadcrumbJsonLd, jsonLdScript, pageMeta } from '@magicstoreai/hydrogen/seo';
4
+ import { ArticleListing, CategoryNav, pageParam } from '@/components/blog-listing';
5
+ import { api, locale } from '@/lib/api';
6
+ import { messages } from '@/lib/i18n';
7
+
8
+ type Props = { params: Promise<{ handle: string }>; searchParams: Promise<{ page?: string }> };
9
+
10
+ /** The category, or 404: the list holds only categories with an article in this language. */
11
+ async function category(handle: string) {
12
+ const { data } = await api.blogCategoriesIndex();
13
+ return data.find((item) => item.handle === handle) ?? notFound();
14
+ }
15
+
16
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
17
+ const { handle } = await params;
18
+ const [found, { data: shop }] = await Promise.all([category(handle), api.shop()]);
19
+ const meta = pageMeta({ title: found.title, shop });
20
+ return { title: meta.title };
21
+ }
22
+
23
+ export default async function BlogCategoryPage({ params, searchParams }: Props) {
24
+ const { handle } = await params;
25
+ const page = pageParam((await searchParams).page);
26
+ const t = messages(locale);
27
+ const [found, { data: shop }] = await Promise.all([category(handle), api.shop()]);
28
+
29
+ return (
30
+ <div className="stack">
31
+ <script
32
+ type="application/ld+json"
33
+ dangerouslySetInnerHTML={{
34
+ __html: jsonLdScript(
35
+ breadcrumbJsonLd([
36
+ { name: t.home, url: '/' },
37
+ { name: shop.blog?.title || t.blog, url: '/blog' },
38
+ { name: found.title, url: `/blog/category/${handle}` },
39
+ ]),
40
+ ),
41
+ }}
42
+ />
43
+ <h1>{found.title}</h1>
44
+ <CategoryNav current={handle} />
45
+ <ArticleListing
46
+ query={{ page, 'filter[category]': handle }}
47
+ href={(p) => `/blog/category/${handle}${p > 1 ? `?page=${p}` : ''}`}
48
+ timeZone={shop.timezone}
49
+ />
50
+ </div>
51
+ );
52
+ }
@@ -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
+ }
@@ -4,6 +4,9 @@
4
4
  --muted: #6b6b76;
5
5
  --line: #e7e7ec;
6
6
  --danger: #c62828;
7
+ /* Controls and image placeholders: white and light grey here, Telegram's colors in a Mini App. */
8
+ --surface: #fff;
9
+ --placeholder: #f3f3f6;
7
10
  /* Brand tokens: app/layout.tsx overrides them with the merchant's colors (lib/brand.ts). */
8
11
  --accent: #1f5eff;
9
12
  --on-accent: #fff;
@@ -20,6 +23,32 @@
20
23
  color: var(--fg);
21
24
  background: var(--bg);
22
25
  }
26
+ /*
27
+ * Inside a Telegram Mini App (components/telegram-shell.tsx marks <html>): Telegram's theme, which
28
+ * its script writes as --tg-theme-* on <html>, with the defaults above as fallbacks. The merchant's
29
+ * accent from <html style> (lib/brand.ts) still wins over Telegram's button color.
30
+ */
31
+ html[data-telegram] {
32
+ --bg: var(--tg-theme-bg-color, #fff);
33
+ --fg: var(--tg-theme-text-color, #16161a);
34
+ --muted: var(--tg-theme-hint-color, #6b6b76);
35
+ --danger: var(--tg-theme-destructive-text-color, #c62828);
36
+ --surface: var(--tg-theme-secondary-bg-color, #fff);
37
+ --placeholder: var(--tg-theme-secondary-bg-color, #f3f3f6);
38
+ --accent: var(--tg-theme-button-color, #1f5eff);
39
+ --on-accent: var(--tg-theme-button-text-color, #fff);
40
+ }
41
+ html[data-telegram][data-color-scheme='dark'] {
42
+ --line: rgba(255, 255, 255, 0.14);
43
+ color-scheme: dark;
44
+ }
45
+ html[data-telegram] body {
46
+ /* Telegram's stable viewport (the sheet can be dragged), the dynamic viewport outside it. */
47
+ min-height: var(--tg-viewport-stable-height, 100dvh);
48
+ padding-left: max(env(safe-area-inset-left), var(--tg-safe-area-inset-left, 0px));
49
+ padding-right: max(env(safe-area-inset-right), var(--tg-safe-area-inset-right, 0px));
50
+ padding-bottom: max(env(safe-area-inset-bottom), var(--tg-safe-area-inset-bottom, 0px));
51
+ }
23
52
  * {
24
53
  box-sizing: border-box;
25
54
  }
@@ -72,9 +101,11 @@ header.site .brand img {
72
101
  .card img,
73
102
  .card .noimg {
74
103
  width: 100%;
104
+ /* The intrinsic width/height attributes (no layout shift) must not override the ratio. */
105
+ height: auto;
75
106
  aspect-ratio: 1;
76
107
  object-fit: cover;
77
- background: #f3f3f6;
108
+ background: var(--placeholder);
78
109
  border-radius: 8px;
79
110
  }
80
111
  .muted {
@@ -115,7 +146,9 @@ button,
115
146
  padding: 10px 16px;
116
147
  border-radius: 8px;
117
148
  border: 1px solid var(--line);
118
- background: #fff;
149
+ background: var(--surface);
150
+ color: var(--fg);
151
+ min-height: 44px;
119
152
  cursor: pointer;
120
153
  text-decoration: none;
121
154
  }
@@ -135,7 +168,10 @@ button:disabled {
135
168
  input,
136
169
  select {
137
170
  font: inherit;
171
+ min-height: 44px;
138
172
  padding: 9px 12px;
173
+ background: var(--surface);
174
+ color: var(--fg);
139
175
  border: 1px solid var(--line);
140
176
  border-radius: 8px;
141
177
  }
@@ -284,7 +320,7 @@ fieldset {
284
320
  width: 80px;
285
321
  height: 80px;
286
322
  object-fit: cover;
287
- background: #f3f3f6;
323
+ background: var(--placeholder);
288
324
  border-radius: 12px;
289
325
  }
290
326
  .stories-circle .story img,
@@ -308,3 +344,69 @@ footer.site nav {
308
344
  gap: 16px;
309
345
  flex-wrap: wrap;
310
346
  }
347
+ .articles {
348
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
349
+ }
350
+ .article-card img,
351
+ .article-card .noimg {
352
+ aspect-ratio: 16 / 9;
353
+ }
354
+ .article-card .badge {
355
+ align-self: flex-start;
356
+ }
357
+ .chips a {
358
+ display: inline-flex;
359
+ align-items: center;
360
+ gap: 6px;
361
+ min-height: 44px;
362
+ padding: 0 14px;
363
+ border: 1px solid var(--line);
364
+ border-radius: 999px;
365
+ text-decoration: none;
366
+ }
367
+ .chips a[aria-current='page'] {
368
+ background: var(--accent);
369
+ border-color: var(--accent);
370
+ color: var(--on-accent);
371
+ }
372
+ .article {
373
+ max-width: 760px;
374
+ margin: 0 auto;
375
+ }
376
+ .article-cover {
377
+ width: 100%;
378
+ height: auto;
379
+ border-radius: 8px;
380
+ }
381
+ .prose {
382
+ line-height: 1.65;
383
+ }
384
+ .prose img {
385
+ max-width: 100%;
386
+ height: auto;
387
+ }
388
+ .prose table {
389
+ display: block;
390
+ overflow-x: auto;
391
+ }
392
+ .article-product {
393
+ max-width: 260px;
394
+ margin: 16px 0;
395
+ }
396
+ .callout {
397
+ margin: 16px 0;
398
+ padding: 12px 16px;
399
+ border-left: 4px solid var(--accent);
400
+ border-radius: 8px;
401
+ background: var(--placeholder);
402
+ }
403
+ .callout[data-callout='warning'] {
404
+ border-left-color: var(--danger);
405
+ }
406
+ .author img {
407
+ border-radius: 50%;
408
+ object-fit: cover;
409
+ }
410
+ .author h1 {
411
+ margin: 0;
412
+ }
@@ -1,8 +1,12 @@
1
1
  import type { Metadata } from 'next';
2
2
  import Link from 'next/link';
3
+ import Script from 'next/script';
3
4
  import type { ReactNode } from 'react';
4
5
  import { Image } from '@magicstoreai/hydrogen';
6
+ // From /core, not the "use client" entry: a plain string a server component can read.
7
+ import { TELEGRAM_WEB_APP_SCRIPT } from '@magicstoreai/hydrogen/core';
5
8
  import { CartLink } from '@/components/cart-link';
9
+ import { TelegramShell } from '@/components/telegram-shell';
6
10
  import { api, locale } from '@/lib/api';
7
11
  import { brandStyle } from '@/lib/brand';
8
12
  import { messages } from '@/lib/i18n';
@@ -34,7 +38,12 @@ export default async function RootLayout({ children }: { children: ReactNode })
34
38
  // The merchant's colors, read per request: a change in the admin shows after SHOP_UPDATED.
35
39
  <html lang={locale} style={brandStyle(shop.branding)}>
36
40
  <body>
41
+ {/* Telegram's Mini App script, only for a shop with a bot: nobody else can open it in Telegram. */}
42
+ {shop.features.telegramBot && (
43
+ <Script src={TELEGRAM_WEB_APP_SCRIPT} strategy="beforeInteractive" />
44
+ )}
37
45
  <Providers shop={shop}>
46
+ <TelegramShell />
38
47
  <header className="site">
39
48
  <nav>
40
49
  <Link href="/" className="brand">
@@ -49,6 +58,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
49
58
  {collection.title}
50
59
  </Link>
51
60
  ))}
61
+ {/* Only when an article is readable in this language (`features.blog`). */}
62
+ {shop.features.blog && <Link href="/blog">{t.blog}</Link>}
52
63
  <Link href="/search">{t.search}</Link>
53
64
  <Link href="/account">{t.account}</Link>
54
65
  <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
+ }