create-magic-storefront 0.4.0 → 0.6.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/package.json +3 -3
- package/template/.claude/skills/storefront-design/SKILL.md +14 -10
- package/template/.env.example +5 -0
- package/template/AGENTS.md +36 -17
- package/template/DESIGN.md +117 -0
- package/template/PAGES.md +116 -29
- package/template/README.md +15 -3
- package/template/app/account/addresses/page.tsx +16 -0
- package/template/app/account/layout.tsx +14 -0
- package/template/app/account/loading.tsx +15 -0
- package/template/app/account/orders/[id]/page.tsx +15 -0
- package/template/app/account/page.tsx +12 -244
- package/template/app/account/profile/page.tsx +16 -0
- package/template/app/blog/(index)/loading.tsx +22 -0
- package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
- package/template/app/blog/[handle]/page.tsx +92 -56
- package/template/app/blog/author/[handle]/page.tsx +1 -1
- package/template/app/cart/layout.tsx +14 -0
- package/template/app/cart/page.tsx +49 -101
- package/template/app/checkout/layout.tsx +14 -0
- package/template/app/checkout/page.tsx +98 -281
- package/template/app/collections/[handle]/layout.tsx +20 -0
- package/template/app/collections/[handle]/loading.tsx +23 -0
- package/template/app/collections/[handle]/page.tsx +131 -15
- package/template/app/collections/page.tsx +49 -0
- package/template/app/contacts/page.tsx +196 -0
- package/template/app/error.tsx +19 -8
- package/template/app/fonts/OFL.txt +93 -0
- package/template/app/fonts/index.ts +14 -0
- package/template/app/fonts/onest-variable.woff2 +0 -0
- package/template/app/globals.css +14 -518
- package/template/app/layout.tsx +74 -50
- package/template/app/not-found.tsx +34 -4
- package/template/app/page.tsx +1 -1
- package/template/app/pages/[handle]/page.tsx +1 -1
- package/template/app/products/[handle]/layout.tsx +20 -0
- package/template/app/products/[handle]/loading.tsx +25 -0
- package/template/app/products/[handle]/page.tsx +112 -15
- package/template/app/providers.tsx +20 -7
- package/template/app/search/loading.tsx +12 -0
- package/template/app/search/page.tsx +138 -25
- package/template/app/sitemap.ts +5 -1
- package/template/app/storefront-api/[...path]/route.ts +11 -66
- package/template/app/styles/base.css +138 -0
- package/template/app/styles/components.css +1108 -0
- package/template/app/styles/layout.css +455 -0
- package/template/app/styles/pages.css +1839 -0
- package/template/app/styles/reset.css +55 -0
- package/template/app/styles/sections.css +415 -0
- package/template/app/styles/tokens.css +167 -0
- package/template/app/styles/utilities.css +37 -0
- package/template/app/wishlist/page.tsx +26 -0
- package/template/components/account/account-shell.tsx +100 -0
- package/template/components/account/addresses.tsx +247 -0
- package/template/components/account/order-detail.tsx +281 -0
- package/template/components/account/order-list.tsx +94 -0
- package/template/components/account/order-status.tsx +19 -0
- package/template/components/account/profile.tsx +129 -0
- package/template/components/account/sign-in.tsx +164 -0
- package/template/components/article-body.tsx +3 -2
- package/template/components/article-card.tsx +37 -12
- package/template/components/cart/cart-drawer.tsx +149 -0
- package/template/components/cart/cart-lines.tsx +89 -0
- package/template/components/cart/cart-summary.tsx +191 -0
- package/template/components/catalog/catalog-form.tsx +107 -0
- package/template/components/catalog/catalog-layout.tsx +82 -0
- package/template/components/catalog/facets-shell.tsx +60 -0
- package/template/components/catalog/facets.tsx +216 -0
- package/template/components/checkout/order-summary.tsx +84 -0
- package/template/components/checkout/radio-card.tsx +34 -0
- package/template/components/checkout/step-indicator.tsx +56 -0
- package/template/components/checkout/steps.tsx +264 -0
- package/template/components/checkout/thank-you.tsx +48 -0
- package/template/components/checkout/use-checkout.ts +90 -0
- package/template/components/footer/site-footer.tsx +132 -0
- package/template/components/header/cart-button.tsx +43 -0
- package/template/components/header/desktop-nav.tsx +133 -0
- package/template/components/header/mobile-menu.tsx +102 -0
- package/template/components/header/site-header.tsx +78 -0
- package/template/components/page-loading.tsx +20 -0
- package/template/components/pager.tsx +3 -1
- package/template/components/product/buy-box.tsx +148 -0
- package/template/components/product/gallery.tsx +179 -0
- package/template/components/product/pre-order-form.tsx +93 -0
- package/template/components/product/product-selection.tsx +31 -0
- package/template/components/product/recommendations.tsx +26 -0
- package/template/components/product/review-list.tsx +159 -0
- package/template/components/product/reviews.tsx +80 -0
- package/template/components/product/variant-picker.tsx +75 -0
- package/template/components/product-card.tsx +103 -0
- package/template/components/product-grid.tsx +27 -57
- package/template/components/quick-add.tsx +62 -0
- package/template/components/search/predictive-search.tsx +282 -0
- package/template/components/sections/announcement-bar.tsx +1 -1
- package/template/components/sections/banner.tsx +1 -1
- package/template/components/sections/collections.tsx +50 -13
- package/template/components/sections/deal-of-day.tsx +2 -2
- package/template/components/sections/product-shelves.tsx +13 -5
- package/template/components/sections/shoppable-stories.tsx +2 -2
- package/template/components/sections/store-reviews.tsx +10 -4
- package/template/components/share-button.tsx +42 -0
- package/template/components/ui/accordion.tsx +35 -0
- package/template/components/ui/badge.tsx +20 -0
- package/template/components/ui/breadcrumbs.tsx +31 -0
- package/template/components/ui/button.tsx +90 -0
- package/template/components/ui/drawer.tsx +84 -0
- package/template/components/ui/empty-state.tsx +27 -0
- package/template/components/ui/field.tsx +33 -0
- package/template/components/ui/icon.tsx +112 -0
- package/template/components/ui/price.tsx +35 -0
- package/template/components/ui/quantity-stepper.tsx +91 -0
- package/template/components/ui/rating.tsx +48 -0
- package/template/components/ui/skeleton.tsx +46 -0
- package/template/components/wishlist/wishlist-view.tsx +134 -0
- package/template/lib/article-outline.ts +35 -0
- package/template/lib/catalog-params.ts +170 -0
- package/template/lib/contacts.ts +34 -0
- package/template/lib/errors.ts +29 -0
- package/template/lib/i18n.ts +484 -0
- package/template/lib/menu.ts +70 -0
- package/template/lib/theme-settings.ts +36 -0
- package/template/lib/upstream.ts +21 -0
- package/template/llms.txt +13 -0
- package/template/theme/index.ts +7 -1
- package/template/theme/sections/collapsible-content.tsx +75 -0
- package/template/theme/sections/collection-list.tsx +32 -4
- package/template/theme/sections/featured-collection.tsx +22 -2
- package/template/theme/sections/hero.tsx +94 -7
- package/template/theme/sections/image-with-text.tsx +19 -3
- package/template/theme/sections/multicolumn.tsx +113 -0
- package/template/theme/sections/page-content.tsx +10 -4
- package/template/theme/sections/product-shelf.tsx +26 -4
- package/template/theme/sections/rich-text.tsx +13 -1
- package/template/theme/sections/testimonials.tsx +14 -9
- package/template/theme/settings.ts +85 -1
- package/template/theme/templates/index.json +4 -2
- package/template/.screenshots/home-1280.png +0 -0
- package/template/.screenshots/home-390.png +0 -0
- package/template/.screenshots/home-filled-1280.png +0 -0
- package/template/.screenshots/home-filled-390.png +0 -0
- package/template/.screenshots/page-1280.png +0 -0
- package/template/components/buy-box.tsx +0 -89
- package/template/components/cart-link.tsx +0 -15
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
2
|
+
|
|
3
|
+
import { Rating } from '@/components/ui/rating';
|
|
4
|
+
import type { Messages } from '@/lib/i18n';
|
|
5
|
+
|
|
6
|
+
import { ReviewList } from './review-list';
|
|
7
|
+
|
|
8
|
+
type ReviewsPage = {
|
|
9
|
+
data: Schema<'Review'>[];
|
|
10
|
+
meta: { pagination: Schema<'Pagination'>; rating: Schema<'RatingSummary'> };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Reviews on the first page load; more, and a rating filter, come from the browser. */
|
|
14
|
+
export const REVIEWS_PER_PAGE = 5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The product's reviews: the summary the API sends (average, count, the count per rating — never
|
|
18
|
+
* computed here), then the first page, server-rendered. Hidden while the product has none.
|
|
19
|
+
*/
|
|
20
|
+
export function ProductReviews({
|
|
21
|
+
handle,
|
|
22
|
+
reviews,
|
|
23
|
+
locale,
|
|
24
|
+
t,
|
|
25
|
+
}: {
|
|
26
|
+
handle: string;
|
|
27
|
+
reviews: ReviewsPage;
|
|
28
|
+
locale: string;
|
|
29
|
+
t: Messages;
|
|
30
|
+
}) {
|
|
31
|
+
const { rating } = reviews.meta;
|
|
32
|
+
if (rating.count === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const most = Math.max(...rating.breakdown.map((row) => row.count), 1);
|
|
36
|
+
return (
|
|
37
|
+
<section id="reviews" className="product-reviews" aria-labelledby="reviews-title">
|
|
38
|
+
<div className="section-header">
|
|
39
|
+
<h2 id="reviews-title">{t.reviews}</h2>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="product-reviews__layout">
|
|
42
|
+
<div className="review-summary">
|
|
43
|
+
{rating.average !== null && (
|
|
44
|
+
<p className="review-summary__average">{rating.average.toFixed(1)}</p>
|
|
45
|
+
)}
|
|
46
|
+
<Rating
|
|
47
|
+
average={rating.average}
|
|
48
|
+
count={rating.count}
|
|
49
|
+
label={t.ratingSummary(rating.average ?? 0, rating.count)}
|
|
50
|
+
showCount={false}
|
|
51
|
+
/>
|
|
52
|
+
<p className="muted text-sm">{t.reviewsCount(rating.count)}</p>
|
|
53
|
+
{rating.breakdown.length > 0 && (
|
|
54
|
+
<ul className="review-summary__breakdown">
|
|
55
|
+
{[...rating.breakdown]
|
|
56
|
+
.sort((a, b) => b.rating - a.rating)
|
|
57
|
+
.map((row) => (
|
|
58
|
+
<li key={row.rating}>
|
|
59
|
+
<span>{row.rating}</span>
|
|
60
|
+
<span className="review-summary__bar" aria-hidden="true">
|
|
61
|
+
<span style={{ width: `${(row.count / most) * 100}%` }} />
|
|
62
|
+
</span>
|
|
63
|
+
<span className="muted">{row.count}</span>
|
|
64
|
+
</li>
|
|
65
|
+
))}
|
|
66
|
+
</ul>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
<ReviewList
|
|
70
|
+
handle={handle}
|
|
71
|
+
initial={reviews.data}
|
|
72
|
+
pagination={reviews.meta.pagination}
|
|
73
|
+
perPage={REVIEWS_PER_PAGE}
|
|
74
|
+
ratings={rating.breakdown.filter((row) => row.count > 0).map((row) => row.rating)}
|
|
75
|
+
locale={locale}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
|
+
|
|
5
|
+
import { messages } from '@/lib/i18n';
|
|
6
|
+
|
|
7
|
+
import { useProductSelection } from './product-selection';
|
|
8
|
+
|
|
9
|
+
/** More values than this and an option becomes a `<select>` rather than a row of pills. */
|
|
10
|
+
const MAX_PILLS = 8;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One control per option with more than one value: pills (radio buttons), or a select when there
|
|
14
|
+
* are many. A value no variant for sale has with the other choices is struck through but stays
|
|
15
|
+
* selectable — choosing it moves to the closest variant that has it (`useVariantSelection`).
|
|
16
|
+
*/
|
|
17
|
+
export function VariantPicker() {
|
|
18
|
+
const t = messages(useMagicStore().locale);
|
|
19
|
+
const { product, selectedOptions, setOption, isAvailable } = useProductSelection();
|
|
20
|
+
|
|
21
|
+
return product.options
|
|
22
|
+
.filter((option) => option.values.length > 1)
|
|
23
|
+
.map((option) => {
|
|
24
|
+
const selected = selectedOptions[option.name];
|
|
25
|
+
if (option.values.length > MAX_PILLS) {
|
|
26
|
+
return (
|
|
27
|
+
<label key={option.name} className="field variant-select">
|
|
28
|
+
<span className="field__label">{option.name}</span>
|
|
29
|
+
<select
|
|
30
|
+
value={selected ?? ''}
|
|
31
|
+
onChange={(event) => setOption(option.name, event.target.value)}
|
|
32
|
+
>
|
|
33
|
+
{selected === undefined && (
|
|
34
|
+
<option value="" disabled>
|
|
35
|
+
{t.selectOption(option.name)}
|
|
36
|
+
</option>
|
|
37
|
+
)}
|
|
38
|
+
{option.values.map((value) => (
|
|
39
|
+
<option key={value} value={value}>
|
|
40
|
+
{isAvailable(option.name, value) ? value : `${value} — ${t.soldOut}`}
|
|
41
|
+
</option>
|
|
42
|
+
))}
|
|
43
|
+
</select>
|
|
44
|
+
</label>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return (
|
|
48
|
+
<fieldset key={option.name} className="variant-picker">
|
|
49
|
+
<legend className="variant-picker__legend">
|
|
50
|
+
{option.name}
|
|
51
|
+
{selected !== undefined && <span className="variant-picker__value">: {selected}</span>}
|
|
52
|
+
</legend>
|
|
53
|
+
<div className="variant-picker__values">
|
|
54
|
+
{option.values.map((value) => {
|
|
55
|
+
const available = isAvailable(option.name, value);
|
|
56
|
+
return (
|
|
57
|
+
<label key={value} className={available ? 'pill' : 'pill pill--unavailable'}>
|
|
58
|
+
<input
|
|
59
|
+
type="radio"
|
|
60
|
+
className="visually-hidden"
|
|
61
|
+
name={`option-${option.name}`}
|
|
62
|
+
value={value}
|
|
63
|
+
checked={selected === value}
|
|
64
|
+
onChange={() => setOption(option.name, value)}
|
|
65
|
+
/>
|
|
66
|
+
{value}
|
|
67
|
+
{!available && <span className="visually-hidden">, {t.soldOut}</span>}
|
|
68
|
+
</label>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</div>
|
|
72
|
+
</fieldset>
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Image } from '@magicstoreai/hydrogen';
|
|
2
|
+
import { moneyAmount } from '@magicstoreai/hydrogen/core';
|
|
3
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
|
|
6
|
+
import { QuickAdd } from '@/components/quick-add';
|
|
7
|
+
import { Badge, type BadgeKind } from '@/components/ui/badge';
|
|
8
|
+
import { Price } from '@/components/ui/price';
|
|
9
|
+
import { Rating } from '@/components/ui/rating';
|
|
10
|
+
import { messages, type Messages } from '@/lib/i18n';
|
|
11
|
+
|
|
12
|
+
type Product = Schema<'Product'>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The card's badges, only from the product's data and at most two: sold out first (it changes what
|
|
16
|
+
* the buyer can do), then a real discount (compare-at above the price), pre-order, new.
|
|
17
|
+
*/
|
|
18
|
+
export function productBadges(product: Product, t: Messages): { kind: BadgeKind; label: string }[] {
|
|
19
|
+
const onSale =
|
|
20
|
+
product.price !== null &&
|
|
21
|
+
product.compareAtPrice !== null &&
|
|
22
|
+
moneyAmount(product.compareAtPrice) > moneyAmount(product.price);
|
|
23
|
+
const badges: { kind: BadgeKind; label: string }[] = [
|
|
24
|
+
...(!product.availableForSale ? [{ kind: 'soldOut' as const, label: t.soldOut }] : []),
|
|
25
|
+
...(onSale ? [{ kind: 'sale' as const, label: t.sale }] : []),
|
|
26
|
+
...(product.preOrder ? [{ kind: 'preOrder' as const, label: t.preOrder }] : []),
|
|
27
|
+
...(product.isNew ? [{ kind: 'new' as const, label: t.isNew }] : []),
|
|
28
|
+
];
|
|
29
|
+
return badges.slice(0, 2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A product with nothing to choose: one variant, or none (bought as itself). */
|
|
33
|
+
export function canQuickAdd(product: Product): boolean {
|
|
34
|
+
return product.availableForSale && product.variants.length <= 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One product as a card: image at the theme's ratio, badges from data, vendor and rating (each
|
|
39
|
+
* shown when the theme turns it on, `data-show-vendor` / `data-show-rating`), title, price. The
|
|
40
|
+
* whole card is one link; quick add is a separate button, only for a product with nothing to pick.
|
|
41
|
+
* Works in server and client components: the caller passes the storefront's `locale`.
|
|
42
|
+
*/
|
|
43
|
+
export function ProductCard({
|
|
44
|
+
product,
|
|
45
|
+
locale,
|
|
46
|
+
eager = false,
|
|
47
|
+
}: {
|
|
48
|
+
product: Product;
|
|
49
|
+
locale: string;
|
|
50
|
+
/** The LCP candidates (a grid's first row): loaded at once, not lazily. */
|
|
51
|
+
eager?: boolean;
|
|
52
|
+
}) {
|
|
53
|
+
const t = messages(locale);
|
|
54
|
+
const badges = productBadges(product, t);
|
|
55
|
+
const href = `/products/${product.handle}`;
|
|
56
|
+
return (
|
|
57
|
+
<div className="product-card">
|
|
58
|
+
<div className="product-card__media">
|
|
59
|
+
<Image
|
|
60
|
+
data={product.featuredImage}
|
|
61
|
+
alt={product.title}
|
|
62
|
+
loading={eager ? 'eager' : 'lazy'}
|
|
63
|
+
fetchPriority={eager ? 'high' : undefined}
|
|
64
|
+
fallback={<span className="media-fallback" />}
|
|
65
|
+
/>
|
|
66
|
+
{badges.length > 0 && (
|
|
67
|
+
<span className="product-card__badges">
|
|
68
|
+
{badges.map((badge) => (
|
|
69
|
+
<Badge key={badge.kind} kind={badge.kind}>
|
|
70
|
+
{badge.label}
|
|
71
|
+
</Badge>
|
|
72
|
+
))}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
{canQuickAdd(product) && (
|
|
76
|
+
<QuickAdd
|
|
77
|
+
productId={product.id}
|
|
78
|
+
variantId={product.variants[0]?.id ?? null}
|
|
79
|
+
title={product.title}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
<div className="product-card__info">
|
|
84
|
+
{product.vendor && <span className="product-card__vendor">{product.vendor}</span>}
|
|
85
|
+
<Link href={href} className="product-card__link">
|
|
86
|
+
<span className="product-card__title">{product.title}</span>
|
|
87
|
+
</Link>
|
|
88
|
+
<span className="product-card__rating">
|
|
89
|
+
<Rating
|
|
90
|
+
average={product.rating.average}
|
|
91
|
+
count={product.rating.count}
|
|
92
|
+
label={
|
|
93
|
+
product.rating.average === null
|
|
94
|
+
? ''
|
|
95
|
+
: t.ratingSummary(product.rating.average, product.rating.count)
|
|
96
|
+
}
|
|
97
|
+
/>
|
|
98
|
+
</span>
|
|
99
|
+
<Price price={product.price} compareAtPrice={product.compareAtPrice} />
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -1,67 +1,37 @@
|
|
|
1
|
-
import { Image, Money } from '@magicstoreai/hydrogen';
|
|
2
1
|
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
2
|
|
|
7
|
-
|
|
8
|
-
function ProductBadges({ product }: { product: Schema<'Product'> }) {
|
|
9
|
-
const t = messages(locale);
|
|
10
|
-
const badges = [
|
|
11
|
-
product.preOrder && t.preOrder,
|
|
12
|
-
product.compareAtPrice && t.sale,
|
|
13
|
-
product.isNew && t.isNew,
|
|
14
|
-
]
|
|
15
|
-
.filter((badge): badge is string => typeof badge === 'string')
|
|
16
|
-
.slice(0, 2);
|
|
17
|
-
if (badges.length === 0) {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
return (
|
|
21
|
-
<span className="row">
|
|
22
|
-
{badges.map((badge) => (
|
|
23
|
-
<span key={badge} className="badge">
|
|
24
|
-
{badge}
|
|
25
|
-
</span>
|
|
26
|
-
))}
|
|
27
|
-
</span>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
3
|
+
import { ProductCard } from '@/components/product-card';
|
|
30
4
|
|
|
31
|
-
/**
|
|
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
|
-
}
|
|
5
|
+
/** How many cards a grid loads at once: its first row at the widest layout (4 columns). */
|
|
6
|
+
const EAGER_CARDS = 4;
|
|
55
7
|
|
|
56
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Product cards in the theme's grid (2 / 3 / 4 columns), or one row that scrolls sideways
|
|
10
|
+
* (`slider`). The first row is loaded at once, the rest lazily. Renders nothing for no products:
|
|
11
|
+
* the page says why (an empty collection, no match).
|
|
12
|
+
*/
|
|
13
|
+
export function ProductGrid({
|
|
14
|
+
products,
|
|
15
|
+
locale,
|
|
16
|
+
eager = true,
|
|
17
|
+
slider = false,
|
|
18
|
+
}: {
|
|
19
|
+
products: Schema<'Product'>[];
|
|
20
|
+
locale: string;
|
|
21
|
+
/** `false` for a grid below the fold (a shelf, a related list): every card lazy. */
|
|
22
|
+
eager?: boolean;
|
|
23
|
+
slider?: boolean;
|
|
24
|
+
}) {
|
|
57
25
|
if (products.length === 0) {
|
|
58
|
-
return
|
|
26
|
+
return null;
|
|
59
27
|
}
|
|
60
28
|
return (
|
|
61
|
-
<
|
|
62
|
-
{products.map((product) => (
|
|
63
|
-
<
|
|
29
|
+
<ul className={slider ? 'product-grid product-grid--slider' : 'product-grid'}>
|
|
30
|
+
{products.map((product, index) => (
|
|
31
|
+
<li key={product.id}>
|
|
32
|
+
<ProductCard product={product} locale={locale} eager={eager && index < EAGER_CARDS} />
|
|
33
|
+
</li>
|
|
64
34
|
))}
|
|
65
|
-
</
|
|
35
|
+
</ul>
|
|
66
36
|
);
|
|
67
37
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCart, useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { useCartDrawer } from '@/components/cart/cart-drawer';
|
|
7
|
+
import { Icon } from '@/components/ui/icon';
|
|
8
|
+
import { describe } from '@/lib/errors';
|
|
9
|
+
import { messages } from '@/lib/i18n';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Add a product with nothing to choose straight from its card, then open the cart drawer (or
|
|
13
|
+
* announce the add, per `cartType`). A refusal (out of stock meanwhile) shows the API's detail.
|
|
14
|
+
*/
|
|
15
|
+
export function QuickAdd({
|
|
16
|
+
productId,
|
|
17
|
+
variantId,
|
|
18
|
+
title,
|
|
19
|
+
}: {
|
|
20
|
+
productId: string;
|
|
21
|
+
variantId: string | null;
|
|
22
|
+
title: string;
|
|
23
|
+
}) {
|
|
24
|
+
const { addLine } = useCart();
|
|
25
|
+
const drawer = useCartDrawer();
|
|
26
|
+
const t = messages(useMagicStore().locale);
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
async function add() {
|
|
31
|
+
setBusy(true);
|
|
32
|
+
setError(null);
|
|
33
|
+
try {
|
|
34
|
+
await addLine({ productId, variantId, quantity: 1 });
|
|
35
|
+
drawer.added();
|
|
36
|
+
} catch (e) {
|
|
37
|
+
setError(describe(e, t.couldNotAdd));
|
|
38
|
+
} finally {
|
|
39
|
+
setBusy(false);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
className="product-card__quick-add"
|
|
48
|
+
aria-label={t.quickAdd(title)}
|
|
49
|
+
aria-busy={busy || undefined}
|
|
50
|
+
disabled={busy}
|
|
51
|
+
onClick={() => void add()}
|
|
52
|
+
>
|
|
53
|
+
<Icon name="plus" size={18} />
|
|
54
|
+
</button>
|
|
55
|
+
{error && (
|
|
56
|
+
<p className="product-card__error" role="alert">
|
|
57
|
+
{error}
|
|
58
|
+
</p>
|
|
59
|
+
)}
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|