create-magic-storefront 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/dist/index.js +222 -0
  4. package/package.json +31 -0
  5. package/template/.env.example +22 -0
  6. package/template/AGENTS.md +56 -0
  7. package/template/CLAUDE.md +1 -0
  8. package/template/PAGES.md +154 -0
  9. package/template/README.md +31 -0
  10. package/template/_gitignore +5 -0
  11. package/template/app/account/page.tsx +117 -0
  12. package/template/app/api/magicstore/webhook/route.ts +9 -0
  13. package/template/app/cart/page.tsx +124 -0
  14. package/template/app/checkout/page.tsx +268 -0
  15. package/template/app/collections/[handle]/page.tsx +48 -0
  16. package/template/app/error.tsx +18 -0
  17. package/template/app/globals.css +268 -0
  18. package/template/app/layout.tsx +63 -0
  19. package/template/app/not-found.tsx +10 -0
  20. package/template/app/page.tsx +16 -0
  21. package/template/app/pages/[handle]/page.tsx +28 -0
  22. package/template/app/products/[handle]/page.tsx +47 -0
  23. package/template/app/providers.tsx +49 -0
  24. package/template/app/search/page.tsx +37 -0
  25. package/template/app/sitemap.ts +36 -0
  26. package/template/app/storefront-api/[...path]/route.ts +83 -0
  27. package/template/components/analytics-views.tsx +18 -0
  28. package/template/components/buy-box.tsx +86 -0
  29. package/template/components/cart-link.tsx +9 -0
  30. package/template/components/pager.tsx +25 -0
  31. package/template/components/product-grid.tsx +32 -0
  32. package/template/components/sections/announcement-bar.tsx +13 -0
  33. package/template/components/sections/banner.tsx +41 -0
  34. package/template/components/sections/collections.tsx +67 -0
  35. package/template/components/sections/deal-of-day.tsx +48 -0
  36. package/template/components/sections/index.tsx +66 -0
  37. package/template/components/sections/product-shelves.tsx +123 -0
  38. package/template/components/sections/shoppable-stories.tsx +39 -0
  39. package/template/components/sections/store-reviews.tsx +67 -0
  40. package/template/components/sections/types.ts +10 -0
  41. package/template/lib/api.ts +35 -0
  42. package/template/lib/errors.ts +9 -0
  43. package/template/lib/upstream.ts +12 -0
  44. package/template/llms.txt +128 -0
  45. package/template/next.config.ts +9 -0
  46. package/template/package.json +26 -0
  47. package/template/tsconfig.json +33 -0
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import {
4
+ MagicStoreError,
5
+ Money,
6
+ useAnalytics,
7
+ useCart,
8
+ useVariantSelection,
9
+ useWishlist,
10
+ } from '@magicstoreai/hydrogen';
11
+ import type { Schema } from '@magicstoreai/storefront-client';
12
+ import { useEffect, useState } from 'react';
13
+
14
+ export function BuyBox({ product }: { product: Schema<'ProductDetail'> }) {
15
+ const { selectedOptions, selectedVariant, setOption, isAvailable } = useVariantSelection(product);
16
+ const { addLine, status } = useCart();
17
+ const wishlist = useWishlist();
18
+ const analytics = useAnalytics();
19
+ const [error, setError] = useState<string | null>(null);
20
+ const [added, setAdded] = useState(false);
21
+
22
+ useEffect(
23
+ () => analytics.productView(product.id, selectedVariant?.id),
24
+ [analytics, product.id, selectedVariant?.id],
25
+ );
26
+
27
+ // A product without options has one variant; a product without variants is bought as itself.
28
+ const variantId = product.variants.length > 0 ? (selectedVariant?.id ?? null) : null;
29
+ const forSale =
30
+ product.variants.length > 0
31
+ ? Boolean(selectedVariant?.availableForSale)
32
+ : product.availableForSale;
33
+
34
+ async function add() {
35
+ setError(null);
36
+ try {
37
+ await addLine({ productId: product.id, variantId, quantity: 1 });
38
+ setAdded(true);
39
+ } catch (e) {
40
+ setError(e instanceof MagicStoreError ? (e.detail ?? e.message) : 'Could not add.');
41
+ }
42
+ }
43
+
44
+ return (
45
+ <div className="stack">
46
+ <div className="row">
47
+ <Money data={selectedVariant?.price ?? product.price} />
48
+ {(selectedVariant?.compareAtPrice ?? product.compareAtPrice) && (
49
+ <s className="muted">
50
+ <Money data={selectedVariant?.compareAtPrice ?? product.compareAtPrice} />
51
+ </s>
52
+ )}
53
+ </div>
54
+ {product.options
55
+ .filter((option) => option.values.length > 1)
56
+ .map((option) => (
57
+ <fieldset key={option.name}>
58
+ <legend>{option.name}</legend>
59
+ <div className="row">
60
+ {option.values.map((value) => (
61
+ <button
62
+ key={value}
63
+ type="button"
64
+ aria-pressed={selectedOptions[option.name] === value}
65
+ disabled={!isAvailable(option.name, value)}
66
+ onClick={() => setOption(option.name, value)}
67
+ >
68
+ {value}
69
+ </button>
70
+ ))}
71
+ </div>
72
+ </fieldset>
73
+ ))}
74
+ <div className="row">
75
+ <button className="primary" disabled={!forSale || status === 'updating'} onClick={add}>
76
+ {forSale ? 'Add to cart' : 'Sold out'}
77
+ </button>
78
+ <button type="button" onClick={() => void wishlist.toggle(product.id)}>
79
+ {wishlist.has(product.id) ? '♥ Saved' : '♡ Save'}
80
+ </button>
81
+ </div>
82
+ {added && <p className="muted">Added to cart.</p>}
83
+ {error && <p className="error">{error}</p>}
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,9 @@
1
+ 'use client';
2
+
3
+ import { useCart } from '@magicstoreai/hydrogen';
4
+ import Link from 'next/link';
5
+
6
+ export function CartLink() {
7
+ const { totalQuantity } = useCart();
8
+ return <Link href="/cart">Cart ({totalQuantity})</Link>;
9
+ }
@@ -0,0 +1,25 @@
1
+ import { paginationState, type Pagination } from '@magicstoreai/hydrogen';
2
+ import Link from 'next/link';
3
+
4
+ /** Server-rendered page links (`paginationState` is plain arithmetic, no hooks). */
5
+ export function Pager({ meta, href }: { meta: Pagination; href: (page: number) => string }) {
6
+ if (meta.totalPages <= 1) {
7
+ return null;
8
+ }
9
+ const state = paginationState(meta);
10
+ return (
11
+ <nav className="pager" aria-label="Pages">
12
+ {state.pages.map((page, index) =>
13
+ page === null ? (
14
+ <span key={`gap-${index}`}>…</span>
15
+ ) : page === state.page ? (
16
+ <strong key={page}>{page}</strong>
17
+ ) : (
18
+ <Link key={page} href={href(page)}>
19
+ {page}
20
+ </Link>
21
+ ),
22
+ )}
23
+ </nav>
24
+ );
25
+ }
@@ -0,0 +1,32 @@
1
+ import { Image, Money } from '@magicstoreai/hydrogen';
2
+ import type { Schema } from '@magicstoreai/storefront-client';
3
+ import Link from 'next/link';
4
+
5
+ export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
6
+ if (products.length === 0) {
7
+ return <p className="muted">Nothing here yet.</p>;
8
+ }
9
+ return (
10
+ <div className="grid">
11
+ {products.map((product) => (
12
+ <Link key={product.id} href={`/products/${product.handle}`} className="card">
13
+ <Image
14
+ data={product.featuredImage}
15
+ alt={product.title}
16
+ fallback={<div className="noimg" />}
17
+ />
18
+ <span>{product.title}</span>
19
+ <span className="row">
20
+ <Money data={product.price} fallback={<span className="muted">—</span>} />
21
+ {product.compareAtPrice && (
22
+ <s className="muted">
23
+ <Money data={product.compareAtPrice} />
24
+ </s>
25
+ )}
26
+ </span>
27
+ {!product.availableForSale && <span className="muted">Sold out</span>}
28
+ </Link>
29
+ ))}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,13 @@
1
+ import type { SectionOf } from './types';
2
+
3
+ export function AnnouncementBar({ section }: { section: SectionOf<'ANNOUNCEMENT_BAR'> }) {
4
+ const { text, backgroundColor, textColor, animation } = section.settings;
5
+ return (
6
+ <p
7
+ className={`announcement announcement-${animation.toLowerCase()}`}
8
+ style={{ background: backgroundColor ?? undefined, color: textColor ?? undefined }}
9
+ >
10
+ <span>{text}</span>
11
+ </p>
12
+ );
13
+ }
@@ -0,0 +1,41 @@
1
+ import { Image } from '@magicstoreai/hydrogen';
2
+ import Link from 'next/link';
3
+
4
+ import type { SectionOf } from './types';
5
+
6
+ type Slide = SectionOf<'BANNER'>['settings']['slides'][number];
7
+
8
+ /** Where a slide leads; `null` when it leads nowhere or to a URL that is neither a path nor http(s). */
9
+ function slideHref(link: Slide['link']): string | null {
10
+ if (link === null) {
11
+ return null;
12
+ }
13
+ switch (link.type) {
14
+ case 'PRODUCT':
15
+ return link.product ? `/products/${link.product.handle}` : null;
16
+ case 'COLLECTION':
17
+ return link.collection ? `/collections/${link.collection.handle}` : null;
18
+ case 'URL':
19
+ return link.url && /^(\/(?!\/)|https?:\/\/)/.test(link.url) ? link.url : null;
20
+ }
21
+ }
22
+
23
+ export function Banner({ section }: { section: SectionOf<'BANNER'> }) {
24
+ return (
25
+ <section className="banner">
26
+ {section.settings.slides.map((slide, index) => {
27
+ const image = (
28
+ <Image data={slide.image} loading={index === 0 ? 'eager' : 'lazy'} alt="Banner" />
29
+ );
30
+ const href = slideHref(slide.link);
31
+ return href === null ? (
32
+ <div key={index}>{image}</div>
33
+ ) : (
34
+ <Link key={index} href={href}>
35
+ {image}
36
+ </Link>
37
+ );
38
+ })}
39
+ </section>
40
+ );
41
+ }
@@ -0,0 +1,67 @@
1
+ import { Image } from '@magicstoreai/hydrogen';
2
+ import type { Schema } from '@magicstoreai/storefront-client';
3
+ import Link from 'next/link';
4
+
5
+ import { api } from '@/lib/api';
6
+
7
+ import type { SectionOf } from './types';
8
+
9
+ function CollectionTiles({
10
+ collections,
11
+ className = '',
12
+ }: {
13
+ collections: Schema<'Collection'>[];
14
+ className?: string;
15
+ }) {
16
+ if (collections.length === 0) {
17
+ return null;
18
+ }
19
+ return (
20
+ <div className={`grid ${className}`}>
21
+ {collections.map((collection) => (
22
+ <Link key={collection.id} href={`/collections/${collection.handle}`} className="card">
23
+ <Image
24
+ data={collection.image}
25
+ alt={collection.title}
26
+ fallback={<div className="noimg" />}
27
+ />
28
+ <span>{collection.title}</span>
29
+ </Link>
30
+ ))}
31
+ </div>
32
+ );
33
+ }
34
+
35
+ /** The collections the merchant flagged for the main navigation (`featured`). */
36
+ export async function FeaturedCollections(_: { section: SectionOf<'FEATURED_COLLECTIONS'> }) {
37
+ const { data } = await api.collectionsIndex({
38
+ query: { 'filter[featured]': 'true', perPage: 12 },
39
+ });
40
+ return <CollectionTiles collections={data} />;
41
+ }
42
+
43
+ /** Top-level collections; `cardStyle` is a hint the theme may honour — here a CSS class. */
44
+ export async function Collections({ section }: { section: SectionOf<'COLLECTIONS'> }) {
45
+ const { data } = await api.collectionsIndex({ query: { 'filter[root]': 'true', perPage: 24 } });
46
+ const style = section.settings.cardStyle;
47
+ return (
48
+ <CollectionTiles
49
+ collections={data}
50
+ className={style ? `cards-${style.toLowerCase().replaceAll('_', '-')}` : ''}
51
+ />
52
+ );
53
+ }
54
+
55
+ /** Tiles the merchant picked by hand, each with an optional image of its own. */
56
+ export function CollectionGrid({ section }: { section: SectionOf<'COLLECTION_GRID'> }) {
57
+ return (
58
+ <div className="grid">
59
+ {section.settings.tiles.map(({ collection, image }) => (
60
+ <Link key={collection.id} href={`/collections/${collection.handle}`} className="card">
61
+ <Image data={image} alt={collection.title} fallback={<div className="noimg" />} />
62
+ <span>{collection.title}</span>
63
+ </Link>
64
+ ))}
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,48 @@
1
+ import { Image, Money } from '@magicstoreai/hydrogen';
2
+ import Link from 'next/link';
3
+
4
+ import type { SectionOf } from './types';
5
+
6
+ export function DealOfDay({ section }: { section: SectionOf<'DEAL_OF_DAY'> }) {
7
+ const { product, image, price, compareAtPrice, endsAt, accentColor, badgeText, stock } =
8
+ section.settings;
9
+ return (
10
+ <section className="deal" style={{ borderColor: accentColor ?? undefined }}>
11
+ <Link href={`/products/${product.handle}`}>
12
+ <Image data={image} alt={product.title} fallback={<div className="noimg" />} />
13
+ </Link>
14
+ <div className="stack">
15
+ {badgeText && (
16
+ <span className="badge" style={{ background: accentColor ?? undefined }}>
17
+ {badgeText}
18
+ </span>
19
+ )}
20
+ <h2>
21
+ <Link href={`/products/${product.handle}`}>{product.title}</Link>
22
+ </h2>
23
+ <span className="row">
24
+ <Money data={price} />
25
+ {compareAtPrice && (
26
+ <s className="muted">
27
+ <Money data={compareAtPrice} />
28
+ </s>
29
+ )}
30
+ </span>
31
+ {/* The merchant's "N of M left" bar — not the product's real stock. */}
32
+ {stock && (
33
+ <label className="stack">
34
+ <span className="muted">
35
+ {stock.remaining} of {stock.initial} left
36
+ </span>
37
+ <progress value={stock.remaining} max={Math.max(stock.initial, stock.remaining, 1)} />
38
+ </label>
39
+ )}
40
+ {endsAt && (
41
+ <span className="muted">
42
+ Ends <time dateTime={endsAt}>{new Date(endsAt).toUTCString()}</time>
43
+ </span>
44
+ )}
45
+ </div>
46
+ </section>
47
+ );
48
+ }
@@ -0,0 +1,66 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { AnnouncementBar } from './announcement-bar';
4
+ import { Banner } from './banner';
5
+ import { CollectionGrid, Collections, FeaturedCollections } from './collections';
6
+ import { DealOfDay } from './deal-of-day';
7
+ import {
8
+ Bestsellers,
9
+ CollectionProducts,
10
+ FlashSale,
11
+ ForYou,
12
+ NewArrivals,
13
+ OnSale,
14
+ PreOrders,
15
+ Trending,
16
+ } from './product-shelves';
17
+ import { ShoppableStories } from './shoppable-stories';
18
+ import { StoreReviews } from './store-reviews';
19
+ import type { HomeSection, HomeSectionType, SectionOf } from './types';
20
+
21
+ type SectionRenderer<Type extends HomeSectionType> = (props: {
22
+ section: SectionOf<Type>;
23
+ }) => ReactNode | Promise<ReactNode>;
24
+
25
+ /**
26
+ * One renderer per `GET /home` section type. Typed against the generated `HomeSectionType`, so a
27
+ * type the backend adds (after `pnpm sync:spec` + generate) fails the typecheck until it is here.
28
+ */
29
+ export const SECTION_RENDERERS: { [Type in HomeSectionType]: SectionRenderer<Type> } = {
30
+ ANNOUNCEMENT_BAR: AnnouncementBar,
31
+ BANNER: Banner,
32
+ FEATURED_COLLECTIONS: FeaturedCollections,
33
+ COLLECTIONS: Collections,
34
+ COLLECTION_GRID: CollectionGrid,
35
+ NEW_ARRIVALS: NewArrivals,
36
+ ON_SALE: OnSale,
37
+ TRENDING: Trending,
38
+ BESTSELLERS: Bestsellers,
39
+ PRE_ORDERS: PreOrders,
40
+ FOR_YOU: ForYou,
41
+ COLLECTION_PRODUCTS: CollectionProducts,
42
+ FLASH_SALE: FlashSale,
43
+ DEAL_OF_DAY: DealOfDay,
44
+ SHOPPABLE_STORIES: ShoppableStories,
45
+ STORE_REVIEWS: StoreReviews,
46
+ };
47
+
48
+ /**
49
+ * The renderer for a section, or `null` for a type this storefront does not know yet — a live API
50
+ * may send a newer type than the spec it was built from, and an unknown section renders nothing.
51
+ */
52
+ export function sectionRenderer(section: HomeSection): SectionRenderer<HomeSectionType> | null {
53
+ if (!Object.hasOwn(SECTION_RENDERERS, section.type)) {
54
+ return null;
55
+ }
56
+ // The map is keyed by `type`, so the renderer always matches the section it is picked for.
57
+ return SECTION_RENDERERS[section.type] as SectionRenderer<HomeSectionType>;
58
+ }
59
+
60
+ /** The home page's sections in the merchant's order. */
61
+ export function HomeSections({ sections }: { sections: HomeSection[] }) {
62
+ return sections.map((section) => {
63
+ const Renderer = sectionRenderer(section);
64
+ return Renderer === null ? null : <Renderer key={section.id} section={section} />;
65
+ });
66
+ }
@@ -0,0 +1,123 @@
1
+ import type { Schema } from '@magicstoreai/storefront-client';
2
+ import Link from 'next/link';
3
+ import type { ReactNode } from 'react';
4
+
5
+ import { ProductGrid } from '@/components/product-grid';
6
+ import { api } from '@/lib/api';
7
+
8
+ import { SHELF_SIZE, type SectionOf } from './types';
9
+
10
+ function Shelf({
11
+ title,
12
+ href,
13
+ products,
14
+ children,
15
+ }: {
16
+ title: string;
17
+ href?: string;
18
+ products: Schema<'Product'>[];
19
+ children?: ReactNode;
20
+ }) {
21
+ // The server drops a section that would show nothing, but a shelf can empty between the two reads.
22
+ if (products.length === 0) {
23
+ return null;
24
+ }
25
+ return (
26
+ <section className="stack">
27
+ <div className="row">
28
+ <h2>{title}</h2>
29
+ {children}
30
+ {href && <Link href={href}>See all</Link>}
31
+ </div>
32
+ <ProductGrid products={products} />
33
+ </section>
34
+ );
35
+ }
36
+
37
+ type ShelfSection = SectionOf<
38
+ 'NEW_ARRIVALS' | 'ON_SALE' | 'TRENDING' | 'BESTSELLERS' | 'PRE_ORDERS'
39
+ >;
40
+
41
+ export async function NewArrivals({ section }: { section: ShelfSection }) {
42
+ const { data } = await api.productsIndex({
43
+ query: { sort: '-createdAt', perPage: section.settings.limit ?? SHELF_SIZE },
44
+ });
45
+ return <Shelf title="New arrivals" products={data} />;
46
+ }
47
+
48
+ export async function OnSale({ section }: { section: ShelfSection }) {
49
+ const { data } = await api.productsIndex({
50
+ query: { 'filter[onSale]': 'true', perPage: section.settings.limit ?? SHELF_SIZE },
51
+ });
52
+ return <Shelf title="On sale" products={data} />;
53
+ }
54
+
55
+ export async function Trending({ section }: { section: ShelfSection }) {
56
+ const { data } = await api.productsIndex({
57
+ query: { 'filter[trending]': 'true', perPage: section.settings.limit ?? SHELF_SIZE },
58
+ });
59
+ return <Shelf title="Trending" products={data} />;
60
+ }
61
+
62
+ export async function Bestsellers({ section }: { section: ShelfSection }) {
63
+ const { data } = await api.productsIndex({
64
+ query: { sort: '-bestSellers', perPage: section.settings.limit ?? SHELF_SIZE },
65
+ });
66
+ return <Shelf title="Bestsellers" products={data} />;
67
+ }
68
+
69
+ export async function PreOrders({ section }: { section: ShelfSection }) {
70
+ const { data } = await api.productsIndex({
71
+ query: { 'filter[preOrder]': 'true', perPage: section.settings.limit ?? SHELF_SIZE },
72
+ });
73
+ return <Shelf title="Pre-order" products={data} />;
74
+ }
75
+
76
+ /**
77
+ * Renders nothing on purpose: v2 has no product-independent personal shelf yet
78
+ * (`/products/{handle}/recommendations?intent=FOR_YOU` needs a product in context), and this page
79
+ * is cached for every visitor, so it must not be personal.
80
+ */
81
+ export function ForYou(_: { section: SectionOf<'FOR_YOU'> }) {
82
+ return null;
83
+ }
84
+
85
+ export async function CollectionProducts({
86
+ section,
87
+ }: {
88
+ section: SectionOf<'COLLECTION_PRODUCTS'>;
89
+ }) {
90
+ const { collection, limit, title } = section.settings;
91
+ const { data } = await api.collectionsProducts({
92
+ path: { handle: collection.handle },
93
+ query: { perPage: limit ?? SHELF_SIZE },
94
+ });
95
+ return (
96
+ <Shelf
97
+ title={title ?? collection.title}
98
+ href={`/collections/${collection.handle}`}
99
+ products={data}
100
+ />
101
+ );
102
+ }
103
+
104
+ export async function FlashSale({ section }: { section: SectionOf<'FLASH_SALE'> }) {
105
+ const { collection, limit, title, endsAt } = section.settings;
106
+ const { data } = await api.collectionsProducts({
107
+ path: { handle: collection.handle },
108
+ query: { perPage: limit ?? SHELF_SIZE },
109
+ });
110
+ return (
111
+ <Shelf
112
+ title={title ?? collection.title}
113
+ href={`/collections/${collection.handle}`}
114
+ products={data}
115
+ >
116
+ {endsAt && (
117
+ <span className="muted">
118
+ Ends <time dateTime={endsAt}>{new Date(endsAt).toUTCString()}</time>
119
+ </span>
120
+ )}
121
+ </Shelf>
122
+ );
123
+ }
@@ -0,0 +1,39 @@
1
+ import { Image } from '@magicstoreai/hydrogen';
2
+ import Link from 'next/link';
3
+
4
+ import type { SectionOf } from './types';
5
+
6
+ /**
7
+ * Story bubbles. The starter has no story player: a bubble leads to the first product its frames
8
+ * tag, or shows no link when none does. A theme adds the player (frames, `autoplay`) on top.
9
+ */
10
+ export function ShoppableStories({ section }: { section: SectionOf<'SHOPPABLE_STORIES'> }) {
11
+ const { layout, stories } = section.settings;
12
+ return (
13
+ <section className={`stories stories-${layout.toLowerCase()}`}>
14
+ {stories.map((story, index) => {
15
+ const product = story.frames.find((frame) => frame.product !== null)?.product ?? null;
16
+ const bubble = (
17
+ <>
18
+ <Image
19
+ data={story.thumbnail}
20
+ alt={story.title ?? ''}
21
+ fallback={<div className="noimg" />}
22
+ />
23
+ {story.title && <span>{story.title}</span>}
24
+ </>
25
+ );
26
+ const key = story.id ?? index;
27
+ return product === null ? (
28
+ <div key={key} className="story">
29
+ {bubble}
30
+ </div>
31
+ ) : (
32
+ <Link key={key} href={`/products/${product.handle}`} className="story">
33
+ {bubble}
34
+ </Link>
35
+ );
36
+ })}
37
+ </section>
38
+ );
39
+ }
@@ -0,0 +1,67 @@
1
+ import Link from 'next/link';
2
+
3
+ import { api } from '@/lib/api';
4
+
5
+ import type { SectionOf } from './types';
6
+
7
+ const SOURCE_LABELS = { GOOGLE: 'Google', YANDEX: 'Yandex', TWO_GIS: '2GIS', CUSTOM: '' } as const;
8
+
9
+ /** Reviews of the shop itself (`GET /reviews/site`), dressed with what the merchant entered. */
10
+ export async function StoreReviews({ section }: { section: SectionOf<'STORE_REVIEWS'> }) {
11
+ const settings = section.settings;
12
+ const { data: reviews, meta } = await api.reviewsSite({
13
+ query: { perPage: settings.limit, sort: '-createdAt' },
14
+ });
15
+ const { rating } = meta;
16
+
17
+ return (
18
+ <section className="stack">
19
+ {settings.overline && <span className="muted">{settings.overline}</span>}
20
+ <h2>{settings.title ?? 'What our customers say'}</h2>
21
+ {settings.subtitle && <p className="muted">{settings.subtitle}</p>}
22
+
23
+ <div className="row">
24
+ {rating.average !== null && (
25
+ <strong>
26
+ {rating.average} / 5 · {rating.count} reviews
27
+ </strong>
28
+ )}
29
+ {settings.recommendPercent !== null && (
30
+ <span>{settings.recommendPercent}% recommend us</span>
31
+ )}
32
+ {settings.externalRatings.map((external, index) => (
33
+ <span key={index} className="muted">
34
+ {external.label ?? SOURCE_LABELS[external.source]} {external.score}
35
+ </span>
36
+ ))}
37
+ </div>
38
+
39
+ {settings.showDistribution && rating.breakdown.length > 0 && (
40
+ <ul className="muted">
41
+ {rating.breakdown.map(({ rating: stars, count }) => (
42
+ <li key={stars}>
43
+ {stars}★ — {count}
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ )}
48
+
49
+ <div className="grid">
50
+ {reviews.map((review) => (
51
+ <blockquote key={review.id} className="stack">
52
+ <span>{'★'.repeat(review.rating)}</span>
53
+ <p>{review.body}</p>
54
+ <footer className="muted">{review.author.name}</footer>
55
+ </blockquote>
56
+ ))}
57
+ </div>
58
+
59
+ {settings.showLeaveReview && (
60
+ // Leaving a site review needs a signed-in customer (POST /reviews/site).
61
+ <Link href="/account" className="button">
62
+ Leave a review
63
+ </Link>
64
+ )}
65
+ </section>
66
+ );
67
+ }
@@ -0,0 +1,10 @@
1
+ import type { Schema } from '@magicstoreai/storefront-client';
2
+
3
+ export type HomeSection = Schema<'HomeSection'>;
4
+ export type HomeSectionType = Schema<'HomeSectionType'>;
5
+
6
+ /** The `GET /home` section of one type, with that type's own `settings`. */
7
+ export type SectionOf<Type extends HomeSectionType> = Extract<HomeSection, { type: Type }>;
8
+
9
+ /** Products a shelf shows when the merchant left `limit` empty (`null` = the storefront's default). */
10
+ export const SHELF_SIZE = 8;
@@ -0,0 +1,35 @@
1
+ import 'server-only';
2
+ import { MagicStoreError, createStorefrontClient } from '@magicstoreai/storefront-client';
3
+ import { nextCacheFetch } from '@magicstoreai/hydrogen/server';
4
+ import { notFound } from 'next/navigation';
5
+
6
+ import { upstreamBaseUrl } from './upstream';
7
+
8
+ const baseUrl = upstreamBaseUrl();
9
+ if (baseUrl === null) {
10
+ throw new Error('Set MAGICSTORE_SHOP_DOMAIN or MAGICSTORE_API_URL (see .env.example).');
11
+ }
12
+
13
+ export const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
14
+
15
+ /**
16
+ * The server-side client. Public reads are cached under the tags the webhook revalidates; nothing
17
+ * personal is ever called from here.
18
+ */
19
+ export const api = createStorefrontClient({
20
+ baseUrl,
21
+ locale,
22
+ fetch: nextCacheFetch({ revalidate: 600 }),
23
+ });
24
+
25
+ /** Runs a read and turns the API's NOT_FOUND into the page's 404. */
26
+ export async function orNotFound<T>(read: Promise<T>): Promise<T> {
27
+ try {
28
+ return await read;
29
+ } catch (error) {
30
+ if (error instanceof MagicStoreError && error.code === 'NOT_FOUND') {
31
+ notFound();
32
+ }
33
+ throw error;
34
+ }
35
+ }