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.
Files changed (111) hide show
  1. package/package.json +1 -1
  2. package/template/.claude/skills/design-taste-frontend/LICENSE +21 -0
  3. package/template/.claude/skills/design-taste-frontend/SKILL.md +1206 -0
  4. package/template/.claude/skills/design-taste-frontend/SOURCE.md +9 -0
  5. package/template/.claude/skills/storefront-design/SKILL.md +170 -0
  6. package/template/.claude/skills/storefront-verify/SKILL.md +85 -0
  7. package/template/.claude/skills/vercel-react-best-practices/AGENTS.md +3810 -0
  8. package/template/.claude/skills/vercel-react-best-practices/README.md +123 -0
  9. package/template/.claude/skills/vercel-react-best-practices/SKILL.md +149 -0
  10. package/template/.claude/skills/vercel-react-best-practices/SOURCE.md +7 -0
  11. package/template/.claude/skills/vercel-react-best-practices/metadata.json +15 -0
  12. package/template/.claude/skills/vercel-react-best-practices/rules/_sections.md +46 -0
  13. package/template/.claude/skills/vercel-react-best-practices/rules/_template.md +28 -0
  14. package/template/.claude/skills/vercel-react-best-practices/rules/advanced-effect-event-deps.md +56 -0
  15. package/template/.claude/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
  16. package/template/.claude/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
  17. package/template/.claude/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
  18. package/template/.claude/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
  19. package/template/.claude/skills/vercel-react-best-practices/rules/async-cheap-condition-before-await.md +37 -0
  20. package/template/.claude/skills/vercel-react-best-practices/rules/async-defer-await.md +82 -0
  21. package/template/.claude/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
  22. package/template/.claude/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
  23. package/template/.claude/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
  24. package/template/.claude/skills/vercel-react-best-practices/rules/bundle-analyzable-paths.md +63 -0
  25. package/template/.claude/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +60 -0
  26. package/template/.claude/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
  27. package/template/.claude/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
  28. package/template/.claude/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
  29. package/template/.claude/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
  30. package/template/.claude/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
  31. package/template/.claude/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
  32. package/template/.claude/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
  33. package/template/.claude/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
  34. package/template/.claude/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
  35. package/template/.claude/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
  36. package/template/.claude/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
  37. package/template/.claude/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
  38. package/template/.claude/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
  39. package/template/.claude/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
  40. package/template/.claude/skills/vercel-react-best-practices/rules/js-flatmap-filter.md +60 -0
  41. package/template/.claude/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
  42. package/template/.claude/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
  43. package/template/.claude/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
  44. package/template/.claude/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
  45. package/template/.claude/skills/vercel-react-best-practices/rules/js-request-idle-callback.md +105 -0
  46. package/template/.claude/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
  47. package/template/.claude/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
  48. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
  49. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
  50. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
  51. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
  52. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
  53. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
  54. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
  55. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-resource-hints.md +85 -0
  56. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md +68 -0
  57. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
  58. package/template/.claude/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
  59. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
  60. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
  61. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
  62. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
  63. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
  64. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
  65. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
  66. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
  67. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
  68. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md +82 -0
  69. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
  70. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-split-combined-hooks.md +64 -0
  71. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
  72. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-use-deferred-value.md +59 -0
  73. package/template/.claude/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
  74. package/template/.claude/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
  75. package/template/.claude/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
  76. package/template/.claude/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
  77. package/template/.claude/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
  78. package/template/.claude/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
  79. package/template/.claude/skills/vercel-react-best-practices/rules/server-hoist-static-io.md +149 -0
  80. package/template/.claude/skills/vercel-react-best-practices/rules/server-no-shared-module-state.md +50 -0
  81. package/template/.claude/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
  82. package/template/.claude/skills/vercel-react-best-practices/rules/server-parallel-nested-fetching.md +34 -0
  83. package/template/.claude/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
  84. package/template/.claude/skills/web-interface-guidelines/LICENSE +21 -0
  85. package/template/.claude/skills/web-interface-guidelines/SKILL.md +40 -0
  86. package/template/.claude/skills/web-interface-guidelines/SOURCE.md +9 -0
  87. package/template/.claude/skills/web-interface-guidelines/guidelines.md +155 -0
  88. package/template/AGENTS.md +16 -0
  89. package/template/PAGES.md +11 -3
  90. package/template/_gitignore +1 -0
  91. package/template/app/account/page.tsx +31 -11
  92. package/template/app/cart/page.tsx +50 -54
  93. package/template/app/checkout/page.tsx +86 -38
  94. package/template/app/collections/[handle]/page.tsx +3 -2
  95. package/template/app/error.tsx +6 -4
  96. package/template/app/globals.css +46 -4
  97. package/template/app/layout.tsx +14 -4
  98. package/template/app/not-found.tsx +5 -2
  99. package/template/app/pages/[handle]/page.tsx +3 -2
  100. package/template/app/search/page.tsx +11 -6
  101. package/template/components/buy-box.tsx +7 -4
  102. package/template/components/cart-link.tsx +8 -2
  103. package/template/components/pager.tsx +3 -1
  104. package/template/components/product-grid.tsx +30 -2
  105. package/template/components/sections/banner.tsx +7 -1
  106. package/template/components/sections/deal-of-day.tsx +16 -5
  107. package/template/components/sections/product-shelves.tsx +8 -7
  108. package/template/components/sections/store-reviews.tsx +7 -5
  109. package/template/lib/brand.ts +60 -0
  110. package/template/lib/errors.ts +1 -4
  111. package/template/lib/i18n.ts +315 -0
@@ -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: #fff;
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(--accent);
225
- color: #fff;
266
+ background: var(--label-bg);
267
+ color: var(--label-fg);
226
268
  }
227
269
  .stories {
228
270
  display: flex;
@@ -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
- <html lang={locale}>
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.name}
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">Search</Link>
43
- <Link href="/account">Account</Link>
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>Not found</h1>
7
- <Link href="/">Back to the shop</Link>
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">Nothing here yet.</p>
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 = { title: 'Search', robots: { index: false, follow: true } };
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>Search</h1>
24
+ <h1>{t.search}</h1>
20
25
  <form className="row" action="/search">
21
- <input name="q" defaultValue={q} placeholder="What are you looking for?" />
22
- <button className="primary">Search</button>
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} found</p>
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) : 'Could not add.');
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 ? 'Add to cart' : 'Sold out'}
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) ? '♥ Saved' : '♡ Save'}
82
+ {wishlist.has(product.id) ? `♥ ${t.saved}` : `♡ ${t.save}`}
80
83
  </button>
81
84
  </div>
82
- {added && <p className="muted">Added to cart.</p>}
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
- return <Link href="/cart">Cart ({totalQuantity})</Link>;
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="Pages">
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">Nothing here yet.</p>;
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">Sold out</span>}
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 data={slide.image} loading={index === 0 ? 'eager' : 'lazy'} alt="Banner" />
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
- Ends <time dateTime={endsAt}>{new Date(endsAt).toUTCString()}</time>
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}>See all</Link>}
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="New arrivals" products={data} />;
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="On sale" products={data} />;
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="Trending" products={data} />;
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="Bestsellers" products={data} />;
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="Pre-order" products={data} />;
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 ?? 'What our customers say'}</h2>
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} reviews
28
+ {rating.average} / 5 · {t.reviewsCount(rating.count)}
27
29
  </strong>
28
30
  )}
29
31
  {settings.recommendPercent !== null && (
30
- <span>{settings.recommendPercent}% recommend us</span>
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
- Leave a review
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
+ }
@@ -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
  }