create-magic-storefront 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import { ArticleGrid } from '@/components/article-card';
4
+ import { ARTICLES_PER_PAGE, pageParam } from '@/components/blog-listing';
5
+ import { Pager } from '@/components/pager';
6
+ import { api, locale } from '@/lib/api';
7
+ import { messages } from '@/lib/i18n';
8
+
9
+ type Props = { params: Promise<{ handle: string }>; searchParams: Promise<{ page?: string }> };
10
+
11
+ /**
12
+ * Articles with a tag. There is no tag list in the API: the tag's title comes from its articles,
13
+ * and a tag without any (in this language) is a 404.
14
+ */
15
+ async function tagged(handle: string, page: number) {
16
+ const articles = await api.blogArticlesIndex({
17
+ query: { page, perPage: ARTICLES_PER_PAGE, 'filter[tag]': handle },
18
+ });
19
+ if (articles.data.length === 0) {
20
+ notFound();
21
+ }
22
+ const detail = await api.blogArticlesShow({ path: { handle: articles.data[0]!.handle } });
23
+ const title = detail.data.tags.find((tag) => tag.handle === handle)?.title ?? handle;
24
+ return { articles, title };
25
+ }
26
+
27
+ export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
28
+ const { handle } = await params;
29
+ const { title } = await tagged(handle, pageParam((await searchParams).page));
30
+ return { title: `${messages(locale).tag}: ${title}`, robots: { index: false, follow: true } };
31
+ }
32
+
33
+ export default async function BlogTagPage({ params, searchParams }: Props) {
34
+ const { handle } = await params;
35
+ const page = pageParam((await searchParams).page);
36
+ const [{ articles, title }, { data: shop }] = await Promise.all([
37
+ tagged(handle, page),
38
+ api.shop(),
39
+ ]);
40
+
41
+ return (
42
+ <div className="stack">
43
+ <h1>#{title}</h1>
44
+ <ArticleGrid articles={articles.data} timeZone={shop.timezone} />
45
+ <Pager
46
+ meta={articles.meta.pagination}
47
+ href={(p) => `/blog/tag/${handle}${p > 1 ? `?page=${p}` : ''}`}
48
+ />
49
+ </div>
50
+ );
51
+ }
@@ -72,6 +72,8 @@ header.site .brand img {
72
72
  .card img,
73
73
  .card .noimg {
74
74
  width: 100%;
75
+ /* The intrinsic width/height attributes (no layout shift) must not override the ratio. */
76
+ height: auto;
75
77
  aspect-ratio: 1;
76
78
  object-fit: cover;
77
79
  background: #f3f3f6;
@@ -308,3 +310,69 @@ footer.site nav {
308
310
  gap: 16px;
309
311
  flex-wrap: wrap;
310
312
  }
313
+ .articles {
314
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
315
+ }
316
+ .article-card img,
317
+ .article-card .noimg {
318
+ aspect-ratio: 16 / 9;
319
+ }
320
+ .article-card .badge {
321
+ align-self: flex-start;
322
+ }
323
+ .chips a {
324
+ display: inline-flex;
325
+ align-items: center;
326
+ gap: 6px;
327
+ min-height: 44px;
328
+ padding: 0 14px;
329
+ border: 1px solid var(--line);
330
+ border-radius: 999px;
331
+ text-decoration: none;
332
+ }
333
+ .chips a[aria-current='page'] {
334
+ background: var(--accent);
335
+ border-color: var(--accent);
336
+ color: var(--on-accent);
337
+ }
338
+ .article {
339
+ max-width: 760px;
340
+ margin: 0 auto;
341
+ }
342
+ .article-cover {
343
+ width: 100%;
344
+ height: auto;
345
+ border-radius: 8px;
346
+ }
347
+ .prose {
348
+ line-height: 1.65;
349
+ }
350
+ .prose img {
351
+ max-width: 100%;
352
+ height: auto;
353
+ }
354
+ .prose table {
355
+ display: block;
356
+ overflow-x: auto;
357
+ }
358
+ .article-product {
359
+ max-width: 260px;
360
+ margin: 16px 0;
361
+ }
362
+ .callout {
363
+ margin: 16px 0;
364
+ padding: 12px 16px;
365
+ border-left: 4px solid var(--accent);
366
+ border-radius: 8px;
367
+ background: #f6f7fb;
368
+ }
369
+ .callout[data-callout='warning'] {
370
+ border-left-color: var(--danger);
371
+ }
372
+ .author img {
373
+ border-radius: 50%;
374
+ object-fit: cover;
375
+ }
376
+ .author h1 {
377
+ margin: 0;
378
+ }
@@ -49,6 +49,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
49
49
  {collection.title}
50
50
  </Link>
51
51
  ))}
52
+ {/* Only when an article is readable in this language (`features.blog`). */}
53
+ {shop.features.blog && <Link href="/blog">{t.blog}</Link>}
52
54
  <Link href="/search">{t.search}</Link>
53
55
  <Link href="/account">{t.account}</Link>
54
56
  <CartLink />
@@ -1,7 +1,15 @@
1
1
  import type { MetadataRoute } from 'next';
2
+ import type { Schema } from '@magicstoreai/storefront-client';
2
3
  import { api } from '@/lib/api';
4
+ import { siteOrigin } from '@/lib/site';
3
5
 
4
- const PATHS = { PAGE: 'pages', COLLECTION: 'collections', PRODUCT: 'products' } as const;
6
+ /** URL prefix per `SitemapEntry.type`; a type added to the API later is skipped, not linked. */
7
+ const PATHS: Partial<Record<string, string>> & Record<Schema<'SitemapEntry'>['type'], string> = {
8
+ PAGE: 'pages',
9
+ COLLECTION: 'collections',
10
+ PRODUCT: 'products',
11
+ ARTICLE: 'blog',
12
+ };
5
13
  /** The sitemap protocol allows 50 000 URLs in one file: 50 pages of `GET /sitemap`. */
6
14
  const PER_PAGE = 1000;
7
15
  const MAX_PAGES = 50;
@@ -10,21 +18,23 @@ const MAX_PAGES = 50;
10
18
  export const dynamic = 'force-dynamic';
11
19
 
12
20
  /**
13
- * Every published product, collection and page (`GET /sitemap`, up to 1000 rows a page). URLs are
21
+ * Every published product, collection, page and blog article (`GET /sitemap`, up to 1000 rows a page). URLs are
14
22
  * absolute on `SITE_URL`, else the shop's primary domain.
15
23
  */
16
24
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
17
25
  const { data: shop } = await api.shop();
18
- const origin =
19
- process.env.SITE_URL?.replace(/\/+$/, '') ??
20
- `https://${shop.primaryDomain ?? process.env.MAGICSTORE_SHOP_DOMAIN}`;
26
+ const origin = siteOrigin(shop);
21
27
 
22
28
  const entries: MetadataRoute.Sitemap = [{ url: `${origin}/` }];
23
29
  for (let page = 1; page <= MAX_PAGES; page++) {
24
30
  const { data, meta } = await api.sitemap({ query: { page, perPage: PER_PAGE } });
25
31
  for (const row of data) {
32
+ const prefix = PATHS[row.type];
33
+ if (!prefix) {
34
+ continue;
35
+ }
26
36
  entries.push({
27
- url: `${origin}/${PATHS[row.type]}/${row.handle}`,
37
+ url: `${origin}/${prefix}/${row.handle}`,
28
38
  ...(row.updatedAt && { lastModified: row.updatedAt }),
29
39
  });
30
40
  }
@@ -16,3 +16,10 @@ export function SearchView({ query, resultsCount }: { query: string; resultsCoun
16
16
  useEffect(() => analytics.search(query, resultsCount), [analytics, query, resultsCount]);
17
17
  return null;
18
18
  }
19
+
20
+ /** Reports an article read once per article (`ARTICLE_VIEW`); renders nothing. */
21
+ export function ArticleView({ articleId }: { articleId: string }) {
22
+ const analytics = useAnalytics();
23
+ useEffect(() => analytics.articleView(articleId), [analytics, articleId]);
24
+ return null;
25
+ }
@@ -0,0 +1,58 @@
1
+ import { articleBodyBlocks, type ArticleBlock } from '@magicstoreai/hydrogen/core';
2
+ import type { Schema } from '@magicstoreai/storefront-client';
3
+ import { ProductCard } from '@/components/product-grid';
4
+
5
+ type Product = Schema<'Product'>;
6
+
7
+ /**
8
+ * An article body (`bodyHtml`, sanitized by the server) with its inert markup turned into
9
+ * components: product cards from `products` (a marker whose product the list did not return is
10
+ * dropped), media embeds as links, callouts as boxes.
11
+ */
12
+ export function ArticleBody({
13
+ bodyHtml,
14
+ products,
15
+ }: {
16
+ bodyHtml: string;
17
+ products: ReadonlyMap<string, Product>;
18
+ }) {
19
+ return <Blocks blocks={articleBodyBlocks(bodyHtml)} products={products} />;
20
+ }
21
+
22
+ function Blocks({
23
+ blocks,
24
+ products,
25
+ }: {
26
+ blocks: ArticleBlock[];
27
+ products: ReadonlyMap<string, Product>;
28
+ }) {
29
+ return blocks.map((block, index) => {
30
+ switch (block.type) {
31
+ case 'html':
32
+ return <div key={index} dangerouslySetInnerHTML={{ __html: block.html }} />;
33
+ case 'product': {
34
+ const product = products.get(block.handle);
35
+ return product ? (
36
+ <div key={index} className="article-product">
37
+ <ProductCard product={product} />
38
+ </div>
39
+ ) : null;
40
+ }
41
+ case 'embed':
42
+ return (
43
+ <p key={index}>
44
+ <a href={block.url} target="_blank" rel="noopener nofollow">
45
+ {block.url}
46
+ </a>
47
+ </p>
48
+ );
49
+ case 'callout':
50
+ return (
51
+ <aside key={index} className="callout" data-callout={block.kind}>
52
+ {block.title && <strong>{block.title}</strong>}
53
+ <Blocks blocks={block.blocks} products={products} />
54
+ </aside>
55
+ );
56
+ }
57
+ });
58
+ }
@@ -0,0 +1,54 @@
1
+ import { Image } from '@magicstoreai/hydrogen';
2
+ import type { Schema } from '@magicstoreai/storefront-client';
3
+ import Link from 'next/link';
4
+ import { locale } from '@/lib/api';
5
+ import { messages } from '@/lib/i18n';
6
+
7
+ type Article = Schema<'BlogArticle'>;
8
+
9
+ /** A date in the shop's time zone, so a server elsewhere does not move it by a day. */
10
+ export function ArticleDate({ iso, timeZone }: { iso: string; timeZone: string }) {
11
+ return <time dateTime={iso}>{formatDate(iso, timeZone)}</time>;
12
+ }
13
+
14
+ function formatDate(iso: string, timeZone: string): string {
15
+ try {
16
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone }).format(new Date(iso));
17
+ } catch {
18
+ // A time zone this runtime does not know: the date in UTC beats a failed page.
19
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(
20
+ new Date(iso),
21
+ );
22
+ }
23
+ }
24
+
25
+ /** One article as a card: cover, category, title, excerpt, author, date and reading time. */
26
+ export function ArticleCard({ article, timeZone }: { article: Article; timeZone: string }) {
27
+ const t = messages(locale);
28
+ return (
29
+ <Link href={`/blog/${article.handle}`} className="card article-card">
30
+ <Image data={article.image} alt={article.title} fallback={<div className="noimg" />} />
31
+ {article.category && <span className="badge">{article.category.title}</span>}
32
+ <strong>{article.title}</strong>
33
+ {article.excerpt && <span className="muted">{article.excerpt}</span>}
34
+ <span className="row muted">
35
+ {article.authorName && <span>{article.authorName}</span>}
36
+ <ArticleDate iso={article.publishedAt} timeZone={timeZone} />
37
+ <span>{t.readingTime(article.readingMinutes)}</span>
38
+ </span>
39
+ </Link>
40
+ );
41
+ }
42
+
43
+ export function ArticleGrid({ articles, timeZone }: { articles: Article[]; timeZone: string }) {
44
+ if (articles.length === 0) {
45
+ return <p className="muted">{messages(locale).nothingHere}</p>;
46
+ }
47
+ return (
48
+ <div className="grid articles">
49
+ {articles.map((article) => (
50
+ <ArticleCard key={article.id} article={article} timeZone={timeZone} />
51
+ ))}
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { useArticleVote, useMagicStore } from '@magicstoreai/hydrogen';
4
+ import { describe } from '@/lib/errors';
5
+ import { messages } from '@/lib/i18n';
6
+
7
+ /** «Was this helpful?»: a guest votes as this browser, a signed-in customer as themselves. */
8
+ export function ArticleVote({ handle, helpfulCount }: { handle: string; helpfulCount: number }) {
9
+ const t = messages(useMagicStore().locale);
10
+ const vote = useArticleVote(handle, helpfulCount);
11
+ const choose = (value: 'HELPFUL' | 'NOT_HELPFUL') =>
12
+ void (vote.viewerVote === value ? vote.clear() : vote.vote(value)).catch(() => undefined);
13
+
14
+ return (
15
+ <section className="stack" aria-live="polite">
16
+ <strong>{t.wasHelpful}</strong>
17
+ <div className="row">
18
+ <button
19
+ aria-pressed={vote.viewerVote === 'HELPFUL'}
20
+ disabled={vote.status === 'pending'}
21
+ onClick={() => choose('HELPFUL')}
22
+ >
23
+ {t.helpful}
24
+ </button>
25
+ <button
26
+ aria-pressed={vote.viewerVote === 'NOT_HELPFUL'}
27
+ disabled={vote.status === 'pending'}
28
+ onClick={() => choose('NOT_HELPFUL')}
29
+ >
30
+ {t.notHelpful}
31
+ </button>
32
+ {vote.helpfulCount > 0 && (
33
+ <span className="muted">{t.helpfulCount(vote.helpfulCount)}</span>
34
+ )}
35
+ </div>
36
+ {vote.error ? (
37
+ <p className="error">{describe(vote.error, t.tryAgainLater)}</p>
38
+ ) : (
39
+ vote.viewerVote !== null && <p className="muted">{t.thanksForVote}</p>
40
+ )}
41
+ </section>
42
+ );
43
+ }
@@ -0,0 +1,93 @@
1
+ import { MagicStoreError } from '@magicstoreai/storefront-client';
2
+ import Link from 'next/link';
3
+ import { ArticleGrid } from '@/components/article-card';
4
+ import { Pager } from '@/components/pager';
5
+ import { api, locale } from '@/lib/api';
6
+ import { describe } from '@/lib/errors';
7
+ import { messages } from '@/lib/i18n';
8
+
9
+ export type ArticlesQuery = NonNullable<
10
+ NonNullable<Parameters<typeof api.blogArticlesIndex>[0]>['query']
11
+ >;
12
+
13
+ /** 12 a page, the API's default: a blog page reads better short. */
14
+ export const ARTICLES_PER_PAGE = 12;
15
+
16
+ /**
17
+ * One page of articles with its pager. A query the API refuses (`422`: an unknown `sort`, a `q`
18
+ * over 200 characters) shows the API's message instead of failing the page. Articles in
19
+ * `exclude` (already shown above, e.g. editor's picks) are left out; when that leaves nothing on a
20
+ * single page, nothing renders — not even `heading`.
21
+ */
22
+ export async function ArticleListing({
23
+ query,
24
+ href,
25
+ timeZone,
26
+ exclude = [],
27
+ heading,
28
+ }: {
29
+ query: ArticlesQuery;
30
+ href: (page: number) => string;
31
+ timeZone: string;
32
+ exclude?: readonly string[];
33
+ heading?: string;
34
+ }) {
35
+ try {
36
+ const { data, meta } = await api.blogArticlesIndex({
37
+ query: { perPage: ARTICLES_PER_PAGE, ...query },
38
+ });
39
+ const articles = data.filter((article) => !exclude.includes(article.id));
40
+ if (articles.length === 0 && data.length > 0 && meta.pagination.totalPages <= 1) {
41
+ return null;
42
+ }
43
+ return (
44
+ <>
45
+ {heading && <h2>{heading}</h2>}
46
+ <ArticleGrid articles={articles} timeZone={timeZone} />
47
+ <Pager meta={meta.pagination} href={href} />
48
+ </>
49
+ );
50
+ } catch (error) {
51
+ if (error instanceof MagicStoreError && error.code === 'VALIDATION_FAILED') {
52
+ return <p className="error">{describe(error, messages(locale).tryAgainLater)}</p>;
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ /** The blog's categories as links; the current one is marked. Nothing when there are none. */
59
+ export async function CategoryNav({ current }: { current?: string }) {
60
+ const t = messages(locale);
61
+ const { data: categories } = await api.blogCategoriesIndex();
62
+ if (categories.length === 0) {
63
+ return null;
64
+ }
65
+ return (
66
+ <nav className="row chips" aria-label={t.blog}>
67
+ <Link href="/blog" aria-current={current === undefined ? 'page' : undefined}>
68
+ {t.allArticles}
69
+ </Link>
70
+ {categories.map((category) => (
71
+ <Link
72
+ key={category.id}
73
+ href={`/blog/category/${category.handle}`}
74
+ aria-current={current === category.handle ? 'page' : undefined}
75
+ >
76
+ {category.title} <span className="muted">{category.articlesCount}</span>
77
+ </Link>
78
+ ))}
79
+ </nav>
80
+ );
81
+ }
82
+
83
+ const SORTS = ['-publishedAt', 'publishedAt', 'relevance', '-recentViews'] as const;
84
+
85
+ /** `?sort=` when it is one the API knows; anything else means the default order. */
86
+ export function sortParam(value: string | undefined): ArticlesQuery['sort'] {
87
+ return SORTS.find((sort) => sort === value);
88
+ }
89
+
90
+ /** `?page=` as a page number, 1 for anything else. */
91
+ export function pageParam(value: string | undefined): number {
92
+ return Math.max(1, Number(value) || 1);
93
+ }
@@ -1,9 +1,15 @@
1
- import { paginationState, type Pagination } from '@magicstoreai/hydrogen';
1
+ import { paginationState } from '@magicstoreai/hydrogen/core';
2
+ import type { Schema } from '@magicstoreai/storefront-client';
2
3
  import Link from 'next/link';
3
4
  import { locale } from '@/lib/api';
4
5
  import { messages } from '@/lib/i18n';
5
6
 
6
- /** Server-rendered page links (`paginationState` is plain arithmetic, no hooks). */
7
+ type Pagination = Schema<'Pagination'>;
8
+
9
+ /**
10
+ * Server-rendered page links. `paginationState` comes from `/core`: the root entry is a client
11
+ * module, and a server component cannot call its functions.
12
+ */
7
13
  export function Pager({ meta, href }: { meta: Pagination; href: (page: number) => string }) {
8
14
  if (meta.totalPages <= 1) {
9
15
  return null;
@@ -28,32 +28,39 @@ function ProductBadges({ product }: { product: Schema<'Product'> }) {
28
28
  );
29
29
  }
30
30
 
31
- export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
31
+ /** One product as a card: the whole card is one link. */
32
+ export function ProductCard({ product }: { product: Schema<'Product'> }) {
32
33
  const t = messages(locale);
34
+ return (
35
+ <Link href={`/products/${product.handle}`} className="card">
36
+ <Image
37
+ data={product.featuredImage}
38
+ alt={product.title}
39
+ fallback={<div className="noimg" />}
40
+ />
41
+ <ProductBadges product={product} />
42
+ <span>{product.title}</span>
43
+ <span className="row">
44
+ <Money data={product.price} fallback={<span className="muted">—</span>} />
45
+ {product.compareAtPrice && (
46
+ <s className="muted">
47
+ <Money data={product.compareAtPrice} />
48
+ </s>
49
+ )}
50
+ </span>
51
+ {!product.availableForSale && <span className="muted">{t.soldOut}</span>}
52
+ </Link>
53
+ );
54
+ }
55
+
56
+ export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
33
57
  if (products.length === 0) {
34
- return <p className="muted">{t.nothingHere}</p>;
58
+ return <p className="muted">{messages(locale).nothingHere}</p>;
35
59
  }
36
60
  return (
37
61
  <div className="grid">
38
62
  {products.map((product) => (
39
- <Link key={product.id} href={`/products/${product.handle}`} className="card">
40
- <Image
41
- data={product.featuredImage}
42
- alt={product.title}
43
- fallback={<div className="noimg" />}
44
- />
45
- <ProductBadges product={product} />
46
- <span>{product.title}</span>
47
- <span className="row">
48
- <Money data={product.price} fallback={<span className="muted">—</span>} />
49
- {product.compareAtPrice && (
50
- <s className="muted">
51
- <Money data={product.compareAtPrice} />
52
- </s>
53
- )}
54
- </span>
55
- {!product.availableForSale && <span className="muted">{t.soldOut}</span>}
56
- </Link>
63
+ <ProductCard key={product.id} product={product} />
57
64
  ))}
58
65
  </div>
59
66
  );
@@ -1,4 +1,5 @@
1
- import { MagicStoreError } from '@magicstoreai/hydrogen';
1
+ // From the client package, not the "use client" hydrogen entry: this runs in server components too.
2
+ import { MagicStoreError } from '@magicstoreai/storefront-client';
2
3
 
3
4
  /** What to tell the buyer: the API's localized detail when there is one. */
4
5
  export function describe(error: unknown, fallback: string): string {
@@ -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.',
@@ -156,6 +172,21 @@ const ru: Messages = {
156
172
  `${count} ${plural('ru', count, { one: 'отзыв', few: 'отзыва', other: 'отзывов' })}`,
157
173
  recommendUs: (percent) => `${percent}% рекомендуют нас`,
158
174
  leaveReview: 'Оставить отзыв',
175
+ blog: 'Блог',
176
+ allArticles: 'Все статьи',
177
+ featured: 'Выбор редакции',
178
+ readingTime: (minutes) => `${minutes} мин чтения`,
179
+ relatedArticles: 'Читайте также',
180
+ productsInArticle: 'Товары из статьи',
181
+ wasHelpful: 'Статья была полезной?',
182
+ helpful: 'Да',
183
+ notHelpful: 'Нет',
184
+ helpfulCount: (count) => `Полезно: ${count}`,
185
+ thanksForVote: 'Спасибо за отзыв',
186
+ searchArticles: 'Поиск по статьям',
187
+ sortNewest: 'Новые',
188
+ sortPopular: 'Популярные',
189
+ tag: 'Тег',
159
190
  addToCart: 'В корзину',
160
191
  addedToCart: 'Добавлено в корзину.',
161
192
  couldNotAdd: 'Не удалось добавить в корзину.',
@@ -248,6 +279,21 @@ const uz: Messages = {
248
279
  reviewsCount: (count) => `${count} ta sharh`,
249
280
  recommendUs: (percent) => `${percent}% bizni tavsiya qiladi`,
250
281
  leaveReview: 'Sharh qoldirish',
282
+ blog: 'Blog',
283
+ allArticles: 'Barcha maqolalar',
284
+ featured: 'Muharrir tanlovi',
285
+ readingTime: (minutes) => `${minutes} daqiqa o'qish`,
286
+ relatedArticles: "Shuningdek o'qing",
287
+ productsInArticle: 'Maqoladagi mahsulotlar',
288
+ wasHelpful: "Maqola foydali bo'ldimi?",
289
+ helpful: 'Ha',
290
+ notHelpful: "Yo'q",
291
+ helpfulCount: (count) => `Foydali: ${count}`,
292
+ thanksForVote: 'Fikringiz uchun rahmat',
293
+ searchArticles: 'Maqolalardan qidirish',
294
+ sortNewest: 'Yangilari',
295
+ sortPopular: 'Ommaboplari',
296
+ tag: 'Teg',
251
297
  addToCart: "Savatga qo'shish",
252
298
  addedToCart: "Savatga qo'shildi.",
253
299
  couldNotAdd: "Savatga qo'shib bo'lmadi.",
@@ -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
+ }