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,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
  );
@@ -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
+ }
@@ -1,6 +1,28 @@
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 {
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
+ }
@@ -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 | anywhere |
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` | anywhere |
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. `npx create-magic-storefront check` fails on either.
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`, `webhooks.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.