create-magic-storefront 0.5.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/AGENTS.md +32 -16
- package/template/DESIGN.md +117 -0
- package/template/PAGES.md +107 -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 +73 -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 +5 -1
- 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/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/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,148 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MagicStoreError,
|
|
5
|
+
useAnalytics,
|
|
6
|
+
useCart,
|
|
7
|
+
useMagicStore,
|
|
8
|
+
useWishlist,
|
|
9
|
+
} from '@magicstoreai/hydrogen';
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import { useCartDrawer } from '@/components/cart/cart-drawer';
|
|
13
|
+
import { Button } from '@/components/ui/button';
|
|
14
|
+
import { Icon } from '@/components/ui/icon';
|
|
15
|
+
import { Price } from '@/components/ui/price';
|
|
16
|
+
import { QuantityStepper } from '@/components/ui/quantity-stepper';
|
|
17
|
+
import { messages, type Messages } from '@/lib/i18n';
|
|
18
|
+
|
|
19
|
+
import { PreOrderForm } from './pre-order-form';
|
|
20
|
+
import { useProductSelection } from './product-selection';
|
|
21
|
+
import { VariantPicker } from './variant-picker';
|
|
22
|
+
|
|
23
|
+
/** At or below this many in stock, the buy box says how many are left (the API's number). */
|
|
24
|
+
const LOW_STOCK = 5;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The price, the variant picker, the stock the API reports, a quantity and add to cart (which
|
|
28
|
+
* opens the cart drawer) — or a pre-order form for a product open for pre-order — the wishlist
|
|
29
|
+
* toggle, and the shop's delivery terms.
|
|
30
|
+
*/
|
|
31
|
+
export function BuyBox({
|
|
32
|
+
wishlist: wishlistEnabled,
|
|
33
|
+
deliveryText,
|
|
34
|
+
}: {
|
|
35
|
+
wishlist: boolean;
|
|
36
|
+
deliveryText: string | null;
|
|
37
|
+
}) {
|
|
38
|
+
const { product, selectedVariant } = useProductSelection();
|
|
39
|
+
const { addLine, status } = useCart();
|
|
40
|
+
const wishlist = useWishlist();
|
|
41
|
+
const analytics = useAnalytics();
|
|
42
|
+
const cartDrawer = useCartDrawer();
|
|
43
|
+
const t = messages(useMagicStore().locale);
|
|
44
|
+
const [quantity, setQuantity] = useState(1);
|
|
45
|
+
const [adding, setAdding] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(
|
|
49
|
+
() => analytics.productView(product.id, selectedVariant?.id),
|
|
50
|
+
[analytics, product.id, selectedVariant?.id],
|
|
51
|
+
);
|
|
52
|
+
useEffect(() => setQuantity(1), [selectedVariant?.id]);
|
|
53
|
+
|
|
54
|
+
// A product without options has one variant; a product without variants is bought as itself.
|
|
55
|
+
const hasVariants = product.variants.length > 0;
|
|
56
|
+
const variant = hasVariants ? selectedVariant : null;
|
|
57
|
+
const forSale = hasVariants
|
|
58
|
+
? Boolean(selectedVariant?.availableForSale)
|
|
59
|
+
: product.availableForSale;
|
|
60
|
+
const stock = hasVariants
|
|
61
|
+
? (selectedVariant?.quantityAvailable ?? null)
|
|
62
|
+
: product.quantityAvailable;
|
|
63
|
+
const saved = wishlist.has(product.id);
|
|
64
|
+
|
|
65
|
+
async function add() {
|
|
66
|
+
setError(null);
|
|
67
|
+
setAdding(true);
|
|
68
|
+
try {
|
|
69
|
+
await addLine({ productId: product.id, variantId: variant?.id ?? null, quantity });
|
|
70
|
+
cartDrawer.added();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
setError(e instanceof MagicStoreError ? (e.detail ?? e.message) : t.couldNotAdd);
|
|
73
|
+
} finally {
|
|
74
|
+
setAdding(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="buy-box">
|
|
80
|
+
<Price
|
|
81
|
+
price={variant?.price ?? product.price}
|
|
82
|
+
compareAtPrice={variant ? variant.compareAtPrice : product.compareAtPrice}
|
|
83
|
+
size="lg"
|
|
84
|
+
/>
|
|
85
|
+
<VariantPicker />
|
|
86
|
+
{!product.preOrder && <StockNote forSale={forSale} stock={stock} t={t} />}
|
|
87
|
+
{product.preOrder && variant ? (
|
|
88
|
+
<PreOrderForm productId={product.id} variantId={variant.id} t={t} />
|
|
89
|
+
) : (
|
|
90
|
+
<div className="buy-box__actions">
|
|
91
|
+
{forSale && (
|
|
92
|
+
<QuantityStepper
|
|
93
|
+
value={quantity}
|
|
94
|
+
onChange={setQuantity}
|
|
95
|
+
max={stock}
|
|
96
|
+
label={t.quantity}
|
|
97
|
+
decreaseLabel={t.decreaseQuantity}
|
|
98
|
+
increaseLabel={t.increaseQuantity}
|
|
99
|
+
/>
|
|
100
|
+
)}
|
|
101
|
+
<Button
|
|
102
|
+
variant="primary"
|
|
103
|
+
size="lg"
|
|
104
|
+
className="buy-box__add"
|
|
105
|
+
disabled={!forSale || status === 'updating'}
|
|
106
|
+
loading={adding}
|
|
107
|
+
onClick={add}
|
|
108
|
+
>
|
|
109
|
+
{forSale ? t.addToCart : t.soldOut}
|
|
110
|
+
</Button>
|
|
111
|
+
{wishlistEnabled && (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className="icon-button buy-box__wishlist"
|
|
115
|
+
aria-label={saved ? t.saved : t.save}
|
|
116
|
+
aria-pressed={saved}
|
|
117
|
+
onClick={() => void wishlist.toggle(product.id)}
|
|
118
|
+
>
|
|
119
|
+
<Icon name="heart" />
|
|
120
|
+
</button>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
{error && (
|
|
125
|
+
<p className="error" role="alert">
|
|
126
|
+
{error}
|
|
127
|
+
</p>
|
|
128
|
+
)}
|
|
129
|
+
<p className="buy-box__delivery">
|
|
130
|
+
<Icon name="truck" />
|
|
131
|
+
<span>{deliveryText ?? t.deliveryAtCheckout}</span>
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Stock only as the API reports it: nothing when it is not tracked, never an invented number. */
|
|
138
|
+
function StockNote({ forSale, stock, t }: { forSale: boolean; stock: number | null; t: Messages }) {
|
|
139
|
+
if (!forSale || stock === null) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return (
|
|
143
|
+
<p className={stock <= LOW_STOCK ? 'stock-note stock-note--low' : 'stock-note'}>
|
|
144
|
+
<span className="stock-note__dot" aria-hidden="true" />
|
|
145
|
+
{stock <= LOW_STOCK ? t.lowStock(stock) : t.inStock}
|
|
146
|
+
</p>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Image, useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Icon } from '@/components/ui/icon';
|
|
8
|
+
import { messages } from '@/lib/i18n';
|
|
9
|
+
|
|
10
|
+
import { useProductSelection } from './product-selection';
|
|
11
|
+
|
|
12
|
+
type ImageData = Schema<'Image'>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The product's images (and its video, last): a swipeable scroll-snap track with a counter on
|
|
16
|
+
* phones, thumbnails and arrows from 750px. Choosing a variant with an image shows that image. The
|
|
17
|
+
* first image loads eagerly with high priority, the rest lazily; slides keep the theme's ratio.
|
|
18
|
+
*/
|
|
19
|
+
export function Gallery({
|
|
20
|
+
images,
|
|
21
|
+
video,
|
|
22
|
+
title,
|
|
23
|
+
}: {
|
|
24
|
+
images: ImageData[];
|
|
25
|
+
video: string | null;
|
|
26
|
+
title: string;
|
|
27
|
+
}) {
|
|
28
|
+
const t = messages(useMagicStore().locale);
|
|
29
|
+
const { selectedVariant } = useProductSelection();
|
|
30
|
+
const variantImage = selectedVariant?.image ?? null;
|
|
31
|
+
// A variant image the product list does not hold goes first.
|
|
32
|
+
const slides =
|
|
33
|
+
variantImage && !images.some((image) => image.url === variantImage.url)
|
|
34
|
+
? [variantImage, ...images]
|
|
35
|
+
: images;
|
|
36
|
+
const total = slides.length + (video ? 1 : 0);
|
|
37
|
+
const [active, setActive] = useState(0);
|
|
38
|
+
const track = useRef<HTMLDivElement>(null);
|
|
39
|
+
const thumbs = useRef<HTMLUListElement>(null);
|
|
40
|
+
|
|
41
|
+
function show(index: number) {
|
|
42
|
+
const element = track.current;
|
|
43
|
+
if (element) {
|
|
44
|
+
element.scrollTo({ left: index * element.clientWidth });
|
|
45
|
+
}
|
|
46
|
+
setActive(index);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (variantImage) {
|
|
51
|
+
show(
|
|
52
|
+
Math.max(
|
|
53
|
+
0,
|
|
54
|
+
slides.findIndex((image) => image.url === variantImage.url),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// Only a new variant image moves the gallery, not a new slides array.
|
|
59
|
+
}, [variantImage?.url]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const list = thumbs.current;
|
|
63
|
+
const thumb = list?.children[active] as HTMLElement | undefined;
|
|
64
|
+
if (!list || !thumb) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const start = thumb.offsetLeft - list.offsetLeft;
|
|
68
|
+
if (start < list.scrollLeft || start + thumb.offsetWidth > list.scrollLeft + list.clientWidth) {
|
|
69
|
+
list.scrollTo({ left: start - list.clientWidth / 2 + thumb.offsetWidth / 2 });
|
|
70
|
+
}
|
|
71
|
+
}, [active]);
|
|
72
|
+
|
|
73
|
+
if (total === 0) {
|
|
74
|
+
return (
|
|
75
|
+
<div className="gallery">
|
|
76
|
+
<div className="gallery__slide">
|
|
77
|
+
<span className="media-fallback" />
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="gallery" role="region" aria-roledescription="carousel" aria-label={title}>
|
|
85
|
+
<div className="gallery__viewport">
|
|
86
|
+
<div
|
|
87
|
+
ref={track}
|
|
88
|
+
className="gallery__track"
|
|
89
|
+
onScroll={(event) => {
|
|
90
|
+
const element = event.currentTarget;
|
|
91
|
+
setActive(Math.round(element.scrollLeft / element.clientWidth));
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{slides.map((image, index) => (
|
|
95
|
+
<div
|
|
96
|
+
key={image.url}
|
|
97
|
+
className="gallery__slide"
|
|
98
|
+
role="group"
|
|
99
|
+
aria-roledescription="slide"
|
|
100
|
+
aria-label={t.imageOf(index + 1, total)}
|
|
101
|
+
>
|
|
102
|
+
<Image
|
|
103
|
+
data={image}
|
|
104
|
+
alt={index === 0 ? title : ''}
|
|
105
|
+
loading={index === 0 ? 'eager' : 'lazy'}
|
|
106
|
+
fetchPriority={index === 0 ? 'high' : undefined}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
{video && (
|
|
111
|
+
<div
|
|
112
|
+
className="gallery__slide gallery__slide--video"
|
|
113
|
+
role="group"
|
|
114
|
+
aria-roledescription="slide"
|
|
115
|
+
aria-label={t.video}
|
|
116
|
+
>
|
|
117
|
+
<video src={video} controls playsInline preload="metadata" />
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
{total > 1 && (
|
|
122
|
+
<>
|
|
123
|
+
<p className="gallery__counter" aria-hidden="true">
|
|
124
|
+
{active + 1} / {total}
|
|
125
|
+
</p>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
className="icon-button gallery__arrow gallery__arrow--prev"
|
|
129
|
+
aria-label={t.previousImage}
|
|
130
|
+
disabled={active === 0}
|
|
131
|
+
onClick={() => show(active - 1)}
|
|
132
|
+
>
|
|
133
|
+
<Icon name="chevron-left" />
|
|
134
|
+
</button>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="icon-button gallery__arrow gallery__arrow--next"
|
|
138
|
+
aria-label={t.nextImage}
|
|
139
|
+
disabled={active === total - 1}
|
|
140
|
+
onClick={() => show(active + 1)}
|
|
141
|
+
>
|
|
142
|
+
<Icon name="chevron-right" />
|
|
143
|
+
</button>
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
{total > 1 && (
|
|
148
|
+
<ul ref={thumbs} className="gallery__thumbs">
|
|
149
|
+
{slides.map((image, index) => (
|
|
150
|
+
<li key={image.url}>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
className="gallery__thumb"
|
|
154
|
+
aria-label={t.imageOf(index + 1, total)}
|
|
155
|
+
aria-current={index === active || undefined}
|
|
156
|
+
onClick={() => show(index)}
|
|
157
|
+
>
|
|
158
|
+
<Image data={image} alt="" width={64} height={64} />
|
|
159
|
+
</button>
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
{video && (
|
|
163
|
+
<li>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
className="gallery__thumb gallery__thumb--video"
|
|
167
|
+
aria-label={t.video}
|
|
168
|
+
aria-current={active === slides.length || undefined}
|
|
169
|
+
onClick={() => show(slides.length)}
|
|
170
|
+
>
|
|
171
|
+
<Icon name="play" />
|
|
172
|
+
</button>
|
|
173
|
+
</li>
|
|
174
|
+
)}
|
|
175
|
+
</ul>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { MagicStoreError, useCustomer, useStorefrontClient } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { useState, type FormEvent } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import type { Messages } from '@/lib/i18n';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A pre-order (`POST /pre-orders`): a button that opens a short form — name and phone; a signed-in
|
|
11
|
+
* customer sends none, the shop uses theirs. The phone goes in the request body only.
|
|
12
|
+
*/
|
|
13
|
+
export function PreOrderForm({
|
|
14
|
+
productId,
|
|
15
|
+
variantId,
|
|
16
|
+
t,
|
|
17
|
+
}: {
|
|
18
|
+
productId: string;
|
|
19
|
+
variantId: string;
|
|
20
|
+
t: Messages;
|
|
21
|
+
}) {
|
|
22
|
+
const client = useStorefrontClient();
|
|
23
|
+
const { isSignedIn } = useCustomer();
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const [sending, setSending] = useState(false);
|
|
26
|
+
const [sent, setSent] = useState(false);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
const form = new FormData(event.currentTarget);
|
|
32
|
+
setError(null);
|
|
33
|
+
setSending(true);
|
|
34
|
+
try {
|
|
35
|
+
await client.preOrdersStore({
|
|
36
|
+
body: {
|
|
37
|
+
productId,
|
|
38
|
+
variantId,
|
|
39
|
+
quantity: 1,
|
|
40
|
+
...(!isSignedIn && {
|
|
41
|
+
name: String(form.get('name')),
|
|
42
|
+
phone: String(form.get('phone')),
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
setSent(true);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
setError(e instanceof MagicStoreError ? (e.detail ?? e.message) : t.somethingWentWrong);
|
|
49
|
+
} finally {
|
|
50
|
+
setSending(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (sent) {
|
|
55
|
+
return (
|
|
56
|
+
<p className="buy-box__note" role="status">
|
|
57
|
+
{t.preOrderSent}
|
|
58
|
+
</p>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!open && !isSignedIn) {
|
|
62
|
+
return (
|
|
63
|
+
<Button variant="primary" size="lg" full onClick={() => setOpen(true)}>
|
|
64
|
+
{t.preOrder}
|
|
65
|
+
</Button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return (
|
|
69
|
+
<form className="pre-order stack" onSubmit={submit}>
|
|
70
|
+
{!isSignedIn && (
|
|
71
|
+
<>
|
|
72
|
+
<p className="muted text-sm">{t.preOrderHint}</p>
|
|
73
|
+
<label className="field">
|
|
74
|
+
<span className="field__label">{t.name}</span>
|
|
75
|
+
<input name="name" autoComplete="name" required maxLength={255} />
|
|
76
|
+
</label>
|
|
77
|
+
<label className="field">
|
|
78
|
+
<span className="field__label">{t.phone}</span>
|
|
79
|
+
<input name="phone" type="tel" autoComplete="tel" required maxLength={32} />
|
|
80
|
+
</label>
|
|
81
|
+
</>
|
|
82
|
+
)}
|
|
83
|
+
<Button type="submit" variant="primary" size="lg" full loading={sending}>
|
|
84
|
+
{isSignedIn ? t.preOrder : t.sendPreOrder}
|
|
85
|
+
</Button>
|
|
86
|
+
{error && (
|
|
87
|
+
<p className="error" role="alert">
|
|
88
|
+
{error}
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
</form>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useVariantSelection } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
type Product = Schema<'ProductDetail'>;
|
|
8
|
+
type Selection = ReturnType<typeof useVariantSelection> & { product: Product };
|
|
9
|
+
|
|
10
|
+
const SelectionContext = createContext<Selection | null>(null);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The chosen variant, shared by the parts of the product page that depend on it: the gallery shows
|
|
14
|
+
* its image, the picker and the buy box its options, price and stock.
|
|
15
|
+
*/
|
|
16
|
+
export function ProductSelection({ product, children }: { product: Product; children: ReactNode }) {
|
|
17
|
+
const selection = useVariantSelection(product);
|
|
18
|
+
return (
|
|
19
|
+
<SelectionContext.Provider value={{ ...selection, product }}>
|
|
20
|
+
{children}
|
|
21
|
+
</SelectionContext.Provider>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useProductSelection(): Selection {
|
|
26
|
+
const selection = useContext(SelectionContext);
|
|
27
|
+
if (selection === null) {
|
|
28
|
+
throw new Error('useProductSelection must be used inside <ProductSelection>.');
|
|
29
|
+
}
|
|
30
|
+
return selection;
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
2
|
+
|
|
3
|
+
import { ProductGrid } from '@/components/product-grid';
|
|
4
|
+
|
|
5
|
+
/** A shelf of recommended products under the product; nothing when the API has none. */
|
|
6
|
+
export function Recommendations({
|
|
7
|
+
title,
|
|
8
|
+
products,
|
|
9
|
+
locale,
|
|
10
|
+
}: {
|
|
11
|
+
title: string;
|
|
12
|
+
products: Schema<'Product'>[];
|
|
13
|
+
locale: string;
|
|
14
|
+
}) {
|
|
15
|
+
if (products.length === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return (
|
|
19
|
+
<section className="product-recommendations">
|
|
20
|
+
<div className="section-header">
|
|
21
|
+
<h2>{title}</h2>
|
|
22
|
+
</div>
|
|
23
|
+
<ProductGrid products={products} locale={locale} eager={false} />
|
|
24
|
+
</section>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Image, MagicStoreError, useMagicStore, useStorefrontClient } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Badge } from '@/components/ui/badge';
|
|
8
|
+
import { Button } from '@/components/ui/button';
|
|
9
|
+
import { Rating } from '@/components/ui/rating';
|
|
10
|
+
import { messages, type Messages } from '@/lib/i18n';
|
|
11
|
+
|
|
12
|
+
type Review = Schema<'Review'>;
|
|
13
|
+
type Pagination = Schema<'Pagination'>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The review list: the server's first page, then "show more" and a filter by rating, both asked of
|
|
17
|
+
* the API (`productsReviews`, `filter[rating]`).
|
|
18
|
+
*/
|
|
19
|
+
export function ReviewList({
|
|
20
|
+
handle,
|
|
21
|
+
initial,
|
|
22
|
+
pagination,
|
|
23
|
+
perPage,
|
|
24
|
+
ratings,
|
|
25
|
+
locale,
|
|
26
|
+
}: {
|
|
27
|
+
handle: string;
|
|
28
|
+
initial: Review[];
|
|
29
|
+
pagination: Pagination;
|
|
30
|
+
perPage: number;
|
|
31
|
+
ratings: number[];
|
|
32
|
+
locale: string;
|
|
33
|
+
}) {
|
|
34
|
+
const client = useStorefrontClient();
|
|
35
|
+
const t = messages(useMagicStore().locale);
|
|
36
|
+
const [reviews, setReviews] = useState(initial);
|
|
37
|
+
const [page, setPage] = useState(pagination);
|
|
38
|
+
const [rating, setRating] = useState<number | null>(null);
|
|
39
|
+
const [loading, setLoading] = useState(false);
|
|
40
|
+
const [error, setError] = useState<string | null>(null);
|
|
41
|
+
|
|
42
|
+
async function load(next: number, filter: number | null) {
|
|
43
|
+
setError(null);
|
|
44
|
+
setLoading(true);
|
|
45
|
+
try {
|
|
46
|
+
const result = await client.productsReviews({
|
|
47
|
+
path: { handle },
|
|
48
|
+
query: {
|
|
49
|
+
page: next,
|
|
50
|
+
perPage,
|
|
51
|
+
...(filter !== null && { 'filter[rating]': String(filter) }),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
setReviews((current) => (next === 1 ? result.data : [...current, ...result.data]));
|
|
55
|
+
setPage(result.meta.pagination);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
setError(e instanceof MagicStoreError ? (e.detail ?? e.message) : t.somethingWentWrong);
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function filterBy(next: number | null) {
|
|
64
|
+
setRating(next);
|
|
65
|
+
void load(1, next);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="review-list">
|
|
70
|
+
{ratings.length > 1 && (
|
|
71
|
+
<div className="chips review-list__filter" role="group" aria-label={t.reviews}>
|
|
72
|
+
<button type="button" aria-pressed={rating === null} onClick={() => filterBy(null)}>
|
|
73
|
+
{t.allRatings}
|
|
74
|
+
</button>
|
|
75
|
+
{[...ratings]
|
|
76
|
+
.sort((a, b) => b - a)
|
|
77
|
+
.map((stars) => (
|
|
78
|
+
<button
|
|
79
|
+
key={stars}
|
|
80
|
+
type="button"
|
|
81
|
+
aria-pressed={rating === stars}
|
|
82
|
+
onClick={() => filterBy(stars)}
|
|
83
|
+
>
|
|
84
|
+
{t.starsFilter(stars)}
|
|
85
|
+
</button>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
{reviews.length === 0 && !loading && <p className="muted">{t.noReviewsForRating}</p>}
|
|
90
|
+
<ul className="review-list__items" aria-busy={loading || undefined}>
|
|
91
|
+
{reviews.map((review) => (
|
|
92
|
+
<ReviewItem key={review.id} review={review} locale={locale} t={t} />
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
{error && (
|
|
96
|
+
<p className="error" role="alert">
|
|
97
|
+
{error}
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
{page.hasNextPage && (
|
|
101
|
+
<Button loading={loading} onClick={() => void load(page.page + 1, rating)}>
|
|
102
|
+
{t.showMoreReviews}
|
|
103
|
+
</Button>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function ReviewItem({ review, locale, t }: { review: Review; locale: string; t: Messages }) {
|
|
110
|
+
const date = review.publishedAt ?? review.createdAt;
|
|
111
|
+
return (
|
|
112
|
+
<li className="review">
|
|
113
|
+
<div className="review__header">
|
|
114
|
+
<Rating
|
|
115
|
+
average={review.rating}
|
|
116
|
+
count={1}
|
|
117
|
+
label={t.ratedOutOf(review.rating)}
|
|
118
|
+
showCount={false}
|
|
119
|
+
/>
|
|
120
|
+
<span className="review__author">{review.author.name}</span>
|
|
121
|
+
{date && (
|
|
122
|
+
<time className="muted text-sm" dateTime={date}>
|
|
123
|
+
{new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(date))}
|
|
124
|
+
</time>
|
|
125
|
+
)}
|
|
126
|
+
{review.isVerifiedBuyer && <Badge kind="neutral">{t.verifiedBuyer}</Badge>}
|
|
127
|
+
</div>
|
|
128
|
+
{review.variantTitle && <p className="muted text-sm">{review.variantTitle}</p>}
|
|
129
|
+
{review.body && <p className="review__body">{review.body}</p>}
|
|
130
|
+
{review.pros && (
|
|
131
|
+
<p>
|
|
132
|
+
<strong>{t.pros}:</strong> {review.pros}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
{review.cons && (
|
|
136
|
+
<p>
|
|
137
|
+
<strong>{t.cons}:</strong> {review.cons}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
{review.images.length > 0 && (
|
|
141
|
+
<ul className="review__images">
|
|
142
|
+
{review.images.map((image) => (
|
|
143
|
+
<li key={image.url}>
|
|
144
|
+
<a href={image.url} target="_blank" rel="noopener noreferrer">
|
|
145
|
+
<Image data={image} alt="" width={72} height={72} />
|
|
146
|
+
</a>
|
|
147
|
+
</li>
|
|
148
|
+
))}
|
|
149
|
+
</ul>
|
|
150
|
+
)}
|
|
151
|
+
{review.reply && (
|
|
152
|
+
<div className="review__reply">
|
|
153
|
+
<p className="review__reply-title">{t.shopReply}</p>
|
|
154
|
+
<p>{review.reply}</p>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</li>
|
|
158
|
+
);
|
|
159
|
+
}
|