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.
- package/README.md +6 -0
- package/dist/index.js +40 -3
- package/package.json +1 -1
- package/template/.claude/skills/storefront-design/SKILL.md +19 -18
- package/template/.claude/skills/storefront-verify/SKILL.md +10 -9
- package/template/AGENTS.md +21 -13
- package/template/PAGES.md +111 -14
- package/template/README.md +7 -1
- package/template/app/account/page.tsx +126 -10
- 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 +105 -3
- package/template/app/layout.tsx +11 -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/components/telegram-shell.tsx +54 -0
- package/template/lib/errors.ts +23 -1
- package/template/lib/i18n.ts +73 -0
- package/template/lib/site.ts +12 -0
- package/template/llms.txt +65 -7
- package/template/package.json +2 -0
|
@@ -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
|
+
}
|
package/template/app/globals.css
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/template/app/layout.tsx
CHANGED
|
@@ -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 />
|
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
|
+
}
|