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.
Files changed (139) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/AGENTS.md +32 -16
  4. package/template/DESIGN.md +117 -0
  5. package/template/PAGES.md +107 -29
  6. package/template/README.md +15 -3
  7. package/template/app/account/addresses/page.tsx +16 -0
  8. package/template/app/account/layout.tsx +14 -0
  9. package/template/app/account/loading.tsx +15 -0
  10. package/template/app/account/orders/[id]/page.tsx +15 -0
  11. package/template/app/account/page.tsx +12 -244
  12. package/template/app/account/profile/page.tsx +16 -0
  13. package/template/app/blog/(index)/loading.tsx +22 -0
  14. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  15. package/template/app/blog/[handle]/page.tsx +92 -56
  16. package/template/app/blog/author/[handle]/page.tsx +1 -1
  17. package/template/app/cart/layout.tsx +14 -0
  18. package/template/app/cart/page.tsx +49 -101
  19. package/template/app/checkout/layout.tsx +14 -0
  20. package/template/app/checkout/page.tsx +98 -281
  21. package/template/app/collections/[handle]/layout.tsx +20 -0
  22. package/template/app/collections/[handle]/loading.tsx +23 -0
  23. package/template/app/collections/[handle]/page.tsx +131 -15
  24. package/template/app/collections/page.tsx +49 -0
  25. package/template/app/contacts/page.tsx +196 -0
  26. package/template/app/error.tsx +19 -8
  27. package/template/app/fonts/OFL.txt +93 -0
  28. package/template/app/fonts/index.ts +14 -0
  29. package/template/app/fonts/onest-variable.woff2 +0 -0
  30. package/template/app/globals.css +14 -518
  31. package/template/app/layout.tsx +73 -50
  32. package/template/app/not-found.tsx +34 -4
  33. package/template/app/page.tsx +1 -1
  34. package/template/app/pages/[handle]/page.tsx +1 -1
  35. package/template/app/products/[handle]/layout.tsx +20 -0
  36. package/template/app/products/[handle]/loading.tsx +25 -0
  37. package/template/app/products/[handle]/page.tsx +112 -15
  38. package/template/app/providers.tsx +5 -1
  39. package/template/app/search/loading.tsx +12 -0
  40. package/template/app/search/page.tsx +138 -25
  41. package/template/app/sitemap.ts +5 -1
  42. package/template/app/styles/base.css +138 -0
  43. package/template/app/styles/components.css +1108 -0
  44. package/template/app/styles/layout.css +455 -0
  45. package/template/app/styles/pages.css +1839 -0
  46. package/template/app/styles/reset.css +55 -0
  47. package/template/app/styles/sections.css +415 -0
  48. package/template/app/styles/tokens.css +167 -0
  49. package/template/app/styles/utilities.css +37 -0
  50. package/template/app/wishlist/page.tsx +26 -0
  51. package/template/components/account/account-shell.tsx +100 -0
  52. package/template/components/account/addresses.tsx +247 -0
  53. package/template/components/account/order-detail.tsx +281 -0
  54. package/template/components/account/order-list.tsx +94 -0
  55. package/template/components/account/order-status.tsx +19 -0
  56. package/template/components/account/profile.tsx +129 -0
  57. package/template/components/account/sign-in.tsx +164 -0
  58. package/template/components/article-body.tsx +3 -2
  59. package/template/components/article-card.tsx +37 -12
  60. package/template/components/cart/cart-drawer.tsx +149 -0
  61. package/template/components/cart/cart-lines.tsx +89 -0
  62. package/template/components/cart/cart-summary.tsx +191 -0
  63. package/template/components/catalog/catalog-form.tsx +107 -0
  64. package/template/components/catalog/catalog-layout.tsx +82 -0
  65. package/template/components/catalog/facets-shell.tsx +60 -0
  66. package/template/components/catalog/facets.tsx +216 -0
  67. package/template/components/checkout/order-summary.tsx +84 -0
  68. package/template/components/checkout/radio-card.tsx +34 -0
  69. package/template/components/checkout/step-indicator.tsx +56 -0
  70. package/template/components/checkout/steps.tsx +264 -0
  71. package/template/components/checkout/thank-you.tsx +48 -0
  72. package/template/components/checkout/use-checkout.ts +90 -0
  73. package/template/components/footer/site-footer.tsx +132 -0
  74. package/template/components/header/cart-button.tsx +43 -0
  75. package/template/components/header/desktop-nav.tsx +133 -0
  76. package/template/components/header/mobile-menu.tsx +102 -0
  77. package/template/components/header/site-header.tsx +78 -0
  78. package/template/components/page-loading.tsx +20 -0
  79. package/template/components/pager.tsx +3 -1
  80. package/template/components/product/buy-box.tsx +148 -0
  81. package/template/components/product/gallery.tsx +179 -0
  82. package/template/components/product/pre-order-form.tsx +93 -0
  83. package/template/components/product/product-selection.tsx +31 -0
  84. package/template/components/product/recommendations.tsx +26 -0
  85. package/template/components/product/review-list.tsx +159 -0
  86. package/template/components/product/reviews.tsx +80 -0
  87. package/template/components/product/variant-picker.tsx +75 -0
  88. package/template/components/product-card.tsx +103 -0
  89. package/template/components/product-grid.tsx +27 -57
  90. package/template/components/quick-add.tsx +62 -0
  91. package/template/components/search/predictive-search.tsx +282 -0
  92. package/template/components/sections/announcement-bar.tsx +1 -1
  93. package/template/components/sections/banner.tsx +1 -1
  94. package/template/components/sections/collections.tsx +50 -13
  95. package/template/components/sections/deal-of-day.tsx +2 -2
  96. package/template/components/sections/product-shelves.tsx +13 -5
  97. package/template/components/sections/shoppable-stories.tsx +2 -2
  98. package/template/components/sections/store-reviews.tsx +10 -4
  99. package/template/components/share-button.tsx +42 -0
  100. package/template/components/ui/accordion.tsx +35 -0
  101. package/template/components/ui/badge.tsx +20 -0
  102. package/template/components/ui/breadcrumbs.tsx +31 -0
  103. package/template/components/ui/button.tsx +90 -0
  104. package/template/components/ui/drawer.tsx +84 -0
  105. package/template/components/ui/empty-state.tsx +27 -0
  106. package/template/components/ui/field.tsx +33 -0
  107. package/template/components/ui/icon.tsx +112 -0
  108. package/template/components/ui/price.tsx +35 -0
  109. package/template/components/ui/quantity-stepper.tsx +91 -0
  110. package/template/components/ui/rating.tsx +48 -0
  111. package/template/components/ui/skeleton.tsx +46 -0
  112. package/template/components/wishlist/wishlist-view.tsx +134 -0
  113. package/template/lib/article-outline.ts +35 -0
  114. package/template/lib/catalog-params.ts +170 -0
  115. package/template/lib/contacts.ts +34 -0
  116. package/template/lib/errors.ts +29 -0
  117. package/template/lib/i18n.ts +484 -0
  118. package/template/lib/menu.ts +70 -0
  119. package/template/lib/theme-settings.ts +36 -0
  120. package/template/theme/index.ts +7 -1
  121. package/template/theme/sections/collapsible-content.tsx +75 -0
  122. package/template/theme/sections/collection-list.tsx +32 -4
  123. package/template/theme/sections/featured-collection.tsx +22 -2
  124. package/template/theme/sections/hero.tsx +94 -7
  125. package/template/theme/sections/image-with-text.tsx +19 -3
  126. package/template/theme/sections/multicolumn.tsx +113 -0
  127. package/template/theme/sections/page-content.tsx +10 -4
  128. package/template/theme/sections/product-shelf.tsx +26 -4
  129. package/template/theme/sections/rich-text.tsx +13 -1
  130. package/template/theme/sections/testimonials.tsx +14 -9
  131. package/template/theme/settings.ts +85 -1
  132. package/template/theme/templates/index.json +4 -2
  133. package/template/.screenshots/home-1280.png +0 -0
  134. package/template/.screenshots/home-390.png +0 -0
  135. package/template/.screenshots/home-filled-1280.png +0 -0
  136. package/template/.screenshots/home-filled-390.png +0 -0
  137. package/template/.screenshots/page-1280.png +0 -0
  138. package/template/components/buy-box.tsx +0 -89
  139. 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
+ }