create-magic-storefront 0.1.1 → 0.1.3
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 +1 -1
- package/template/.claude/skills/storefront-design/SKILL.md +19 -8
- package/template/.claude/skills/storefront-verify/SKILL.md +85 -0
- package/template/.claude/skills/vercel-react-best-practices/AGENTS.md +3810 -0
- package/template/.claude/skills/vercel-react-best-practices/README.md +123 -0
- package/template/.claude/skills/vercel-react-best-practices/SKILL.md +149 -0
- package/template/.claude/skills/vercel-react-best-practices/SOURCE.md +7 -0
- package/template/.claude/skills/vercel-react-best-practices/metadata.json +15 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/_sections.md +46 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/_template.md +28 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/advanced-effect-event-deps.md +56 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/async-cheap-condition-before-await.md +37 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/async-defer-await.md +82 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/bundle-analyzable-paths.md +63 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +60 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-flatmap-filter.md +60 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-request-idle-callback.md +105 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-resource-hints.md +85 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md +68 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md +82 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-split-combined-hooks.md +64 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-use-deferred-value.md +59 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-hoist-static-io.md +149 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-no-shared-module-state.md +50 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-parallel-nested-fetching.md +34 -0
- package/template/.claude/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
- package/template/.claude/skills/web-interface-guidelines/LICENSE +21 -0
- package/template/.claude/skills/web-interface-guidelines/SKILL.md +40 -0
- package/template/.claude/skills/web-interface-guidelines/SOURCE.md +9 -0
- package/template/.claude/skills/web-interface-guidelines/guidelines.md +155 -0
- package/template/AGENTS.md +19 -12
- package/template/PAGES.md +25 -2
- package/template/README.md +1 -1
- package/template/_gitignore +1 -0
- package/template/app/account/page.tsx +31 -11
- package/template/app/cart/page.tsx +50 -54
- package/template/app/checkout/page.tsx +86 -38
- package/template/app/collections/[handle]/page.tsx +3 -2
- package/template/app/error.tsx +6 -4
- package/template/app/globals.css +29 -0
- package/template/app/layout.tsx +4 -2
- package/template/app/not-found.tsx +5 -2
- package/template/app/pages/[handle]/page.tsx +3 -2
- package/template/app/search/page.tsx +11 -6
- package/template/components/buy-box.tsx +7 -4
- package/template/components/cart-link.tsx +8 -2
- package/template/components/pager.tsx +3 -1
- package/template/components/product-grid.tsx +9 -5
- package/template/components/sections/banner.tsx +7 -1
- package/template/components/sections/deal-of-day.tsx +16 -5
- package/template/components/sections/product-shelves.tsx +8 -7
- package/template/components/sections/store-reviews.tsx +7 -5
- package/template/lib/errors.ts +1 -4
- package/template/lib/i18n.ts +315 -0
- package/template/llms.txt +8 -6
package/template/app/error.tsx
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { MagicStoreError } from '@magicstoreai/hydrogen';
|
|
3
|
+
import { MagicStoreError, useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { messages } from '@/lib/i18n';
|
|
4
5
|
|
|
5
6
|
/** Any failure a page did not handle itself: the API's detail when there is one, and a retry. */
|
|
6
7
|
export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) {
|
|
8
|
+
const t = messages(useMagicStore().locale);
|
|
7
9
|
const unavailable = error instanceof MagicStoreError && error.code === 'STORE_UNAVAILABLE';
|
|
8
10
|
return (
|
|
9
11
|
<div className="stack">
|
|
10
|
-
<h1>{unavailable ?
|
|
12
|
+
<h1>{unavailable ? t.shopClosed : t.somethingWentWrong}</h1>
|
|
11
13
|
{error instanceof MagicStoreError && error.detail && <p>{error.detail}</p>}
|
|
12
14
|
<div className="row">
|
|
13
|
-
<button onClick={reset}>
|
|
14
|
-
<a href="/">
|
|
15
|
+
<button onClick={reset}>{t.tryAgain}</button>
|
|
16
|
+
<a href="/">{t.backToShop}</a>
|
|
15
17
|
</div>
|
|
16
18
|
</div>
|
|
17
19
|
);
|
package/template/app/globals.css
CHANGED
|
@@ -150,6 +150,35 @@ th {
|
|
|
150
150
|
text-align: left;
|
|
151
151
|
vertical-align: middle;
|
|
152
152
|
}
|
|
153
|
+
.cart-lines {
|
|
154
|
+
list-style: none;
|
|
155
|
+
margin: 0;
|
|
156
|
+
padding: 0;
|
|
157
|
+
}
|
|
158
|
+
/* Phone: image and title on one row, the controls under the title. Wider: all on one row. */
|
|
159
|
+
.cart-line {
|
|
160
|
+
display: grid;
|
|
161
|
+
grid-template-columns: 64px minmax(0, 1fr);
|
|
162
|
+
gap: 8px 12px;
|
|
163
|
+
padding: 12px 0;
|
|
164
|
+
border-bottom: 1px solid var(--line);
|
|
165
|
+
}
|
|
166
|
+
.cart-line-controls {
|
|
167
|
+
grid-column: 2;
|
|
168
|
+
display: flex;
|
|
169
|
+
gap: 12px;
|
|
170
|
+
align-items: center;
|
|
171
|
+
flex-wrap: wrap;
|
|
172
|
+
}
|
|
173
|
+
@media (min-width: 720px) {
|
|
174
|
+
.cart-line {
|
|
175
|
+
grid-template-columns: 64px minmax(0, 1fr) auto;
|
|
176
|
+
align-items: center;
|
|
177
|
+
}
|
|
178
|
+
.cart-line-controls {
|
|
179
|
+
grid-column: auto;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
153
182
|
.pager {
|
|
154
183
|
display: flex;
|
|
155
184
|
gap: 8px;
|
package/template/app/layout.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Image } from '@magicstoreai/hydrogen';
|
|
|
5
5
|
import { CartLink } from '@/components/cart-link';
|
|
6
6
|
import { api, locale } from '@/lib/api';
|
|
7
7
|
import { brandStyle } from '@/lib/brand';
|
|
8
|
+
import { messages } from '@/lib/i18n';
|
|
8
9
|
import { Providers } from './providers';
|
|
9
10
|
import './globals.css';
|
|
10
11
|
|
|
@@ -27,6 +28,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|
|
27
28
|
api.collectionsIndex({ query: { perPage: 6 } }),
|
|
28
29
|
api.pagesIndex(),
|
|
29
30
|
]);
|
|
31
|
+
const t = messages(locale);
|
|
30
32
|
|
|
31
33
|
return (
|
|
32
34
|
// The merchant's colors, read per request: a change in the admin shows after SHOP_UPDATED.
|
|
@@ -47,8 +49,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|
|
47
49
|
{collection.title}
|
|
48
50
|
</Link>
|
|
49
51
|
))}
|
|
50
|
-
<Link href="/search">
|
|
51
|
-
<Link href="/account">
|
|
52
|
+
<Link href="/search">{t.search}</Link>
|
|
53
|
+
<Link href="/account">{t.account}</Link>
|
|
52
54
|
<CartLink />
|
|
53
55
|
</nav>
|
|
54
56
|
</header>
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
|
+
import { locale } from '@/lib/api';
|
|
3
|
+
import { messages } from '@/lib/i18n';
|
|
2
4
|
|
|
3
5
|
export default function NotFound() {
|
|
6
|
+
const t = messages(locale);
|
|
4
7
|
return (
|
|
5
8
|
<div className="stack">
|
|
6
|
-
<h1>
|
|
7
|
-
<Link href="/">
|
|
9
|
+
<h1>{t.notFound}</h1>
|
|
10
|
+
<Link href="/">{t.backToShop}</Link>
|
|
8
11
|
</div>
|
|
9
12
|
);
|
|
10
13
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { pageMeta } from '@magicstoreai/hydrogen/seo';
|
|
3
|
-
import { api, orNotFound } from '@/lib/api';
|
|
3
|
+
import { api, locale, orNotFound } from '@/lib/api';
|
|
4
|
+
import { messages } from '@/lib/i18n';
|
|
4
5
|
|
|
5
6
|
type Props = { params: Promise<{ handle: string }> };
|
|
6
7
|
|
|
@@ -21,7 +22,7 @@ export default async function ContentPage({ params }: Props) {
|
|
|
21
22
|
{page.body ? (
|
|
22
23
|
<div dangerouslySetInnerHTML={{ __html: page.body }} />
|
|
23
24
|
) : (
|
|
24
|
-
<p className="muted">
|
|
25
|
+
<p className="muted">{messages(locale).nothingHere}</p>
|
|
25
26
|
)}
|
|
26
27
|
</article>
|
|
27
28
|
);
|
|
@@ -2,29 +2,34 @@ import type { Metadata } from 'next';
|
|
|
2
2
|
import { SearchView } from '@/components/analytics-views';
|
|
3
3
|
import { Pager } from '@/components/pager';
|
|
4
4
|
import { ProductGrid } from '@/components/product-grid';
|
|
5
|
-
import { api } from '@/lib/api';
|
|
5
|
+
import { api, locale } from '@/lib/api';
|
|
6
|
+
import { messages } from '@/lib/i18n';
|
|
6
7
|
|
|
7
8
|
type Props = { searchParams: Promise<{ q?: string; page?: string }> };
|
|
8
9
|
|
|
9
10
|
// Result pages are endless and change by the minute: keep them out of the index.
|
|
10
|
-
export const metadata: Metadata = {
|
|
11
|
+
export const metadata: Metadata = {
|
|
12
|
+
title: messages(locale).search,
|
|
13
|
+
robots: { index: false, follow: true },
|
|
14
|
+
};
|
|
11
15
|
|
|
12
16
|
export default async function SearchPage({ searchParams }: Props) {
|
|
13
17
|
const { q = '', page: pageParam } = await searchParams;
|
|
14
18
|
const page = Math.max(1, Number(pageParam) || 1);
|
|
19
|
+
const t = messages(locale);
|
|
15
20
|
const results = q.trim() ? await api.search({ query: { q, page, perPage: 24 } }) : null;
|
|
16
21
|
|
|
17
22
|
return (
|
|
18
23
|
<>
|
|
19
|
-
<h1>
|
|
24
|
+
<h1>{t.search}</h1>
|
|
20
25
|
<form className="row" action="/search">
|
|
21
|
-
<input name="q" defaultValue={q} placeholder=
|
|
22
|
-
<button className="primary">
|
|
26
|
+
<input name="q" defaultValue={q} placeholder={t.searchPlaceholder} />
|
|
27
|
+
<button className="primary">{t.search}</button>
|
|
23
28
|
</form>
|
|
24
29
|
{results && (
|
|
25
30
|
<>
|
|
26
31
|
<SearchView query={q} resultsCount={results.meta.pagination.total} />
|
|
27
|
-
<p className="muted">{results.meta.pagination.total}
|
|
32
|
+
<p className="muted">{t.found(results.meta.pagination.total)}</p>
|
|
28
33
|
<ProductGrid products={results.data} />
|
|
29
34
|
<Pager
|
|
30
35
|
meta={results.meta.pagination}
|
|
@@ -5,17 +5,20 @@ import {
|
|
|
5
5
|
Money,
|
|
6
6
|
useAnalytics,
|
|
7
7
|
useCart,
|
|
8
|
+
useMagicStore,
|
|
8
9
|
useVariantSelection,
|
|
9
10
|
useWishlist,
|
|
10
11
|
} from '@magicstoreai/hydrogen';
|
|
11
12
|
import type { Schema } from '@magicstoreai/storefront-client';
|
|
12
13
|
import { useEffect, useState } from 'react';
|
|
14
|
+
import { messages } from '@/lib/i18n';
|
|
13
15
|
|
|
14
16
|
export function BuyBox({ product }: { product: Schema<'ProductDetail'> }) {
|
|
15
17
|
const { selectedOptions, selectedVariant, setOption, isAvailable } = useVariantSelection(product);
|
|
16
18
|
const { addLine, status } = useCart();
|
|
17
19
|
const wishlist = useWishlist();
|
|
18
20
|
const analytics = useAnalytics();
|
|
21
|
+
const t = messages(useMagicStore().locale);
|
|
19
22
|
const [error, setError] = useState<string | null>(null);
|
|
20
23
|
const [added, setAdded] = useState(false);
|
|
21
24
|
|
|
@@ -37,7 +40,7 @@ export function BuyBox({ product }: { product: Schema<'ProductDetail'> }) {
|
|
|
37
40
|
await addLine({ productId: product.id, variantId, quantity: 1 });
|
|
38
41
|
setAdded(true);
|
|
39
42
|
} catch (e) {
|
|
40
|
-
setError(e instanceof MagicStoreError ? (e.detail ?? e.message) :
|
|
43
|
+
setError(e instanceof MagicStoreError ? (e.detail ?? e.message) : t.couldNotAdd);
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -73,13 +76,13 @@ export function BuyBox({ product }: { product: Schema<'ProductDetail'> }) {
|
|
|
73
76
|
))}
|
|
74
77
|
<div className="row">
|
|
75
78
|
<button className="primary" disabled={!forSale || status === 'updating'} onClick={add}>
|
|
76
|
-
{forSale ?
|
|
79
|
+
{forSale ? t.addToCart : t.soldOut}
|
|
77
80
|
</button>
|
|
78
81
|
<button type="button" onClick={() => void wishlist.toggle(product.id)}>
|
|
79
|
-
{wishlist.has(product.id) ?
|
|
82
|
+
{wishlist.has(product.id) ? `♥ ${t.saved}` : `♡ ${t.save}`}
|
|
80
83
|
</button>
|
|
81
84
|
</div>
|
|
82
|
-
{added && <p className="muted">
|
|
85
|
+
{added && <p className="muted">{t.addedToCart}</p>}
|
|
83
86
|
{error && <p className="error">{error}</p>}
|
|
84
87
|
</div>
|
|
85
88
|
);
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCart } from '@magicstoreai/hydrogen';
|
|
3
|
+
import { useCart, useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
+
import { messages } from '@/lib/i18n';
|
|
5
6
|
|
|
6
7
|
export function CartLink() {
|
|
7
8
|
const { totalQuantity } = useCart();
|
|
8
|
-
|
|
9
|
+
const t = messages(useMagicStore().locale);
|
|
10
|
+
return (
|
|
11
|
+
<Link href="/cart">
|
|
12
|
+
{t.cart} ({totalQuantity})
|
|
13
|
+
</Link>
|
|
14
|
+
);
|
|
9
15
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { paginationState, type Pagination } from '@magicstoreai/hydrogen';
|
|
2
2
|
import Link from 'next/link';
|
|
3
|
+
import { locale } from '@/lib/api';
|
|
4
|
+
import { messages } from '@/lib/i18n';
|
|
3
5
|
|
|
4
6
|
/** Server-rendered page links (`paginationState` is plain arithmetic, no hooks). */
|
|
5
7
|
export function Pager({ meta, href }: { meta: Pagination; href: (page: number) => string }) {
|
|
@@ -8,7 +10,7 @@ export function Pager({ meta, href }: { meta: Pagination; href: (page: number) =
|
|
|
8
10
|
}
|
|
9
11
|
const state = paginationState(meta);
|
|
10
12
|
return (
|
|
11
|
-
<nav className="pager" aria-label=
|
|
13
|
+
<nav className="pager" aria-label={messages(locale).pages}>
|
|
12
14
|
{state.pages.map((page, index) =>
|
|
13
15
|
page === null ? (
|
|
14
16
|
<span key={`gap-${index}`}>…</span>
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { Image, Money } from '@magicstoreai/hydrogen';
|
|
2
2
|
import type { Schema } from '@magicstoreai/storefront-client';
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
+
import { locale } from '@/lib/api';
|
|
5
|
+
import { messages } from '@/lib/i18n';
|
|
4
6
|
|
|
5
7
|
/** Badges come only from the product's data; at most two, so the card stays readable. */
|
|
6
8
|
function ProductBadges({ product }: { product: Schema<'Product'> }) {
|
|
9
|
+
const t = messages(locale);
|
|
7
10
|
const badges = [
|
|
8
|
-
product.preOrder &&
|
|
9
|
-
product.compareAtPrice &&
|
|
10
|
-
product.isNew &&
|
|
11
|
+
product.preOrder && t.preOrder,
|
|
12
|
+
product.compareAtPrice && t.sale,
|
|
13
|
+
product.isNew && t.isNew,
|
|
11
14
|
]
|
|
12
15
|
.filter((badge): badge is string => typeof badge === 'string')
|
|
13
16
|
.slice(0, 2);
|
|
@@ -26,8 +29,9 @@ function ProductBadges({ product }: { product: Schema<'Product'> }) {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
|
|
32
|
+
const t = messages(locale);
|
|
29
33
|
if (products.length === 0) {
|
|
30
|
-
return <p className="muted">
|
|
34
|
+
return <p className="muted">{t.nothingHere}</p>;
|
|
31
35
|
}
|
|
32
36
|
return (
|
|
33
37
|
<div className="grid">
|
|
@@ -48,7 +52,7 @@ export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
|
|
|
48
52
|
</s>
|
|
49
53
|
)}
|
|
50
54
|
</span>
|
|
51
|
-
{!product.availableForSale && <span className="muted">
|
|
55
|
+
{!product.availableForSale && <span className="muted">{t.soldOut}</span>}
|
|
52
56
|
</Link>
|
|
53
57
|
))}
|
|
54
58
|
</div>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Image } from '@magicstoreai/hydrogen';
|
|
2
2
|
import Link from 'next/link';
|
|
3
|
+
import { locale } from '@/lib/api';
|
|
4
|
+
import { messages } from '@/lib/i18n';
|
|
3
5
|
|
|
4
6
|
import type { SectionOf } from './types';
|
|
5
7
|
|
|
@@ -25,7 +27,11 @@ export function Banner({ section }: { section: SectionOf<'BANNER'> }) {
|
|
|
25
27
|
<section className="banner">
|
|
26
28
|
{section.settings.slides.map((slide, index) => {
|
|
27
29
|
const image = (
|
|
28
|
-
<Image
|
|
30
|
+
<Image
|
|
31
|
+
data={slide.image}
|
|
32
|
+
loading={index === 0 ? 'eager' : 'lazy'}
|
|
33
|
+
alt={messages(locale).banner}
|
|
34
|
+
/>
|
|
29
35
|
);
|
|
30
36
|
const href = slideHref(slide.link);
|
|
31
37
|
return href === null ? (
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { Image, Money } from '@magicstoreai/hydrogen';
|
|
2
2
|
import Link from 'next/link';
|
|
3
|
+
import { api, locale } from '@/lib/api';
|
|
4
|
+
import { messages } from '@/lib/i18n';
|
|
3
5
|
|
|
4
6
|
import type { SectionOf } from './types';
|
|
5
7
|
|
|
6
|
-
export function DealOfDay({ section }: { section: SectionOf<'DEAL_OF_DAY'> }) {
|
|
8
|
+
export async function DealOfDay({ section }: { section: SectionOf<'DEAL_OF_DAY'> }) {
|
|
9
|
+
const t = messages(locale);
|
|
7
10
|
const { product, image, price, compareAtPrice, endsAt, accentColor, badgeText, stock } =
|
|
8
11
|
section.settings;
|
|
9
12
|
return (
|
|
@@ -31,18 +34,26 @@ export function DealOfDay({ section }: { section: SectionOf<'DEAL_OF_DAY'> }) {
|
|
|
31
34
|
{/* The merchant's "N of M left" bar — not the product's real stock. */}
|
|
32
35
|
{stock && (
|
|
33
36
|
<label className="stack">
|
|
34
|
-
<span className="muted">
|
|
35
|
-
{stock.remaining} of {stock.initial} left
|
|
36
|
-
</span>
|
|
37
|
+
<span className="muted">{t.stockLeft(stock.remaining, stock.initial)}</span>
|
|
37
38
|
<progress value={stock.remaining} max={Math.max(stock.initial, stock.remaining, 1)} />
|
|
38
39
|
</label>
|
|
39
40
|
)}
|
|
40
41
|
{endsAt && (
|
|
41
42
|
<span className="muted">
|
|
42
|
-
|
|
43
|
+
{t.endsAt} <time dateTime={endsAt}>{await endsAtText(endsAt)}</time>
|
|
43
44
|
</span>
|
|
44
45
|
)}
|
|
45
46
|
</div>
|
|
46
47
|
</section>
|
|
47
48
|
);
|
|
48
49
|
}
|
|
50
|
+
|
|
51
|
+
/** The end of the deal in the shop's time zone, as the storefront's language writes dates. */
|
|
52
|
+
async function endsAtText(endsAt: string): Promise<string> {
|
|
53
|
+
const { data: shop } = await api.shop();
|
|
54
|
+
return new Intl.DateTimeFormat(locale, {
|
|
55
|
+
dateStyle: 'medium',
|
|
56
|
+
timeStyle: 'short',
|
|
57
|
+
timeZone: shop.timezone,
|
|
58
|
+
}).format(new Date(endsAt));
|
|
59
|
+
}
|
|
@@ -3,7 +3,8 @@ import Link from 'next/link';
|
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
import { ProductGrid } from '@/components/product-grid';
|
|
6
|
-
import { api } from '@/lib/api';
|
|
6
|
+
import { api, locale } from '@/lib/api';
|
|
7
|
+
import { messages } from '@/lib/i18n';
|
|
7
8
|
|
|
8
9
|
import { SHELF_SIZE, type SectionOf } from './types';
|
|
9
10
|
|
|
@@ -27,7 +28,7 @@ function Shelf({
|
|
|
27
28
|
<div className="row">
|
|
28
29
|
<h2>{title}</h2>
|
|
29
30
|
{children}
|
|
30
|
-
{href && <Link href={href}>
|
|
31
|
+
{href && <Link href={href}>{messages(locale).seeAll}</Link>}
|
|
31
32
|
</div>
|
|
32
33
|
<ProductGrid products={products} />
|
|
33
34
|
</section>
|
|
@@ -42,35 +43,35 @@ export async function NewArrivals({ section }: { section: ShelfSection }) {
|
|
|
42
43
|
const { data } = await api.productsIndex({
|
|
43
44
|
query: { sort: '-createdAt', perPage: section.settings.limit ?? SHELF_SIZE },
|
|
44
45
|
});
|
|
45
|
-
return <Shelf title=
|
|
46
|
+
return <Shelf title={messages(locale).newArrivals} products={data} />;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export async function OnSale({ section }: { section: ShelfSection }) {
|
|
49
50
|
const { data } = await api.productsIndex({
|
|
50
51
|
query: { 'filter[onSale]': 'true', perPage: section.settings.limit ?? SHELF_SIZE },
|
|
51
52
|
});
|
|
52
|
-
return <Shelf title=
|
|
53
|
+
return <Shelf title={messages(locale).onSale} products={data} />;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export async function Trending({ section }: { section: ShelfSection }) {
|
|
56
57
|
const { data } = await api.productsIndex({
|
|
57
58
|
query: { 'filter[trending]': 'true', perPage: section.settings.limit ?? SHELF_SIZE },
|
|
58
59
|
});
|
|
59
|
-
return <Shelf title=
|
|
60
|
+
return <Shelf title={messages(locale).trending} products={data} />;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export async function Bestsellers({ section }: { section: ShelfSection }) {
|
|
63
64
|
const { data } = await api.productsIndex({
|
|
64
65
|
query: { sort: '-bestSellers', perPage: section.settings.limit ?? SHELF_SIZE },
|
|
65
66
|
});
|
|
66
|
-
return <Shelf title=
|
|
67
|
+
return <Shelf title={messages(locale).bestsellers} products={data} />;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export async function PreOrders({ section }: { section: ShelfSection }) {
|
|
70
71
|
const { data } = await api.productsIndex({
|
|
71
72
|
query: { 'filter[preOrder]': 'true', perPage: section.settings.limit ?? SHELF_SIZE },
|
|
72
73
|
});
|
|
73
|
-
return <Shelf title=
|
|
74
|
+
return <Shelf title={messages(locale).preOrder} products={data} />;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
2
|
|
|
3
|
-
import { api } from '@/lib/api';
|
|
3
|
+
import { api, locale } from '@/lib/api';
|
|
4
|
+
import { messages } from '@/lib/i18n';
|
|
4
5
|
|
|
5
6
|
import type { SectionOf } from './types';
|
|
6
7
|
|
|
@@ -8,6 +9,7 @@ const SOURCE_LABELS = { GOOGLE: 'Google', YANDEX: 'Yandex', TWO_GIS: '2GIS', CUS
|
|
|
8
9
|
|
|
9
10
|
/** Reviews of the shop itself (`GET /reviews/site`), dressed with what the merchant entered. */
|
|
10
11
|
export async function StoreReviews({ section }: { section: SectionOf<'STORE_REVIEWS'> }) {
|
|
12
|
+
const t = messages(locale);
|
|
11
13
|
const settings = section.settings;
|
|
12
14
|
const { data: reviews, meta } = await api.reviewsSite({
|
|
13
15
|
query: { perPage: settings.limit, sort: '-createdAt' },
|
|
@@ -17,17 +19,17 @@ export async function StoreReviews({ section }: { section: SectionOf<'STORE_REVI
|
|
|
17
19
|
return (
|
|
18
20
|
<section className="stack">
|
|
19
21
|
{settings.overline && <span className="muted">{settings.overline}</span>}
|
|
20
|
-
<h2>{settings.title ??
|
|
22
|
+
<h2>{settings.title ?? t.customersSay}</h2>
|
|
21
23
|
{settings.subtitle && <p className="muted">{settings.subtitle}</p>}
|
|
22
24
|
|
|
23
25
|
<div className="row">
|
|
24
26
|
{rating.average !== null && (
|
|
25
27
|
<strong>
|
|
26
|
-
{rating.average} / 5 · {rating.count}
|
|
28
|
+
{rating.average} / 5 · {t.reviewsCount(rating.count)}
|
|
27
29
|
</strong>
|
|
28
30
|
)}
|
|
29
31
|
{settings.recommendPercent !== null && (
|
|
30
|
-
<span>{settings.recommendPercent}
|
|
32
|
+
<span>{t.recommendUs(settings.recommendPercent)}</span>
|
|
31
33
|
)}
|
|
32
34
|
{settings.externalRatings.map((external, index) => (
|
|
33
35
|
<span key={index} className="muted">
|
|
@@ -59,7 +61,7 @@ export async function StoreReviews({ section }: { section: SectionOf<'STORE_REVI
|
|
|
59
61
|
{settings.showLeaveReview && (
|
|
60
62
|
// Leaving a site review needs a signed-in customer (POST /reviews/site).
|
|
61
63
|
<Link href="/account" className="button">
|
|
62
|
-
|
|
64
|
+
{t.leaveReview}
|
|
63
65
|
</Link>
|
|
64
66
|
)}
|
|
65
67
|
</section>
|
package/template/lib/errors.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { MagicStoreError } from '@magicstoreai/hydrogen';
|
|
2
2
|
|
|
3
3
|
/** What to tell the buyer: the API's localized detail when there is one. */
|
|
4
|
-
export function describe(
|
|
5
|
-
error: unknown,
|
|
6
|
-
fallback = 'Something went wrong. Please try again.',
|
|
7
|
-
): string {
|
|
4
|
+
export function describe(error: unknown, fallback: string): string {
|
|
8
5
|
return error instanceof MagicStoreError ? (error.detail ?? error.message) : fallback;
|
|
9
6
|
}
|