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.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/index.js +222 -0
- package/package.json +31 -0
- package/template/.env.example +22 -0
- package/template/AGENTS.md +56 -0
- package/template/CLAUDE.md +1 -0
- package/template/PAGES.md +154 -0
- package/template/README.md +31 -0
- package/template/_gitignore +5 -0
- package/template/app/account/page.tsx +117 -0
- package/template/app/api/magicstore/webhook/route.ts +9 -0
- package/template/app/cart/page.tsx +124 -0
- package/template/app/checkout/page.tsx +268 -0
- package/template/app/collections/[handle]/page.tsx +48 -0
- package/template/app/error.tsx +18 -0
- package/template/app/globals.css +268 -0
- package/template/app/layout.tsx +63 -0
- package/template/app/not-found.tsx +10 -0
- package/template/app/page.tsx +16 -0
- package/template/app/pages/[handle]/page.tsx +28 -0
- package/template/app/products/[handle]/page.tsx +47 -0
- package/template/app/providers.tsx +49 -0
- package/template/app/search/page.tsx +37 -0
- package/template/app/sitemap.ts +36 -0
- package/template/app/storefront-api/[...path]/route.ts +83 -0
- package/template/components/analytics-views.tsx +18 -0
- package/template/components/buy-box.tsx +86 -0
- package/template/components/cart-link.tsx +9 -0
- package/template/components/pager.tsx +25 -0
- package/template/components/product-grid.tsx +32 -0
- package/template/components/sections/announcement-bar.tsx +13 -0
- package/template/components/sections/banner.tsx +41 -0
- package/template/components/sections/collections.tsx +67 -0
- package/template/components/sections/deal-of-day.tsx +48 -0
- package/template/components/sections/index.tsx +66 -0
- package/template/components/sections/product-shelves.tsx +123 -0
- package/template/components/sections/shoppable-stories.tsx +39 -0
- package/template/components/sections/store-reviews.tsx +67 -0
- package/template/components/sections/types.ts +10 -0
- package/template/lib/api.ts +35 -0
- package/template/lib/errors.ts +9 -0
- package/template/lib/upstream.ts +12 -0
- package/template/llms.txt +128 -0
- package/template/next.config.ts +9 -0
- package/template/package.json +26 -0
- 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,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
|
+
}
|