create-magic-storefront 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/AGENTS.md +32 -16
  4. package/template/DESIGN.md +117 -0
  5. package/template/PAGES.md +107 -29
  6. package/template/README.md +15 -3
  7. package/template/app/account/addresses/page.tsx +16 -0
  8. package/template/app/account/layout.tsx +14 -0
  9. package/template/app/account/loading.tsx +15 -0
  10. package/template/app/account/orders/[id]/page.tsx +15 -0
  11. package/template/app/account/page.tsx +12 -244
  12. package/template/app/account/profile/page.tsx +16 -0
  13. package/template/app/blog/(index)/loading.tsx +22 -0
  14. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  15. package/template/app/blog/[handle]/page.tsx +92 -56
  16. package/template/app/blog/author/[handle]/page.tsx +1 -1
  17. package/template/app/cart/layout.tsx +14 -0
  18. package/template/app/cart/page.tsx +49 -101
  19. package/template/app/checkout/layout.tsx +14 -0
  20. package/template/app/checkout/page.tsx +98 -281
  21. package/template/app/collections/[handle]/layout.tsx +20 -0
  22. package/template/app/collections/[handle]/loading.tsx +23 -0
  23. package/template/app/collections/[handle]/page.tsx +131 -15
  24. package/template/app/collections/page.tsx +49 -0
  25. package/template/app/contacts/page.tsx +196 -0
  26. package/template/app/error.tsx +19 -8
  27. package/template/app/fonts/OFL.txt +93 -0
  28. package/template/app/fonts/index.ts +14 -0
  29. package/template/app/fonts/onest-variable.woff2 +0 -0
  30. package/template/app/globals.css +14 -518
  31. package/template/app/layout.tsx +73 -50
  32. package/template/app/not-found.tsx +34 -4
  33. package/template/app/page.tsx +1 -1
  34. package/template/app/pages/[handle]/page.tsx +1 -1
  35. package/template/app/products/[handle]/layout.tsx +20 -0
  36. package/template/app/products/[handle]/loading.tsx +25 -0
  37. package/template/app/products/[handle]/page.tsx +112 -15
  38. package/template/app/providers.tsx +5 -1
  39. package/template/app/search/loading.tsx +12 -0
  40. package/template/app/search/page.tsx +138 -25
  41. package/template/app/sitemap.ts +5 -1
  42. package/template/app/styles/base.css +138 -0
  43. package/template/app/styles/components.css +1108 -0
  44. package/template/app/styles/layout.css +455 -0
  45. package/template/app/styles/pages.css +1839 -0
  46. package/template/app/styles/reset.css +55 -0
  47. package/template/app/styles/sections.css +415 -0
  48. package/template/app/styles/tokens.css +167 -0
  49. package/template/app/styles/utilities.css +37 -0
  50. package/template/app/wishlist/page.tsx +26 -0
  51. package/template/components/account/account-shell.tsx +100 -0
  52. package/template/components/account/addresses.tsx +247 -0
  53. package/template/components/account/order-detail.tsx +281 -0
  54. package/template/components/account/order-list.tsx +94 -0
  55. package/template/components/account/order-status.tsx +19 -0
  56. package/template/components/account/profile.tsx +129 -0
  57. package/template/components/account/sign-in.tsx +164 -0
  58. package/template/components/article-body.tsx +3 -2
  59. package/template/components/article-card.tsx +37 -12
  60. package/template/components/cart/cart-drawer.tsx +149 -0
  61. package/template/components/cart/cart-lines.tsx +89 -0
  62. package/template/components/cart/cart-summary.tsx +191 -0
  63. package/template/components/catalog/catalog-form.tsx +107 -0
  64. package/template/components/catalog/catalog-layout.tsx +82 -0
  65. package/template/components/catalog/facets-shell.tsx +60 -0
  66. package/template/components/catalog/facets.tsx +216 -0
  67. package/template/components/checkout/order-summary.tsx +84 -0
  68. package/template/components/checkout/radio-card.tsx +34 -0
  69. package/template/components/checkout/step-indicator.tsx +56 -0
  70. package/template/components/checkout/steps.tsx +264 -0
  71. package/template/components/checkout/thank-you.tsx +48 -0
  72. package/template/components/checkout/use-checkout.ts +90 -0
  73. package/template/components/footer/site-footer.tsx +132 -0
  74. package/template/components/header/cart-button.tsx +43 -0
  75. package/template/components/header/desktop-nav.tsx +133 -0
  76. package/template/components/header/mobile-menu.tsx +102 -0
  77. package/template/components/header/site-header.tsx +78 -0
  78. package/template/components/page-loading.tsx +20 -0
  79. package/template/components/pager.tsx +3 -1
  80. package/template/components/product/buy-box.tsx +148 -0
  81. package/template/components/product/gallery.tsx +179 -0
  82. package/template/components/product/pre-order-form.tsx +93 -0
  83. package/template/components/product/product-selection.tsx +31 -0
  84. package/template/components/product/recommendations.tsx +26 -0
  85. package/template/components/product/review-list.tsx +159 -0
  86. package/template/components/product/reviews.tsx +80 -0
  87. package/template/components/product/variant-picker.tsx +75 -0
  88. package/template/components/product-card.tsx +103 -0
  89. package/template/components/product-grid.tsx +27 -57
  90. package/template/components/quick-add.tsx +62 -0
  91. package/template/components/search/predictive-search.tsx +282 -0
  92. package/template/components/sections/announcement-bar.tsx +1 -1
  93. package/template/components/sections/banner.tsx +1 -1
  94. package/template/components/sections/collections.tsx +50 -13
  95. package/template/components/sections/deal-of-day.tsx +2 -2
  96. package/template/components/sections/product-shelves.tsx +13 -5
  97. package/template/components/sections/shoppable-stories.tsx +2 -2
  98. package/template/components/sections/store-reviews.tsx +10 -4
  99. package/template/components/share-button.tsx +42 -0
  100. package/template/components/ui/accordion.tsx +35 -0
  101. package/template/components/ui/badge.tsx +20 -0
  102. package/template/components/ui/breadcrumbs.tsx +31 -0
  103. package/template/components/ui/button.tsx +90 -0
  104. package/template/components/ui/drawer.tsx +84 -0
  105. package/template/components/ui/empty-state.tsx +27 -0
  106. package/template/components/ui/field.tsx +33 -0
  107. package/template/components/ui/icon.tsx +112 -0
  108. package/template/components/ui/price.tsx +35 -0
  109. package/template/components/ui/quantity-stepper.tsx +91 -0
  110. package/template/components/ui/rating.tsx +48 -0
  111. package/template/components/ui/skeleton.tsx +46 -0
  112. package/template/components/wishlist/wishlist-view.tsx +134 -0
  113. package/template/lib/article-outline.ts +35 -0
  114. package/template/lib/catalog-params.ts +170 -0
  115. package/template/lib/contacts.ts +34 -0
  116. package/template/lib/errors.ts +29 -0
  117. package/template/lib/i18n.ts +484 -0
  118. package/template/lib/menu.ts +70 -0
  119. package/template/lib/theme-settings.ts +36 -0
  120. package/template/theme/index.ts +7 -1
  121. package/template/theme/sections/collapsible-content.tsx +75 -0
  122. package/template/theme/sections/collection-list.tsx +32 -4
  123. package/template/theme/sections/featured-collection.tsx +22 -2
  124. package/template/theme/sections/hero.tsx +94 -7
  125. package/template/theme/sections/image-with-text.tsx +19 -3
  126. package/template/theme/sections/multicolumn.tsx +113 -0
  127. package/template/theme/sections/page-content.tsx +10 -4
  128. package/template/theme/sections/product-shelf.tsx +26 -4
  129. package/template/theme/sections/rich-text.tsx +13 -1
  130. package/template/theme/sections/testimonials.tsx +14 -9
  131. package/template/theme/settings.ts +85 -1
  132. package/template/theme/templates/index.json +4 -2
  133. package/template/.screenshots/home-1280.png +0 -0
  134. package/template/.screenshots/home-390.png +0 -0
  135. package/template/.screenshots/home-filled-1280.png +0 -0
  136. package/template/.screenshots/home-filled-390.png +0 -0
  137. package/template/.screenshots/page-1280.png +0 -0
  138. package/template/components/buy-box.tsx +0 -89
  139. package/template/components/cart-link.tsx +0 -15
@@ -0,0 +1,90 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
+
3
+ import { Icon, type IconName } from './icon';
4
+
5
+ export type ButtonVariant = 'primary' | 'secondary' | 'link';
6
+ export type ButtonSize = 'sm' | 'md' | 'lg';
7
+
8
+ /**
9
+ * The classes of a button, for a `<Link>` or `<a>` that looks like one. One primary action per
10
+ * view; `secondary` for the rest; `link` for an inline text action.
11
+ */
12
+ export function buttonClass({
13
+ variant = 'secondary',
14
+ size = 'md',
15
+ full = false,
16
+ className,
17
+ }: {
18
+ variant?: ButtonVariant;
19
+ size?: ButtonSize;
20
+ full?: boolean;
21
+ className?: string;
22
+ } = {}): string {
23
+ return [
24
+ 'button',
25
+ `button--${variant}`,
26
+ size !== 'md' && `button--${size}`,
27
+ full && 'button--full',
28
+ className,
29
+ ]
30
+ .filter(Boolean)
31
+ .join(' ');
32
+ }
33
+
34
+ /**
35
+ * A button. `loading` keeps it the same size, marks it `aria-busy` and blocks a second press while
36
+ * the request runs; the label stays for screen readers.
37
+ */
38
+ export function Button({
39
+ variant = 'secondary',
40
+ size = 'md',
41
+ full = false,
42
+ loading = false,
43
+ className,
44
+ disabled,
45
+ type = 'button',
46
+ children,
47
+ ...rest
48
+ }: {
49
+ variant?: ButtonVariant;
50
+ size?: ButtonSize;
51
+ full?: boolean;
52
+ loading?: boolean;
53
+ } & ButtonHTMLAttributes<HTMLButtonElement>) {
54
+ return (
55
+ <button
56
+ type={type}
57
+ className={buttonClass({ variant, size, full, className })}
58
+ disabled={disabled || loading}
59
+ aria-busy={loading || undefined}
60
+ {...rest}
61
+ >
62
+ {children}
63
+ </button>
64
+ );
65
+ }
66
+
67
+ /** A square icon-only button; `label` is its accessible name. */
68
+ export function IconButton({
69
+ icon,
70
+ label,
71
+ className,
72
+ type = 'button',
73
+ children,
74
+ ...rest
75
+ }: { icon: IconName; label: string; children?: ReactNode } & Omit<
76
+ ButtonHTMLAttributes<HTMLButtonElement>,
77
+ 'aria-label'
78
+ >) {
79
+ return (
80
+ <button
81
+ type={type}
82
+ className={className ? `icon-button ${className}` : 'icon-button'}
83
+ aria-label={label}
84
+ {...rest}
85
+ >
86
+ <Icon name={icon} />
87
+ {children}
88
+ </button>
89
+ );
90
+ }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useRef, type ReactNode } from 'react';
4
+
5
+ import { IconButton } from './button';
6
+
7
+ /**
8
+ * A panel over the page from one side: a native `<dialog>` opened with `showModal()`, so the
9
+ * browser traps focus, makes the page behind it inert and closes it on Esc. Controlled: `open`
10
+ * says whether it shows, `onClose` is called for Esc, the close button and a click on the backdrop.
11
+ * Focus goes back to whatever had it before the drawer opened. The page behind does not scroll
12
+ * while it is open (`html:has(.drawer[open])` in components.css).
13
+ */
14
+ export function Drawer({
15
+ open,
16
+ onClose,
17
+ title,
18
+ closeLabel,
19
+ side = 'right',
20
+ footer,
21
+ className,
22
+ children,
23
+ }: {
24
+ open: boolean;
25
+ onClose: () => void;
26
+ title: ReactNode;
27
+ closeLabel: string;
28
+ side?: 'left' | 'right' | 'bottom' | 'top';
29
+ footer?: ReactNode;
30
+ className?: string;
31
+ children: ReactNode;
32
+ }) {
33
+ const dialog = useRef<HTMLDialogElement>(null);
34
+ const returnFocus = useRef<HTMLElement | null>(null);
35
+ const titleId = useId();
36
+
37
+ useEffect(() => {
38
+ const element = dialog.current;
39
+ if (element === null) {
40
+ return;
41
+ }
42
+ if (open && !element.open) {
43
+ returnFocus.current =
44
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
45
+ element.showModal();
46
+ } else if (!open && element.open) {
47
+ element.close();
48
+ }
49
+ }, [open]);
50
+
51
+ return (
52
+ <dialog
53
+ ref={dialog}
54
+ className={['drawer', `drawer--${side}`, className].filter(Boolean).join(' ')}
55
+ aria-labelledby={titleId}
56
+ onCancel={(event) => {
57
+ // Esc: the parent decides, so `open` stays the one source of truth.
58
+ event.preventDefault();
59
+ onClose();
60
+ }}
61
+ onClose={() => {
62
+ returnFocus.current?.focus();
63
+ returnFocus.current = null;
64
+ }}
65
+ onClick={(event) => {
66
+ // The dialog element itself is the backdrop; its content is the inner panel.
67
+ if (event.target === event.currentTarget) {
68
+ onClose();
69
+ }
70
+ }}
71
+ >
72
+ <div className="drawer__panel">
73
+ <header className="drawer__header">
74
+ <h2 id={titleId} className="drawer__title">
75
+ {title}
76
+ </h2>
77
+ <IconButton icon="close" label={closeLabel} onClick={onClose} />
78
+ </header>
79
+ <div className="drawer__body">{children}</div>
80
+ {footer && <footer className="drawer__footer">{footer}</footer>}
81
+ </div>
82
+ </dialog>
83
+ );
84
+ }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { Icon, type IconName } from './icon';
4
+
5
+ /** What a page shows when there is nothing yet: a title, a line of help and the way out. */
6
+ export function EmptyState({
7
+ title,
8
+ icon,
9
+ action,
10
+ children,
11
+ as: Heading = 'h2',
12
+ }: {
13
+ title: string;
14
+ icon?: IconName;
15
+ action?: ReactNode;
16
+ children?: ReactNode;
17
+ as?: 'h1' | 'h2' | 'h3';
18
+ }) {
19
+ return (
20
+ <div className="empty-state">
21
+ {icon && <Icon name={icon} size={32} className="empty-state__icon" />}
22
+ <Heading className="empty-state__title">{title}</Heading>
23
+ {children && <div className="empty-state__text">{children}</div>}
24
+ {action && <div className="empty-state__action">{action}</div>}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { useId, type InputHTMLAttributes } from 'react';
4
+
5
+ /**
6
+ * A labelled input with the API's message for it underneath (`VALIDATION_FAILED`), linked by
7
+ * `aria-describedby` and marked `aria-invalid`.
8
+ */
9
+ export function Field({
10
+ label,
11
+ error,
12
+ ...input
13
+ }: { label: string; error?: string | undefined } & InputHTMLAttributes<HTMLInputElement>) {
14
+ const id = useId();
15
+ return (
16
+ <div className="field">
17
+ <label className="field__label" htmlFor={id}>
18
+ {label}
19
+ </label>
20
+ <input
21
+ id={id}
22
+ aria-invalid={error ? true : undefined}
23
+ aria-describedby={error ? `${id}-error` : undefined}
24
+ {...input}
25
+ />
26
+ {error && (
27
+ <p id={`${id}-error`} className="field__error">
28
+ {error}
29
+ </p>
30
+ )}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,112 @@
1
+ import type { SVGProps } from 'react';
2
+
3
+ /**
4
+ * Outline icons from Tabler Icons (MIT, https://tabler.io/icons), inlined so the storefront needs no
5
+ * icon package. 24×24, stroked with `currentColor`; `star-filled` is the one filled shape.
6
+ */
7
+ const PATHS = {
8
+ search: ['M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0', 'M21 21l-6 -6'],
9
+ cart: [
10
+ 'M6.331 8h11.339a2 2 0 0 1 1.977 2.304l-1.255 8.152a3 3 0 0 1 -2.966 2.544h-6.852a3 3 0 0 1 -2.965 -2.544l-1.255 -8.152a2 2 0 0 1 1.977 -2.304z',
11
+ 'M9 11v-5a3 3 0 0 1 6 0v5',
12
+ ],
13
+ user: ['M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0', 'M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2'],
14
+ heart: ['M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572'],
15
+ menu: ['M4 6l16 0', 'M4 12l16 0', 'M4 18l16 0'],
16
+ close: ['M18 6l-12 12', 'M6 6l12 12'],
17
+ 'chevron-down': ['M6 9l6 6l6 -6'],
18
+ 'chevron-right': ['M9 6l6 6l-6 6'],
19
+ 'chevron-left': ['M15 6l-6 6l6 6'],
20
+ plus: ['M12 5l0 14', 'M5 12l14 0'],
21
+ minus: ['M5 12l14 0'],
22
+ check: ['M5 12l5 5l10 -10'],
23
+ star: [
24
+ 'M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z',
25
+ ],
26
+ share: [
27
+ 'M3 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0',
28
+ 'M15 6a3 3 0 1 0 6 0a3 3 0 1 0 -6 0',
29
+ 'M15 18a3 3 0 1 0 6 0a3 3 0 1 0 -6 0',
30
+ 'M8.7 10.7l6.6 -3.4',
31
+ 'M8.7 13.3l6.6 3.4',
32
+ ],
33
+ play: ['M7 4v16l13 -8z'],
34
+ filter: [
35
+ 'M12 6a2 2 0 1 0 4 0a2 2 0 1 0 -4 0',
36
+ 'M4 6l8 0',
37
+ 'M16 6l4 0',
38
+ 'M6 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0',
39
+ 'M4 12l2 0',
40
+ 'M10 12l10 0',
41
+ 'M15 18a2 2 0 1 0 4 0a2 2 0 1 0 -4 0',
42
+ 'M4 18l11 0',
43
+ 'M19 18l2 0',
44
+ ],
45
+ phone: [
46
+ 'M5 4h4l2 5l-2.5 1.5a11 11 0 0 0 5 5l1.5 -2.5l5 2v4a2 2 0 0 1 -2 2a16 16 0 0 1 -15 -15a2 2 0 0 1 2 -2',
47
+ ],
48
+ mail: [
49
+ 'M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10z',
50
+ 'M3 7l9 6l9 -6',
51
+ ],
52
+ 'map-pin': [
53
+ 'M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0',
54
+ 'M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0z',
55
+ ],
56
+ clock: ['M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0', 'M12 7v5l3 3'],
57
+ link: [
58
+ 'M9 15l6 -6',
59
+ 'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464',
60
+ 'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463',
61
+ ],
62
+ trash: [
63
+ 'M4 7l16 0',
64
+ 'M10 11l0 6',
65
+ 'M14 11l0 6',
66
+ 'M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12',
67
+ 'M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3',
68
+ ],
69
+ truck: [
70
+ 'M3 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0',
71
+ 'M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0',
72
+ 'M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m4 0h2v-6h-8m0 -5h5l3 5',
73
+ ],
74
+ } as const;
75
+
76
+ export type IconName = keyof typeof PATHS | 'star-filled';
77
+
78
+ /**
79
+ * An icon, decorative by default (`aria-hidden`): the control around it carries the name. Pass
80
+ * `label` for an icon that stands alone and means something.
81
+ */
82
+ export function Icon({
83
+ name,
84
+ size = 20,
85
+ label,
86
+ className,
87
+ ...rest
88
+ }: { name: IconName; size?: number; label?: string } & Omit<SVGProps<SVGSVGElement>, 'name'>) {
89
+ const filled = name === 'star-filled';
90
+ const paths = PATHS[filled ? 'star' : name];
91
+ return (
92
+ <svg
93
+ xmlns="http://www.w3.org/2000/svg"
94
+ width={size}
95
+ height={size}
96
+ viewBox="0 0 24 24"
97
+ fill={filled ? 'currentColor' : 'none'}
98
+ stroke="currentColor"
99
+ strokeWidth={1.75}
100
+ strokeLinecap="round"
101
+ strokeLinejoin="round"
102
+ className={className ? `icon ${className}` : 'icon'}
103
+ {...(label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true })}
104
+ focusable="false"
105
+ {...rest}
106
+ >
107
+ {paths.map((d) => (
108
+ <path key={d} d={d} />
109
+ ))}
110
+ </svg>
111
+ );
112
+ }
@@ -0,0 +1,35 @@
1
+ import { Money } from '@magicstoreai/hydrogen';
2
+ import { moneyAmount, type Money as MoneyValue } from '@magicstoreai/hydrogen/core';
3
+
4
+ /**
5
+ * A price as the API sent it, through `<Money>` (the shop's format). The compare-at price shows,
6
+ * struck through, only when it is above the price: an equal or lower one is no discount. Nothing is
7
+ * computed; a missing price renders nothing.
8
+ */
9
+ export function Price({
10
+ price,
11
+ compareAtPrice = null,
12
+ size = 'md',
13
+ className,
14
+ }: {
15
+ price: MoneyValue | null | undefined;
16
+ compareAtPrice?: MoneyValue | null;
17
+ size?: 'sm' | 'md' | 'lg';
18
+ className?: string;
19
+ }) {
20
+ if (price === null || price === undefined) {
21
+ return null;
22
+ }
23
+ const onSale = compareAtPrice !== null && moneyAmount(compareAtPrice) > moneyAmount(price);
24
+ const classes = ['price', `price--${size}`, onSale && 'price--sale', className].filter(Boolean);
25
+ return (
26
+ <span className={classes.join(' ')}>
27
+ <Money data={price} className="price__current" />
28
+ {onSale && (
29
+ <s className="price__compare">
30
+ <Money data={compareAtPrice} />
31
+ </s>
32
+ )}
33
+ </span>
34
+ );
35
+ }
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { Icon } from './icon';
6
+
7
+ /**
8
+ * A quantity: minus, a number field, plus; 44px targets. `max` is the stock the API sent
9
+ * (`quantityAvailable`, `null` = not tracked). Typing is kept as a draft and applied on blur or
10
+ * Enter, clamped to `min`…`max`; arrow keys step at once. `onChange` only ever receives a value in
11
+ * range and different from `value`.
12
+ */
13
+ export function QuantityStepper({
14
+ value,
15
+ onChange,
16
+ min = 1,
17
+ max = null,
18
+ label,
19
+ decreaseLabel,
20
+ increaseLabel,
21
+ disabled = false,
22
+ }: {
23
+ value: number;
24
+ onChange: (quantity: number) => void;
25
+ min?: number;
26
+ max?: number | null;
27
+ label: string;
28
+ decreaseLabel: string;
29
+ increaseLabel: string;
30
+ disabled?: boolean;
31
+ }) {
32
+ const [draft, setDraft] = useState(String(value));
33
+ useEffect(() => setDraft(String(value)), [value]);
34
+
35
+ const clamp = (quantity: number) =>
36
+ Math.max(min, max === null ? quantity : Math.min(max, quantity));
37
+ const commit = (quantity: number) => {
38
+ const next = clamp(Number.isFinite(quantity) ? Math.trunc(quantity) : value);
39
+ setDraft(String(next));
40
+ if (next !== value) {
41
+ onChange(next);
42
+ }
43
+ };
44
+
45
+ // An empty or non-numeric draft means "never mind": the value stays.
46
+ const commitDraft = () => commit(draft.trim() === '' ? Number.NaN : Number(draft));
47
+
48
+ return (
49
+ <div className="quantity" role="group" aria-label={label}>
50
+ <button
51
+ type="button"
52
+ className="quantity__button"
53
+ aria-label={decreaseLabel}
54
+ disabled={disabled || value <= min}
55
+ onClick={() => commit(value - 1)}
56
+ >
57
+ <Icon name="minus" size={16} />
58
+ </button>
59
+ <input
60
+ className="quantity__input"
61
+ type="number"
62
+ inputMode="numeric"
63
+ aria-label={label}
64
+ min={min}
65
+ max={max ?? undefined}
66
+ value={draft}
67
+ disabled={disabled}
68
+ onChange={(event) => setDraft(event.target.value)}
69
+ onBlur={commitDraft}
70
+ onKeyDown={(event) => {
71
+ if (event.key === 'Enter') {
72
+ event.preventDefault();
73
+ commitDraft();
74
+ } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
75
+ event.preventDefault();
76
+ commit(value + (event.key === 'ArrowUp' ? 1 : -1));
77
+ }
78
+ }}
79
+ />
80
+ <button
81
+ type="button"
82
+ className="quantity__button"
83
+ aria-label={increaseLabel}
84
+ disabled={disabled || (max !== null && value >= max)}
85
+ onClick={() => commit(value + 1)}
86
+ >
87
+ <Icon name="plus" size={16} />
88
+ </button>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,48 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ import { Icon } from './icon';
4
+
5
+ const STARS = [1, 2, 3, 4, 5];
6
+
7
+ /**
8
+ * A product's rating as the API sent it (`rating.average`, `rating.count`): five stars filled to
9
+ * the average, and the count. Nothing without reviews. `label` is the text a screen reader hears
10
+ * instead of the stars (`ratingSummary` in `lib/i18n.ts`).
11
+ */
12
+ export function Rating({
13
+ average,
14
+ count,
15
+ label,
16
+ showCount = true,
17
+ }: {
18
+ average: number | null;
19
+ count: number;
20
+ label: string;
21
+ showCount?: boolean;
22
+ }) {
23
+ if (average === null || count === 0) {
24
+ return null;
25
+ }
26
+ const fill = `${Math.min(Math.max(average / 5, 0), 1) * 100}%`;
27
+ return (
28
+ <span className="rating" role="img" aria-label={label}>
29
+ <span className="rating__stars" style={{ '--rating-fill': fill } as CSSProperties}>
30
+ <span className="rating__empty">
31
+ {STARS.map((star) => (
32
+ <Icon key={star} name="star" size={14} />
33
+ ))}
34
+ </span>
35
+ <span className="rating__full">
36
+ {STARS.map((star) => (
37
+ <Icon key={star} name="star-filled" size={14} />
38
+ ))}
39
+ </span>
40
+ </span>
41
+ {showCount && (
42
+ <span className="rating__count" aria-hidden="true">
43
+ {average.toFixed(1)} ({count})
44
+ </span>
45
+ )}
46
+ </span>
47
+ );
48
+ }
@@ -0,0 +1,46 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ /**
4
+ * A grey block the size of the content that will replace it (no layout shift when it arrives).
5
+ * Size it with a class, or `width` / `height` / `ratio`. Hidden from screen readers; the page
6
+ * announces loading once, not per block.
7
+ */
8
+ export function Skeleton({
9
+ className,
10
+ width,
11
+ height,
12
+ ratio,
13
+ }: {
14
+ className?: string;
15
+ width?: CSSProperties['width'];
16
+ height?: CSSProperties['height'];
17
+ ratio?: CSSProperties['aspectRatio'];
18
+ }) {
19
+ return (
20
+ <span
21
+ className={className ? `skeleton ${className}` : 'skeleton'}
22
+ style={{ width, height, aspectRatio: ratio }}
23
+ aria-hidden="true"
24
+ />
25
+ );
26
+ }
27
+
28
+ /** Product cards while a grid loads: the card's image ratio and two text lines each. */
29
+ export function ProductGridSkeleton({ count = 8 }: { count?: number }) {
30
+ return (
31
+ <ul className="product-grid" aria-hidden="true">
32
+ {Array.from({ length: count }, (_, index) => (
33
+ <li key={index} className="stack">
34
+ <Skeleton ratio="var(--media-ratio)" />
35
+ <Skeleton height="1rem" width="85%" />
36
+ <Skeleton height="1rem" width="40%" />
37
+ </li>
38
+ ))}
39
+ </ul>
40
+ );
41
+ }
42
+
43
+ /** A page heading while the page loads. */
44
+ export function TitleSkeleton({ width = '40%' }: { width?: string }) {
45
+ return <Skeleton className="skeleton--title" width={width} />;
46
+ }