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,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
|
);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useTelegramBackButton,
|
|
5
|
+
useTelegramSignIn,
|
|
6
|
+
useTelegramWebApp,
|
|
7
|
+
} from '@magicstoreai/hydrogen';
|
|
8
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
9
|
+
import { useEffect, useRef } from 'react';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The storefront as a Telegram Mini App. Renders nothing; outside Telegram every hook is a no-op.
|
|
13
|
+
*
|
|
14
|
+
* - marks `<html>` with `data-telegram` / `data-color-scheme`, so `globals.css` takes Telegram's
|
|
15
|
+
* theme colors;
|
|
16
|
+
* - shows the header's back arrow on every page but the home page. It goes back only while there is
|
|
17
|
+
* history of ours behind the page; on the history entry the Mini App opened on (a deep link from
|
|
18
|
+
* the bot, the return from a payment page) it goes home. Where the browser has the Navigation API
|
|
19
|
+
* the entry is told by its history index, so revisiting the first page through a link still goes
|
|
20
|
+
* back; elsewhere by its path;
|
|
21
|
+
* - signs the buyer in with the launch data once per visit — the only automatic Telegram sign-in in
|
|
22
|
+
* the app (`/account` offers a manual one). A failure keeps the buyer browsing as a guest;
|
|
23
|
+
* `/account` explains it. A referral in the `startapp` parameter is read by the server.
|
|
24
|
+
*/
|
|
25
|
+
export function TelegramShell() {
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
const router = useRouter();
|
|
28
|
+
useTelegramWebApp({ documentAttributes: true });
|
|
29
|
+
// The page the Mini App opened on: history before it is not ours.
|
|
30
|
+
const entry = useRef<{ pathname: string; index: number | null }>({ pathname, index: null });
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
entry.current.index = historyIndex();
|
|
33
|
+
}, []);
|
|
34
|
+
useTelegramBackButton(pathname !== '/', () => {
|
|
35
|
+
const index = historyIndex();
|
|
36
|
+
const atEntry =
|
|
37
|
+
index !== null && entry.current.index !== null
|
|
38
|
+
? index <= entry.current.index
|
|
39
|
+
: pathname === entry.current.pathname;
|
|
40
|
+
if (atEntry) {
|
|
41
|
+
router.push('/');
|
|
42
|
+
} else {
|
|
43
|
+
router.back();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
useTelegramSignIn();
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The current history entry's index (Navigation API), or null where the browser lacks the API. */
|
|
51
|
+
function historyIndex(): number | null {
|
|
52
|
+
const { navigation } = window as { navigation?: { currentEntry: NavigationHistoryEntry | null } };
|
|
53
|
+
return navigation?.currentEntry?.index ?? null;
|
|
54
|
+
}
|
package/template/lib/errors.ts
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
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 {
|
|
5
6
|
return error instanceof MagicStoreError ? (error.detail ?? error.message) : fallback;
|
|
6
7
|
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Why sharing the Telegram phone (`POST /customer/phone-verification`) failed. `CONTACT_EXPIRED`
|
|
11
|
+
* asks to share again; `CONTACT_INVALID`, `CONTACT_NOT_OWN`, `INVALID_PHONE` and `TAKEN` show the
|
|
12
|
+
* API's localized message for `telegramContact`; anything else falls back to `describe()`.
|
|
13
|
+
*/
|
|
14
|
+
export function describePhoneShare(
|
|
15
|
+
error: unknown,
|
|
16
|
+
text: { sharePhoneAgain: string; tryAgainLater: string },
|
|
17
|
+
): string {
|
|
18
|
+
if (error instanceof MagicStoreError) {
|
|
19
|
+
const refusal = error.errors.find((entry) => entry.field === 'telegramContact');
|
|
20
|
+
if (refusal?.code === 'CONTACT_EXPIRED') {
|
|
21
|
+
return text.sharePhoneAgain;
|
|
22
|
+
}
|
|
23
|
+
if (refusal !== undefined) {
|
|
24
|
+
return refusal.message;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return describe(error, text.tryAgainLater);
|
|
28
|
+
}
|
package/template/lib/i18n.ts
CHANGED
|
@@ -57,6 +57,22 @@ const en = {
|
|
|
57
57
|
`${count} ${plural('en', count, { one: 'review', other: 'reviews' })}`,
|
|
58
58
|
recommendUs: (percent: number) => `${percent}% recommend us`,
|
|
59
59
|
leaveReview: 'Leave a review',
|
|
60
|
+
// Blog
|
|
61
|
+
blog: 'Blog',
|
|
62
|
+
allArticles: 'All articles',
|
|
63
|
+
featured: "Editor's picks",
|
|
64
|
+
readingTime: (minutes: number) => `${minutes} min read`,
|
|
65
|
+
relatedArticles: 'Read also',
|
|
66
|
+
productsInArticle: 'Products in this article',
|
|
67
|
+
wasHelpful: 'Was this article helpful?',
|
|
68
|
+
helpful: 'Yes',
|
|
69
|
+
notHelpful: 'No',
|
|
70
|
+
helpfulCount: (count: number) => `Helpful for ${count}`,
|
|
71
|
+
thanksForVote: 'Thank you for your feedback',
|
|
72
|
+
searchArticles: 'Search articles',
|
|
73
|
+
sortNewest: 'Newest',
|
|
74
|
+
sortPopular: 'Popular',
|
|
75
|
+
tag: 'Tag',
|
|
60
76
|
// Product
|
|
61
77
|
addToCart: 'Add to cart',
|
|
62
78
|
addedToCart: 'Added to cart.',
|
|
@@ -101,6 +117,16 @@ const en = {
|
|
|
101
117
|
signIn: 'Sign in',
|
|
102
118
|
sendCode: 'Send code',
|
|
103
119
|
smsCode: 'Code from the SMS',
|
|
120
|
+
// Telegram Mini App
|
|
121
|
+
signInWithTelegram: 'Sign in with Telegram',
|
|
122
|
+
signingIn: 'Signing in…',
|
|
123
|
+
telegramRelaunch:
|
|
124
|
+
'Your Telegram sign-in has expired. Close the shop and open it again from the bot.',
|
|
125
|
+
telegramUnavailable: 'Sign-in with Telegram is not available in this shop.',
|
|
126
|
+
orWithPhone: 'Or sign in with your phone number',
|
|
127
|
+
sharePhone: 'Share phone number',
|
|
128
|
+
sharePhoneHint: 'Share your Telegram phone number so the shop can reach you about orders.',
|
|
129
|
+
sharePhoneAgain: 'That contact has expired. Share it again.',
|
|
104
130
|
signOut: 'Sign out',
|
|
105
131
|
orders: 'Orders',
|
|
106
132
|
loadingOrders: 'Loading orders…',
|
|
@@ -156,6 +182,21 @@ const ru: Messages = {
|
|
|
156
182
|
`${count} ${plural('ru', count, { one: 'отзыв', few: 'отзыва', other: 'отзывов' })}`,
|
|
157
183
|
recommendUs: (percent) => `${percent}% рекомендуют нас`,
|
|
158
184
|
leaveReview: 'Оставить отзыв',
|
|
185
|
+
blog: 'Блог',
|
|
186
|
+
allArticles: 'Все статьи',
|
|
187
|
+
featured: 'Выбор редакции',
|
|
188
|
+
readingTime: (minutes) => `${minutes} мин чтения`,
|
|
189
|
+
relatedArticles: 'Читайте также',
|
|
190
|
+
productsInArticle: 'Товары из статьи',
|
|
191
|
+
wasHelpful: 'Статья была полезной?',
|
|
192
|
+
helpful: 'Да',
|
|
193
|
+
notHelpful: 'Нет',
|
|
194
|
+
helpfulCount: (count) => `Полезно: ${count}`,
|
|
195
|
+
thanksForVote: 'Спасибо за отзыв',
|
|
196
|
+
searchArticles: 'Поиск по статьям',
|
|
197
|
+
sortNewest: 'Новые',
|
|
198
|
+
sortPopular: 'Популярные',
|
|
199
|
+
tag: 'Тег',
|
|
159
200
|
addToCart: 'В корзину',
|
|
160
201
|
addedToCart: 'Добавлено в корзину.',
|
|
161
202
|
couldNotAdd: 'Не удалось добавить в корзину.',
|
|
@@ -196,6 +237,14 @@ const ru: Messages = {
|
|
|
196
237
|
signIn: 'Войти',
|
|
197
238
|
sendCode: 'Получить код',
|
|
198
239
|
smsCode: 'Код из SMS',
|
|
240
|
+
signInWithTelegram: 'Войти через Telegram',
|
|
241
|
+
signingIn: 'Входим…',
|
|
242
|
+
telegramRelaunch: 'Вход через Telegram устарел. Закройте магазин и откройте его снова из бота.',
|
|
243
|
+
telegramUnavailable: 'Вход через Telegram в этом магазине недоступен.',
|
|
244
|
+
orWithPhone: 'Или войдите по номеру телефона',
|
|
245
|
+
sharePhone: 'Поделиться номером',
|
|
246
|
+
sharePhoneHint: 'Поделитесь номером из Telegram, чтобы магазин мог связаться с вами по заказам.',
|
|
247
|
+
sharePhoneAgain: 'Контакт устарел. Поделитесь им ещё раз.',
|
|
199
248
|
signOut: 'Выйти',
|
|
200
249
|
orders: 'Заказы',
|
|
201
250
|
loadingOrders: 'Загружаем заказы…',
|
|
@@ -248,6 +297,21 @@ const uz: Messages = {
|
|
|
248
297
|
reviewsCount: (count) => `${count} ta sharh`,
|
|
249
298
|
recommendUs: (percent) => `${percent}% bizni tavsiya qiladi`,
|
|
250
299
|
leaveReview: 'Sharh qoldirish',
|
|
300
|
+
blog: 'Blog',
|
|
301
|
+
allArticles: 'Barcha maqolalar',
|
|
302
|
+
featured: 'Muharrir tanlovi',
|
|
303
|
+
readingTime: (minutes) => `${minutes} daqiqa o'qish`,
|
|
304
|
+
relatedArticles: "Shuningdek o'qing",
|
|
305
|
+
productsInArticle: 'Maqoladagi mahsulotlar',
|
|
306
|
+
wasHelpful: "Maqola foydali bo'ldimi?",
|
|
307
|
+
helpful: 'Ha',
|
|
308
|
+
notHelpful: "Yo'q",
|
|
309
|
+
helpfulCount: (count) => `Foydali: ${count}`,
|
|
310
|
+
thanksForVote: 'Fikringiz uchun rahmat',
|
|
311
|
+
searchArticles: 'Maqolalardan qidirish',
|
|
312
|
+
sortNewest: 'Yangilari',
|
|
313
|
+
sortPopular: 'Ommaboplari',
|
|
314
|
+
tag: 'Teg',
|
|
251
315
|
addToCart: "Savatga qo'shish",
|
|
252
316
|
addedToCart: "Savatga qo'shildi.",
|
|
253
317
|
couldNotAdd: "Savatga qo'shib bo'lmadi.",
|
|
@@ -288,6 +352,15 @@ const uz: Messages = {
|
|
|
288
352
|
signIn: 'Kirish',
|
|
289
353
|
sendCode: 'Kod olish',
|
|
290
354
|
smsCode: 'SMS dagi kod',
|
|
355
|
+
signInWithTelegram: 'Telegram orqali kirish',
|
|
356
|
+
signingIn: 'Kirilmoqda…',
|
|
357
|
+
telegramRelaunch: "Telegram orqali kirish eskirdi. Do'konni yoping va botdan qayta oching.",
|
|
358
|
+
telegramUnavailable: "Bu do'konda Telegram orqali kirish mavjud emas.",
|
|
359
|
+
orWithPhone: 'Yoki telefon raqami bilan kiring',
|
|
360
|
+
sharePhone: 'Raqamni ulashish',
|
|
361
|
+
sharePhoneHint:
|
|
362
|
+
"Do'kon buyurtmalar bo'yicha bog'lana olishi uchun Telegram raqamingizni ulashing.",
|
|
363
|
+
sharePhoneAgain: 'Kontakt eskirdi. Uni qayta ulashing.',
|
|
291
364
|
signOut: 'Chiqish',
|
|
292
365
|
orders: 'Buyurtmalar',
|
|
293
366
|
loadingOrders: 'Buyurtmalar yuklanmoqda…',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The storefront's public origin for absolute URLs (sitemap, canonical links): `SITE_URL`, else
|
|
5
|
+
* the shop's primary domain.
|
|
6
|
+
*/
|
|
7
|
+
export function siteOrigin(shop: Pick<Schema<'Shop'>, 'primaryDomain'>): string {
|
|
8
|
+
return (
|
|
9
|
+
process.env.SITE_URL?.replace(/\/+$/, '') ??
|
|
10
|
+
`https://${shop.primaryDomain ?? process.env.MAGICSTORE_SHOP_DOMAIN}`
|
|
11
|
+
);
|
|
12
|
+
}
|
package/template/llms.txt
CHANGED
|
@@ -11,12 +11,15 @@ Every storefront — hand-written or generated — imports the SDK **only** thro
|
|
|
11
11
|
| ------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------- |
|
|
12
12
|
| `@magicstoreai/storefront-client` | `createStorefrontClient`: one typed method per API `operationId`, `MagicStoreError`, `paginate`, `Schema<>` | anywhere, no React |
|
|
13
13
|
| `@magicstoreai/hydrogen` | React: `MagicStoreProvider`, `useCart`, `useCustomer`, `useWishlist`, `<Money>`, `<Image>`, … | client (`"use client"`) |
|
|
14
|
-
| `@magicstoreai/hydrogen/core` | The same state without React: `CartController`, `CustomerSessionController`, `formatMoney`, variants
|
|
14
|
+
| `@magicstoreai/hydrogen/core` | The same state without React: `CartController`, `CustomerSessionController`, `formatMoney`, variants, blog | anywhere |
|
|
15
15
|
| `@magicstoreai/hydrogen/server` | `nextCacheFetch` (cache tags), `createWebhookHandler` (signed webhooks → revalidation) | server, edge |
|
|
16
|
-
| `@magicstoreai/hydrogen/seo` | `pageMeta`, `productJsonLd`, `breadcrumbJsonLd`, `jsonLdScript`
|
|
16
|
+
| `@magicstoreai/hydrogen/seo` | `pageMeta`, `productJsonLd`, `articleMeta`, `articleJsonLd`, `breadcrumbJsonLd`, `jsonLdScript` | anywhere |
|
|
17
17
|
|
|
18
18
|
Never import from `src/` or `dist/` of a package, and never call `/api/v2/storefront/…` by URL —
|
|
19
|
-
call the client method.
|
|
19
|
+
call the client method. A server component takes only components (`<Image>`, `<Money>`, …) from
|
|
20
|
+
`@magicstoreai/hydrogen`, a client module; functions and classes (`paginationState`,
|
|
21
|
+
`MagicStoreError`, `formatMoney`) come from `/core`, `/server`, `/seo` or
|
|
22
|
+
`@magicstoreai/storefront-client`. `npx create-magic-storefront check` fails on any of these.
|
|
20
23
|
|
|
21
24
|
## Start
|
|
22
25
|
|
|
@@ -25,14 +28,14 @@ npm create magic-storefront@latest my-shop -- --shop shop.example.uz
|
|
|
25
28
|
```
|
|
26
29
|
|
|
27
30
|
scaffolds a Next.js App Router storefront (home from the merchant's sections or your own, catalog,
|
|
28
|
-
product, search, cart, checkout, sign-in, orders, SEO, webhook revalidation). Or install the packages:
|
|
31
|
+
product, search, blog, cart, checkout, sign-in, orders, SEO, webhook revalidation). Or install the packages:
|
|
29
32
|
`npm i @magicstoreai/storefront-client @magicstoreai/hydrogen`.
|
|
30
33
|
|
|
31
34
|
## Docs
|
|
32
35
|
|
|
33
36
|
- `PAGES.md` (in a scaffolded storefront; `examples/starter/PAGES.md` in the SDK repository): how to
|
|
34
37
|
build each page type — home (from the `/home` sections or custom), collection, product, search,
|
|
35
|
-
cart, checkout, account — with its data calls, cache tags, SEO and required states.
|
|
38
|
+
blog, cart, checkout, account — with its data calls, cache tags, SEO and required states.
|
|
36
39
|
- [Hydrogen catalogue](node_modules/@magicstoreai/hydrogen/CATALOGUE.md): every export with its props
|
|
37
40
|
or signature, an example, and the API operations it calls. In the SDK repository:
|
|
38
41
|
`packages/hydrogen/CATALOGUE.md`.
|
|
@@ -40,7 +43,8 @@ product, search, cart, checkout, sign-in, orders, SEO, webhook revalidation). Or
|
|
|
40
43
|
idempotency, retries, errors, pagination.
|
|
41
44
|
- [Hydrogen README](node_modules/@magicstoreai/hydrogen/README.md): provider, hooks, server helpers.
|
|
42
45
|
- The API itself (normative): the backend repository's `docs/api/v2/` — `standards.md` (wire format,
|
|
43
|
-
every error code), `authentication.md`, `cart.md`, `checkout.md`, `customer.md`, `
|
|
46
|
+
every error code), `authentication.md`, `cart.md`, `checkout.md`, `customer.md`, `blog.md`,
|
|
47
|
+
`webhooks.md`.
|
|
44
48
|
Operation shapes: `Schema<'Name'>` and the client's method types, generated from the OpenAPI spec.
|
|
45
49
|
|
|
46
50
|
## Two credentials
|
|
@@ -50,7 +54,7 @@ product, search, cart, checkout, sign-in, orders, SEO, webhook revalidation). Or
|
|
|
50
54
|
any other origin the API answers `ORIGIN_NOT_ALLOWED`. Without a token for the browser, proxy
|
|
51
55
|
browser calls through your own server (the starter's `app/storefront-api/[...path]`).
|
|
52
56
|
- **Which customer** — a customer session: access token (60 min) + refresh token (30 days,
|
|
53
|
-
single-use), only on customer routes. `useCustomer()` signs in (OTP, Telegram, OQ, Click),
|
|
57
|
+
single-use), only on customer routes. `useCustomer()` signs in (OTP, Telegram, phone-only, OQ, Click),
|
|
54
58
|
refreshes and signs out; `MagicStoreProvider` keeps the session in storage.
|
|
55
59
|
|
|
56
60
|
Public catalog reads need no customer. Render them on the server with a client whose `fetch` is
|
|
@@ -70,6 +74,26 @@ Public catalog reads need no customer. Render them on the server with a client w
|
|
|
70
74
|
merchant arrange the page from the admin, or compose a custom home from catalog and content
|
|
71
75
|
data. When you render it, an unknown type renders as nothing.
|
|
72
76
|
|
|
77
|
+
## Blog
|
|
78
|
+
|
|
79
|
+
- Only when `shop.features.blog` is on: at least one article is readable in the response language.
|
|
80
|
+
`shop.blog` is the blog home's `{ title, description }` or `null`.
|
|
81
|
+
- `blogArticlesIndex` (12 a page; `sort`, `q`, `filter[category|author|tag|featured]`),
|
|
82
|
+
`blogArticlesShow`, `blogArticlesRelated`, `blogCategoriesIndex`, `blogAuthorsIndex`. An unknown
|
|
83
|
+
`sort` or filter is `422 VALIDATION_FAILED`, never ignored.
|
|
84
|
+
- Articles are strict about language: one not written in the response locale is a `404`, with no
|
|
85
|
+
fallback. Build `hreflang` only from `availableLocales` (`articleMeta` does).
|
|
86
|
+
- `bodyHtml` is sanitized by the server. Split it with `articleBodyBlocks()` (`/core`, server-safe):
|
|
87
|
+
`<product-embed handle>` → a product card, `<oembed url>` → media, `<aside data-callout>` → a
|
|
88
|
+
callout. Fetch the embedded products in one `productsIndex({ query: { 'filter[handles]' } })` per
|
|
89
|
+
`embeddedProductFilters(article.products)` value, put them back in order with
|
|
90
|
+
`orderEmbeddedProducts`, and drop a marker whose product did not come back.
|
|
91
|
+
- «Was this helpful?»: `useArticleVote(handle, helpfulCount)`. A guest votes as the browser
|
|
92
|
+
(`X-Anonymous-Id`, `anonymousId()`), a customer as themselves. Report reads with
|
|
93
|
+
`useAnalytics().articleView(article.id)` (`ARTICLE_VIEW`).
|
|
94
|
+
- Blog reads are cached under `magicstore:pages` (`PAGES_UPDATED`); view and vote counters may be
|
|
95
|
+
up to 10 minutes old.
|
|
96
|
+
|
|
73
97
|
## Credentials in the browser
|
|
74
98
|
|
|
75
99
|
The cart id and the checkout id are credentials. Never log them and never put them in a URL or
|
|
@@ -120,6 +144,40 @@ for buyers), quote `error.requestId` when reporting. GET calls retry on 429 / 5x
|
|
|
120
144
|
|
|
121
145
|
The full list, with the `meta` each code carries, is in the backend's `docs/api/v2/standards.md`.
|
|
122
146
|
|
|
147
|
+
## Telegram Mini App
|
|
148
|
+
|
|
149
|
+
The same storefront runs inside Telegram as a Mini App (`shop.features.telegramBot` says the shop has
|
|
150
|
+
a bot). The app loads `TELEGRAM_WEB_APP_SCRIPT` (from `@magicstoreai/hydrogen/core`) in the document
|
|
151
|
+
head; the SDK never injects it.
|
|
152
|
+
|
|
153
|
+
- `useTelegramWebApp({ documentAttributes: true })`: `status` is `unknown` on the server and the first
|
|
154
|
+
client render, then `telegram` or `browser` (a Mini App = `window.Telegram.WebApp` with a non-empty
|
|
155
|
+
`initData`). Marks `<html data-telegram data-color-scheme>`; map Telegram's `--tg-theme-*` CSS
|
|
156
|
+
variables onto your tokens under `html[data-telegram]`.
|
|
157
|
+
- `useTelegramSignIn()`: signs in once per provider with `initData` (`POST /auth/telegram`) when
|
|
158
|
+
opened from the bot, unless the stored session was signed in as that same Telegram user (any other
|
|
159
|
+
session is signed out first). `failed` + `UNAUTHENTICATED` → "reopen the shop from the bot";
|
|
160
|
+
`FORBIDDEN` → the shop has no bot. `status` / `error` are shared under one provider, so an
|
|
161
|
+
`{ auto: false }` page shows the automatic attempt's progress and failure.
|
|
162
|
+
- `useTelegramBackButton(visible, onBack)`, `useTelegramMainButton(options | null)`: one button per
|
|
163
|
+
page, registrations stack (the latest owns it); MainButton changes apply in place.
|
|
164
|
+
- `useCustomer().shareTelegramPhone()`: `requestContact` → `POST /customer/phone-verification` with
|
|
165
|
+
the signed `response`; `null` when declined; rejects `UNAUTHENTICATED` at once when nobody is
|
|
166
|
+
signed in (show the button to signed-in customers only, and only while
|
|
167
|
+
`useTelegramWebApp().canRequestContact` — older clients resolve `null` without asking). Refusals: `VALIDATION_FAILED` with `CONTACT_EXPIRED`
|
|
168
|
+
(share again), `CONTACT_INVALID`, `CONTACT_NOT_OWN`, `INVALID_PHONE`, `TAKEN` on `telegramContact`.
|
|
169
|
+
- `useCustomer().signInWithPhone(phone)`: only when `shop.features.otpLogin === false`.
|
|
170
|
+
|
|
171
|
+
- `useCustomer().verifyPhoneWithTelegram(telegramContact)`: the same proof with a contact the app
|
|
172
|
+
got itself. `/core`: `TelegramWebAppController.mainButton(options)` → `{ update, release }`.
|
|
173
|
+
- Call `useTelegramSignIn()` with `auto` in exactly one app-wide component; anywhere else
|
|
174
|
+
`{ auto: false }` + `signIn()`. Load the script only when `shop.features.telegramBot` is on. Starter
|
|
175
|
+
reference: `examples/starter/PAGES.md` → "Running as a Telegram Mini App".
|
|
176
|
+
|
|
177
|
+
The SDK does not validate `initData` (the server does), does not parse referrals out of `startapp`
|
|
178
|
+
(the server attributes them), and never sends `responseUnsafe`. Never put `initData` in a URL, a log
|
|
179
|
+
or analytics.
|
|
180
|
+
|
|
123
181
|
## What the SDK does not do
|
|
124
182
|
|
|
125
183
|
- Compute or round prices, discounts, taxes, delivery fees or stock — the API returns them.
|