create-magic-storefront 0.1.0 → 0.1.2
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/design-taste-frontend/LICENSE +21 -0
- package/template/.claude/skills/design-taste-frontend/SKILL.md +1206 -0
- package/template/.claude/skills/design-taste-frontend/SOURCE.md +9 -0
- package/template/.claude/skills/storefront-design/SKILL.md +170 -0
- 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 +16 -0
- package/template/PAGES.md +11 -3
- 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 +46 -4
- package/template/app/layout.tsx +14 -4
- 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 +30 -2
- 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/brand.ts +60 -0
- package/template/lib/errors.ts +1 -4
- package/template/lib/i18n.ts +315 -0
package/template/app/globals.css
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
--fg: #16161a;
|
|
4
4
|
--muted: #6b6b76;
|
|
5
5
|
--line: #e7e7ec;
|
|
6
|
-
--accent: #1f5eff;
|
|
7
6
|
--danger: #c62828;
|
|
7
|
+
/* Brand tokens: app/layout.tsx overrides them with the merchant's colors (lib/brand.ts). */
|
|
8
|
+
--accent: #1f5eff;
|
|
9
|
+
--on-accent: #fff;
|
|
10
|
+
--nav-bg: var(--bg);
|
|
11
|
+
--nav-fg: var(--fg);
|
|
12
|
+
--label-bg: var(--accent);
|
|
13
|
+
--label-fg: var(--on-accent);
|
|
8
14
|
font-family:
|
|
9
15
|
system-ui,
|
|
10
16
|
-apple-system,
|
|
@@ -30,6 +36,8 @@ main {
|
|
|
30
36
|
}
|
|
31
37
|
header.site {
|
|
32
38
|
border-bottom: 1px solid var(--line);
|
|
39
|
+
background: var(--nav-bg);
|
|
40
|
+
color: var(--nav-fg);
|
|
33
41
|
}
|
|
34
42
|
header.site nav {
|
|
35
43
|
max-width: 1100px;
|
|
@@ -45,6 +53,11 @@ header.site .brand {
|
|
|
45
53
|
text-decoration: none;
|
|
46
54
|
margin-right: auto;
|
|
47
55
|
}
|
|
56
|
+
header.site .brand img {
|
|
57
|
+
display: block;
|
|
58
|
+
width: auto;
|
|
59
|
+
height: 32px;
|
|
60
|
+
}
|
|
48
61
|
.grid {
|
|
49
62
|
display: grid;
|
|
50
63
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
@@ -110,7 +123,7 @@ button.primary,
|
|
|
110
123
|
.button.primary {
|
|
111
124
|
background: var(--accent);
|
|
112
125
|
border-color: var(--accent);
|
|
113
|
-
color:
|
|
126
|
+
color: var(--on-accent);
|
|
114
127
|
}
|
|
115
128
|
button[aria-pressed='true'] {
|
|
116
129
|
border-color: var(--fg);
|
|
@@ -137,6 +150,35 @@ th {
|
|
|
137
150
|
text-align: left;
|
|
138
151
|
vertical-align: middle;
|
|
139
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
|
+
}
|
|
140
182
|
.pager {
|
|
141
183
|
display: flex;
|
|
142
184
|
gap: 8px;
|
|
@@ -221,8 +263,8 @@ fieldset {
|
|
|
221
263
|
align-self: flex-start;
|
|
222
264
|
padding: 2px 10px;
|
|
223
265
|
border-radius: 999px;
|
|
224
|
-
background: var(--
|
|
225
|
-
color:
|
|
266
|
+
background: var(--label-bg);
|
|
267
|
+
color: var(--label-fg);
|
|
226
268
|
}
|
|
227
269
|
.stories {
|
|
228
270
|
display: flex;
|
package/template/app/layout.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import Link from 'next/link';
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
+
import { Image } from '@magicstoreai/hydrogen';
|
|
4
5
|
import { CartLink } from '@/components/cart-link';
|
|
5
6
|
import { api, locale } from '@/lib/api';
|
|
7
|
+
import { brandStyle } from '@/lib/brand';
|
|
8
|
+
import { messages } from '@/lib/i18n';
|
|
6
9
|
import { Providers } from './providers';
|
|
7
10
|
import './globals.css';
|
|
8
11
|
|
|
@@ -15,6 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
15
18
|
return {
|
|
16
19
|
title: shop.seo.title || shop.name,
|
|
17
20
|
description: shop.seo.description ?? shop.description,
|
|
21
|
+
icons: shop.branding.favicon ? { icon: shop.branding.favicon.url } : undefined,
|
|
18
22
|
};
|
|
19
23
|
}
|
|
20
24
|
|
|
@@ -24,23 +28,29 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|
|
24
28
|
api.collectionsIndex({ query: { perPage: 6 } }),
|
|
25
29
|
api.pagesIndex(),
|
|
26
30
|
]);
|
|
31
|
+
const t = messages(locale);
|
|
27
32
|
|
|
28
33
|
return (
|
|
29
|
-
|
|
34
|
+
// The merchant's colors, read per request: a change in the admin shows after SHOP_UPDATED.
|
|
35
|
+
<html lang={locale} style={brandStyle(shop.branding)}>
|
|
30
36
|
<body>
|
|
31
37
|
<Providers shop={shop}>
|
|
32
38
|
<header className="site">
|
|
33
39
|
<nav>
|
|
34
40
|
<Link href="/" className="brand">
|
|
35
|
-
{shop.
|
|
41
|
+
{shop.branding.logo ? (
|
|
42
|
+
<Image data={shop.branding.logo} alt={shop.name} height={32} loading="eager" />
|
|
43
|
+
) : (
|
|
44
|
+
shop.name
|
|
45
|
+
)}
|
|
36
46
|
</Link>
|
|
37
47
|
{collections.map((collection) => (
|
|
38
48
|
<Link key={collection.id} href={`/collections/${collection.handle}`}>
|
|
39
49
|
{collection.title}
|
|
40
50
|
</Link>
|
|
41
51
|
))}
|
|
42
|
-
<Link href="/search">
|
|
43
|
-
<Link href="/account">
|
|
52
|
+
<Link href="/search">{t.search}</Link>
|
|
53
|
+
<Link href="/account">{t.account}</Link>
|
|
44
54
|
<CartLink />
|
|
45
55
|
</nav>
|
|
46
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,10 +1,37 @@
|
|
|
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';
|
|
6
|
+
|
|
7
|
+
/** Badges come only from the product's data; at most two, so the card stays readable. */
|
|
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
|
+
}
|
|
4
30
|
|
|
5
31
|
export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
|
|
32
|
+
const t = messages(locale);
|
|
6
33
|
if (products.length === 0) {
|
|
7
|
-
return <p className="muted">
|
|
34
|
+
return <p className="muted">{t.nothingHere}</p>;
|
|
8
35
|
}
|
|
9
36
|
return (
|
|
10
37
|
<div className="grid">
|
|
@@ -15,6 +42,7 @@ export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
|
|
|
15
42
|
alt={product.title}
|
|
16
43
|
fallback={<div className="noimg" />}
|
|
17
44
|
/>
|
|
45
|
+
<ProductBadges product={product} />
|
|
18
46
|
<span>{product.title}</span>
|
|
19
47
|
<span className="row">
|
|
20
48
|
<Money data={product.price} fallback={<span className="muted">—</span>} />
|
|
@@ -24,7 +52,7 @@ export function ProductGrid({ products }: { products: Schema<'Product'>[] }) {
|
|
|
24
52
|
</s>
|
|
25
53
|
)}
|
|
26
54
|
</span>
|
|
27
|
-
{!product.availableForSale && <span className="muted">
|
|
55
|
+
{!product.availableForSale && <span className="muted">{t.soldOut}</span>}
|
|
28
56
|
</Link>
|
|
29
57
|
))}
|
|
30
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>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
2
|
+
import type { CSSProperties } from 'react';
|
|
3
|
+
|
|
4
|
+
type Branding = Schema<'Shop'>['branding'];
|
|
5
|
+
|
|
6
|
+
/** `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`. The admin stores colors as free text; nothing else is trusted. */
|
|
7
|
+
const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
8
|
+
|
|
9
|
+
function hexColor(value: string | null): string | null {
|
|
10
|
+
return value !== null && HEX_COLOR.test(value) ? value : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** WCAG relative luminance of a hex color; alpha is ignored. */
|
|
14
|
+
function luminance(hex: string): number {
|
|
15
|
+
const digits = hex.slice(1);
|
|
16
|
+
const channels =
|
|
17
|
+
digits.length <= 4
|
|
18
|
+
? [0, 1, 2].map((i) => parseInt(digits[i]! + digits[i]!, 16))
|
|
19
|
+
: [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16));
|
|
20
|
+
const [r, g, b] = channels.map((channel) => {
|
|
21
|
+
const c = channel / 255;
|
|
22
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
23
|
+
});
|
|
24
|
+
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Black or white, whichever contrasts more with the background. */
|
|
28
|
+
export function readableTextOn(background: string): '#000' | '#fff' {
|
|
29
|
+
const l = luminance(background);
|
|
30
|
+
const onBlack = (l + 0.05) / 0.05;
|
|
31
|
+
const onWhite = 1.05 / (l + 0.05);
|
|
32
|
+
return onBlack >= onWhite ? '#000' : '#fff';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The merchant's colors as CSS custom properties for `<html style>`. A color the merchant didn't
|
|
37
|
+
* set is left out, so the design's fallback in `globals.css` applies; a text color the merchant
|
|
38
|
+
* didn't set is picked for contrast against its background.
|
|
39
|
+
*/
|
|
40
|
+
export function brandStyle(branding: Branding): CSSProperties {
|
|
41
|
+
const { colors } = branding;
|
|
42
|
+
const tokens: Record<string, string> = {};
|
|
43
|
+
const pair = (name: string, background: string | null, text: string | null) => {
|
|
44
|
+
const bg = hexColor(background);
|
|
45
|
+
if (bg === null) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
tokens[`--${name}-bg`] = bg;
|
|
49
|
+
tokens[`--${name}-fg`] = hexColor(text) ?? readableTextOn(bg);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const accent = hexColor(colors.main);
|
|
53
|
+
if (accent !== null) {
|
|
54
|
+
tokens['--accent'] = accent;
|
|
55
|
+
tokens['--on-accent'] = hexColor(colors.buttonText) ?? readableTextOn(accent);
|
|
56
|
+
}
|
|
57
|
+
pair('nav', colors.navbarBackground, colors.navbarText);
|
|
58
|
+
pair('label', colors.labelBackground, colors.labelText);
|
|
59
|
+
return tokens as CSSProperties;
|
|
60
|
+
}
|
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
|
}
|