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,48 @@
1
+ import { Money } from '@magicstoreai/hydrogen';
2
+ import Link from 'next/link';
3
+
4
+ import { buttonClass } from '@/components/ui/button';
5
+ import { Icon } from '@/components/ui/icon';
6
+ import type { Messages } from '@/lib/i18n';
7
+
8
+ import type { Completion } from './use-checkout';
9
+
10
+ /**
11
+ * The order is placed: its number and amount, and the payment link while the payment is pending
12
+ * (the page that receives the buyer back reads the payment's real state).
13
+ */
14
+ export function ThankYou({ completion, t }: { completion: Completion; t: Messages }) {
15
+ const { order, payment } = completion;
16
+ const pay = payment.status === 'PENDING' ? payment.redirectUrl : null;
17
+ return (
18
+ <div className="thank-you">
19
+ <span className="thank-you__icon" aria-hidden="true">
20
+ <Icon name="check" size={28} />
21
+ </span>
22
+ <h1>{t.thankYou}</h1>
23
+ <p className="thank-you__order">
24
+ {t.order} <strong>{order.number}</strong>
25
+ {order.total && (
26
+ <>
27
+ {' · '}
28
+ <Money data={order.total} />
29
+ </>
30
+ )}
31
+ </p>
32
+ <p className="muted">{t.orderNumberHint}</p>
33
+ <div className="thank-you__actions">
34
+ {pay && (
35
+ <a className={buttonClass({ variant: 'primary', size: 'lg' })} href={pay}>
36
+ {t.payNow}
37
+ </a>
38
+ )}
39
+ <Link
40
+ href="/"
41
+ className={buttonClass({ variant: pay ? 'secondary' : 'primary', size: 'lg' })}
42
+ >
43
+ {t.continueShopping}
44
+ </Link>
45
+ </div>
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import { useCart, useStorefrontClient } from '@magicstoreai/hydrogen';
4
+ import type { Schema } from '@magicstoreai/storefront-client';
5
+ import { useCallback, useEffect, useState } from 'react';
6
+
7
+ import { formErrors } from '@/lib/errors';
8
+
9
+ export type Checkout = Schema<'Checkout'>;
10
+ export type DeliveryOption = Schema<'DeliveryOption'>;
11
+ export type Location = Schema<'Location'>;
12
+ export type Completion = Schema<'CheckoutCompletion'>;
13
+ /** A refused field's message, by the request member it names (`name`, `phone`, `latitude`…). */
14
+ export type FieldErrors = Record<string, string>;
15
+
16
+ /** The request members the checkout steps have an input for; a refusal of any other goes in `error`. */
17
+ const CHECKOUT_FIELDS = ['name', 'phone', 'address', 'city', 'latitude', 'longitude'] as const;
18
+
19
+ /**
20
+ * The checkout of the current cart: it is opened once the cart is in (`checkoutsStore`, safe to
21
+ * repeat), and every step answers the updated checkout. `run` wraps a step: busy while it runs, a
22
+ * failure as `error` — and, for `VALIDATION_FAILED`, as `fieldErrors` to show next to each field.
23
+ * The checkout id stays in this state: never in a URL, never on screen.
24
+ */
25
+ export function useCheckout(fallbackError: string) {
26
+ const client = useStorefrontClient();
27
+ const { cart, status: cartStatus } = useCart();
28
+ const [checkout, setCheckout] = useState<Checkout | null>(null);
29
+ const [locations, setLocations] = useState<Location[]>([]);
30
+ const [options, setOptions] = useState<DeliveryOption[]>([]);
31
+ const [completion, setCompletion] = useState<Completion | null>(null);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
34
+ const [busy, setBusy] = useState(false);
35
+
36
+ const run = useCallback(
37
+ async (step: () => Promise<{ data: Checkout } | void>) => {
38
+ setBusy(true);
39
+ setError(null);
40
+ setFieldErrors({});
41
+ try {
42
+ const result = await step();
43
+ if (result) {
44
+ setCheckout(result.data);
45
+ }
46
+ } catch (e) {
47
+ const { fields, summary } = formErrors(e, CHECKOUT_FIELDS, fallbackError);
48
+ setFieldErrors(fields);
49
+ setError(summary);
50
+ } finally {
51
+ setBusy(false);
52
+ }
53
+ },
54
+ [fallbackError],
55
+ );
56
+
57
+ useEffect(() => {
58
+ if (cart === null || checkout !== null) {
59
+ return;
60
+ }
61
+ void run(() => client.checkoutsStore({ body: { cartId: cart.id } }));
62
+ // Pickup points are optional: without them the buyer is offered delivery only.
63
+ void client.locationsIndex({ query: { perPage: 50 } }).then(
64
+ ({ data }) => setLocations(data),
65
+ () => setLocations([]),
66
+ );
67
+ }, [cart, checkout, client, run]);
68
+
69
+ const loadOptions = useCallback(
70
+ async (id: string) =>
71
+ setOptions((await client.checkoutsDeliveryOptions({ path: { id } })).data),
72
+ [client],
73
+ );
74
+
75
+ return {
76
+ client,
77
+ cart,
78
+ cartStatus,
79
+ checkout,
80
+ locations,
81
+ options,
82
+ completion,
83
+ setCompletion,
84
+ error,
85
+ fieldErrors,
86
+ busy,
87
+ run,
88
+ loadOptions,
89
+ };
90
+ }
@@ -0,0 +1,132 @@
1
+ import type { Schema } from '@magicstoreai/storefront-client';
2
+ import Link from 'next/link';
3
+
4
+ import { Icon } from '@/components/ui/icon';
5
+ import type { Messages } from '@/lib/i18n';
6
+ import { hasContacts, SOCIAL_NAMES, telHref } from '@/lib/contacts';
7
+ import type { NavItem } from '@/lib/menu';
8
+
9
+ type Contacts = Schema<'Contacts'>;
10
+
11
+ /**
12
+ * The site footer. Columns, each only when it has something: the `footer` menu (its columns and
13
+ * links, or the shop's pages without one), the top collections, and the contacts (phones, email,
14
+ * hours, social links). Then the merchant's `footerText`, policy links and the copyright line.
15
+ * It always renders: a shop with no pages still gets its name and contacts.
16
+ */
17
+ export function SiteFooter({
18
+ shop,
19
+ nav,
20
+ collections,
21
+ contacts,
22
+ t,
23
+ }: {
24
+ shop: Pick<Schema<'Shop'>, 'name' | 'branding'>;
25
+ nav: { columns: NavItem[]; links: NavItem[]; policies: NavItem[] };
26
+ collections: NavItem[];
27
+ contacts: Contacts | null;
28
+ t: Messages;
29
+ }) {
30
+ const hours = contacts?.headOffice?.hoursSummary ?? null;
31
+ // The same test as the contacts page and the phone menu: a head office alone still links there.
32
+ const reachable = contacts !== null && hasContacts(contacts);
33
+ const year = new Date().getFullYear();
34
+
35
+ return (
36
+ <footer className="site-footer">
37
+ <div className="site-footer__inner">
38
+ <div className="site-footer__columns">
39
+ {nav.columns.map((column) => (
40
+ <FooterColumn key={column.href} title={column.title} links={column.children} />
41
+ ))}
42
+ {nav.links.length > 0 && <FooterColumn title={t.information} links={nav.links} />}
43
+ {collections.length > 0 && <FooterColumn title={t.catalog} links={collections} />}
44
+ {reachable && (
45
+ <section className="site-footer__column">
46
+ <h2 className="site-footer__heading">{t.contacts}</h2>
47
+ <ul className="site-footer__list">
48
+ {contacts.phones.map((phone) => (
49
+ <li key={phone.number}>
50
+ <a href={telHref(phone.number)} className="site-footer__contact">
51
+ <Icon name="phone" size={16} />
52
+ <span>
53
+ {phone.number}
54
+ {phone.label && <span className="muted"> {phone.label}</span>}
55
+ </span>
56
+ </a>
57
+ </li>
58
+ ))}
59
+ {contacts.email && (
60
+ <li>
61
+ <a href={`mailto:${contacts.email}`} className="site-footer__contact">
62
+ <Icon name="mail" size={16} />
63
+ <span>{contacts.email}</span>
64
+ </a>
65
+ </li>
66
+ )}
67
+ {hours && (
68
+ <li className="site-footer__contact">
69
+ <Icon name="clock" size={16} />
70
+ <span>
71
+ <span className="visually-hidden">{t.openingHours}: </span>
72
+ {hours}
73
+ </span>
74
+ </li>
75
+ )}
76
+ <li>
77
+ <Link href="/contacts" className="site-footer__contact">
78
+ <Icon name="map-pin" size={16} />
79
+ <span>{t.allContacts}</span>
80
+ </Link>
81
+ </li>
82
+ </ul>
83
+ {contacts.socials.length > 0 && (
84
+ <ul className="site-footer__socials" aria-label={t.socialMedia}>
85
+ {contacts.socials.map((social) => (
86
+ <li key={social.url}>
87
+ <a href={social.url} target="_blank" rel="noopener">
88
+ {SOCIAL_NAMES[social.network]}
89
+ </a>
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ )}
94
+ </section>
95
+ )}
96
+ </div>
97
+ {shop.branding.footerText && (
98
+ <p className="site-footer__text">{shop.branding.footerText}</p>
99
+ )}
100
+ <div className="site-footer__bottom">
101
+ <span>
102
+ © {year} {shop.name}
103
+ </span>
104
+ {nav.policies.length > 0 && (
105
+ <ul className="site-footer__policies">
106
+ {nav.policies.map((policy) => (
107
+ <li key={policy.href}>
108
+ <Link href={policy.href}>{policy.title}</Link>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ )}
113
+ </div>
114
+ </div>
115
+ </footer>
116
+ );
117
+ }
118
+
119
+ function FooterColumn({ title, links }: { title: string; links: NavItem[] }) {
120
+ return (
121
+ <nav className="site-footer__column" aria-label={title}>
122
+ <h2 className="site-footer__heading">{title}</h2>
123
+ <ul className="site-footer__list">
124
+ {links.map((link) => (
125
+ <li key={link.href}>
126
+ <Link href={link.href}>{link.title}</Link>
127
+ </li>
128
+ ))}
129
+ </ul>
130
+ </nav>
131
+ );
132
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { useCart, useMagicStore } from '@magicstoreai/hydrogen';
4
+ import Link from 'next/link';
5
+
6
+ import { useCartDrawer } from '@/components/cart/cart-drawer';
7
+ import { Icon } from '@/components/ui/icon';
8
+ import { messages } from '@/lib/i18n';
9
+
10
+ /**
11
+ * The header's cart: an icon with the live item count; its accessible name says the count. It
12
+ * opens the cart drawer, or goes to `/cart` when the theme's `cartType` is `PAGE`.
13
+ */
14
+ export function CartButton() {
15
+ const { totalQuantity } = useCart();
16
+ const { cartType, open } = useCartDrawer();
17
+ const t = messages(useMagicStore().locale);
18
+ const content = (
19
+ <>
20
+ <Icon name="cart" />
21
+ {totalQuantity > 0 && (
22
+ <span className="header-cart__count" aria-hidden="true">
23
+ {totalQuantity > 99 ? '99+' : totalQuantity}
24
+ </span>
25
+ )}
26
+ </>
27
+ );
28
+ return cartType === 'DRAWER' ? (
29
+ <button
30
+ type="button"
31
+ className="icon-button header-cart"
32
+ aria-label={t.cartCount(totalQuantity)}
33
+ aria-haspopup="dialog"
34
+ onClick={open}
35
+ >
36
+ {content}
37
+ </button>
38
+ ) : (
39
+ <Link href="/cart" className="icon-button header-cart" aria-label={t.cartCount(totalQuantity)}>
40
+ {content}
41
+ </Link>
42
+ );
43
+ }
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ import { useMagicStore } from '@magicstoreai/hydrogen';
4
+ import Link from 'next/link';
5
+ import { usePathname } from 'next/navigation';
6
+ import { useEffect, useId, useRef, useState } from 'react';
7
+
8
+ import { Icon } from '@/components/ui/icon';
9
+ import { messages } from '@/lib/i18n';
10
+ import type { NavItem } from '@/lib/menu';
11
+
12
+ /**
13
+ * The header's links at desktop width. A top item with children gets its own toggle (a disclosure
14
+ * button beside the link, so the link still leads to the collection): it opens on hover, on click
15
+ * and on Enter / Space, and closes on Esc (focus back on the toggle), a click outside, leaving the
16
+ * header, or a route change. The panel is a full-width row under the header; a child with children
17
+ * of its own makes it a mega panel with one column per child.
18
+ */
19
+ export function DesktopNav({ items }: { items: NavItem[] }) {
20
+ const t = messages(useMagicStore().locale);
21
+ const [open, setOpen] = useState<number | null>(null);
22
+ const nav = useRef<HTMLElement>(null);
23
+ const pathname = usePathname();
24
+ const id = useId();
25
+
26
+ useEffect(() => setOpen(null), [pathname]);
27
+
28
+ useEffect(() => {
29
+ if (open === null) {
30
+ return;
31
+ }
32
+ const outside = (event: PointerEvent) => {
33
+ if (!nav.current?.contains(event.target as Node)) {
34
+ setOpen(null);
35
+ }
36
+ };
37
+ const escape = (event: KeyboardEvent) => {
38
+ if (event.key === 'Escape') {
39
+ setOpen(null);
40
+ nav.current?.querySelector<HTMLElement>(`[aria-controls="${id}-${open}"]`)?.focus();
41
+ }
42
+ };
43
+ document.addEventListener('pointerdown', outside);
44
+ document.addEventListener('keydown', escape);
45
+ return () => {
46
+ document.removeEventListener('pointerdown', outside);
47
+ document.removeEventListener('keydown', escape);
48
+ };
49
+ }, [open, id]);
50
+
51
+ return (
52
+ <nav ref={nav} className="desktop-nav" aria-label={t.menu} onMouseLeave={() => setOpen(null)}>
53
+ <ul className="desktop-nav__list">
54
+ {items.map((item, index) => {
55
+ const current = pathname === item.href;
56
+ if (item.children.length === 0) {
57
+ return (
58
+ <li key={item.href} className="desktop-nav__item">
59
+ <Link
60
+ href={item.href}
61
+ className="desktop-nav__link"
62
+ aria-current={current ? 'page' : undefined}
63
+ onMouseEnter={() => setOpen(null)}
64
+ >
65
+ {item.title}
66
+ </Link>
67
+ </li>
68
+ );
69
+ }
70
+ const panelId = `${id}-${index}`;
71
+ const expanded = open === index;
72
+ const mega = item.children.some((child) => child.children.length > 0);
73
+ return (
74
+ <li key={item.href} className="desktop-nav__item" onMouseEnter={() => setOpen(index)}>
75
+ <Link
76
+ href={item.href}
77
+ className="desktop-nav__link"
78
+ aria-current={current ? 'page' : undefined}
79
+ >
80
+ {item.title}
81
+ </Link>
82
+ <button
83
+ type="button"
84
+ className="desktop-nav__toggle"
85
+ aria-expanded={expanded}
86
+ aria-controls={panelId}
87
+ aria-label={t.submenu(item.title)}
88
+ onClick={() => setOpen(expanded ? null : index)}
89
+ >
90
+ <Icon name="chevron-down" size={16} />
91
+ </button>
92
+ <div
93
+ id={panelId}
94
+ className={mega ? 'nav-panel nav-panel--mega' : 'nav-panel'}
95
+ hidden={!expanded}
96
+ >
97
+ <div className="nav-panel__inner">
98
+ {mega ? (
99
+ item.children.map((child) => (
100
+ <div key={child.href} className="nav-panel__column">
101
+ <Link href={child.href} className="nav-panel__heading">
102
+ {child.title}
103
+ </Link>
104
+ <ul className="nav-panel__list">
105
+ {child.children.map((grandchild) => (
106
+ <li key={grandchild.href}>
107
+ <Link href={grandchild.href}>{grandchild.title}</Link>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ </div>
112
+ ))
113
+ ) : (
114
+ <ul className="nav-panel__list">
115
+ {item.children.map((child) => (
116
+ <li key={child.href}>
117
+ <Link href={child.href}>{child.title}</Link>
118
+ </li>
119
+ ))}
120
+ </ul>
121
+ )}
122
+ <Link href={item.href} className="nav-panel__all">
123
+ {t.seeAll}
124
+ </Link>
125
+ </div>
126
+ </div>
127
+ </li>
128
+ );
129
+ })}
130
+ </ul>
131
+ </nav>
132
+ );
133
+ }
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+
3
+ import { useMagicStore } from '@magicstoreai/hydrogen';
4
+ import Link from 'next/link';
5
+ import { usePathname } from 'next/navigation';
6
+ import { useEffect, useState } from 'react';
7
+
8
+ import { IconButton } from '@/components/ui/button';
9
+ import { Drawer } from '@/components/ui/drawer';
10
+ import { Icon } from '@/components/ui/icon';
11
+ import { messages } from '@/lib/i18n';
12
+ import type { NavItem } from '@/lib/menu';
13
+
14
+ /**
15
+ * The menu below desktop width: a button that opens a drawer from the left. A level with children
16
+ * opens in place (a back button returns), so a deep collection tree never becomes one long list.
17
+ * `secondary` links (blog, account…) sit under the top level. Closes on navigation.
18
+ */
19
+ export function MobileMenu({ items, secondary }: { items: NavItem[]; secondary: NavItem[] }) {
20
+ const text = messages(useMagicStore().locale);
21
+ const [open, setOpen] = useState(false);
22
+ const [path, setPath] = useState<NavItem[]>([]);
23
+ const pathname = usePathname();
24
+
25
+ function close() {
26
+ setOpen(false);
27
+ setPath([]);
28
+ }
29
+
30
+ // Back/forward navigation; a tapped link closes the drawer itself, even one to this very page.
31
+ useEffect(() => close(), [pathname]);
32
+
33
+ const level = path.at(-1);
34
+ const list = level ? level.children : items;
35
+
36
+ return (
37
+ <>
38
+ <IconButton
39
+ icon="menu"
40
+ label={text.menu}
41
+ className="mobile-menu__button"
42
+ aria-expanded={open}
43
+ onClick={() => setOpen(true)}
44
+ />
45
+ <Drawer
46
+ open={open}
47
+ onClose={close}
48
+ title={text.menu}
49
+ closeLabel={text.close}
50
+ side="left"
51
+ className="mobile-menu"
52
+ >
53
+ {level && (
54
+ <div className="mobile-menu__level">
55
+ <button
56
+ type="button"
57
+ className="mobile-menu__back"
58
+ onClick={() => setPath(path.slice(0, -1))}
59
+ >
60
+ <Icon name="chevron-left" size={18} />
61
+ {text.back}
62
+ </button>
63
+ <Link href={level.href} className="mobile-menu__current" onClick={close}>
64
+ {level.title}
65
+ <span className="muted">{text.seeAll}</span>
66
+ </Link>
67
+ </div>
68
+ )}
69
+ <ul className="mobile-menu__list">
70
+ {list.map((item) => (
71
+ <li key={item.href} className="mobile-menu__item">
72
+ <Link href={item.href} className="mobile-menu__link" onClick={close}>
73
+ {item.title}
74
+ </Link>
75
+ {item.children.length > 0 && (
76
+ <button
77
+ type="button"
78
+ className="mobile-menu__open"
79
+ aria-label={text.submenu(item.title)}
80
+ onClick={() => setPath([...path, item])}
81
+ >
82
+ <Icon name="chevron-right" size={18} />
83
+ </button>
84
+ )}
85
+ </li>
86
+ ))}
87
+ </ul>
88
+ {!level && secondary.length > 0 && (
89
+ <ul className="mobile-menu__secondary">
90
+ {secondary.map((item) => (
91
+ <li key={item.href}>
92
+ <Link href={item.href} onClick={close}>
93
+ {item.title}
94
+ </Link>
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ )}
99
+ </Drawer>
100
+ </>
101
+ );
102
+ }
@@ -0,0 +1,78 @@
1
+ import { Image } from '@magicstoreai/hydrogen';
2
+ import type { Schema } from '@magicstoreai/storefront-client';
3
+ import Link from 'next/link';
4
+
5
+ import { Icon } from '@/components/ui/icon';
6
+ import type { Messages } from '@/lib/i18n';
7
+ import type { NavItem } from '@/lib/menu';
8
+
9
+ import { PredictiveSearch } from '@/components/search/predictive-search';
10
+
11
+ import { CartButton } from './cart-button';
12
+ import { DesktopNav } from './desktop-nav';
13
+ import { MobileMenu } from './mobile-menu';
14
+
15
+ /**
16
+ * The site header: logo (the merchant's image, else the name as a wordmark), the main navigation,
17
+ * and icons for search, account, wishlist (only with `features.wishlist`) and the cart. Below
18
+ * desktop width the navigation moves into a drawer and the logo sits in the middle.
19
+ */
20
+ export function SiteHeader({
21
+ shop,
22
+ nav,
23
+ showContacts,
24
+ t,
25
+ }: {
26
+ shop: Schema<'Shop'>;
27
+ nav: NavItem[];
28
+ /** Whether the shop gave any contacts, so `/contacts` has something to show. */
29
+ showContacts: boolean;
30
+ t: Messages;
31
+ }) {
32
+ const secondary: NavItem[] = [
33
+ { title: t.catalog, href: '/collections', children: [] },
34
+ ...(shop.features.blog ? [{ title: t.blog, href: '/blog', children: [] }] : []),
35
+ ...(showContacts ? [{ title: t.contacts, href: '/contacts', children: [] }] : []),
36
+ { title: t.account, href: '/account', children: [] },
37
+ ];
38
+ return (
39
+ <header className="site-header">
40
+ <div className="site-header__inner">
41
+ <div className="site-header__start">
42
+ <MobileMenu items={nav} secondary={secondary} />
43
+ <PredictiveSearch className="site-header__search-mobile" />
44
+ </div>
45
+ <Link href="/" className="site-header__logo">
46
+ {shop.branding.logo ? (
47
+ <Image data={shop.branding.logo} alt={shop.name} height={40} loading="eager" />
48
+ ) : (
49
+ shop.name
50
+ )}
51
+ </Link>
52
+ <DesktopNav items={nav} />
53
+ <div className="site-header__icons">
54
+ <PredictiveSearch className="site-header__search" />
55
+ {/* Only when an article is readable in this language (`features.blog`). */}
56
+ {shop.features.blog && (
57
+ <Link href="/blog" className="site-header__text-link">
58
+ {t.blog}
59
+ </Link>
60
+ )}
61
+ <Link href="/account" className="icon-button site-header__account" aria-label={t.account}>
62
+ <Icon name="user" />
63
+ </Link>
64
+ {shop.features.wishlist && (
65
+ <Link
66
+ href="/wishlist"
67
+ className="icon-button site-header__wishlist"
68
+ aria-label={t.wishlist}
69
+ >
70
+ <Icon name="heart" />
71
+ </Link>
72
+ )}
73
+ <CartButton />
74
+ </div>
75
+ </div>
76
+ </header>
77
+ );
78
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { locale } from '@/lib/api';
4
+ import { messages } from '@/lib/i18n';
5
+
6
+ /**
7
+ * A route's loading state (`loading.tsx`): the page's skeleton, announced once to screen readers.
8
+ * Skeletons take the final layout's sizes; `.site-main` is a screen tall, so the footer waits below
9
+ * the fold and nothing on screen shifts when the content arrives.
10
+ */
11
+ export function PageLoading({ children }: { children: ReactNode }) {
12
+ return (
13
+ <div aria-busy="true">
14
+ <p className="visually-hidden" role="status">
15
+ {messages(locale).loading}
16
+ </p>
17
+ {children}
18
+ </div>
19
+ );
20
+ }
@@ -21,7 +21,9 @@ export function Pager({ meta, href }: { meta: Pagination; href: (page: number) =
21
21
  page === null ? (
22
22
  <span key={`gap-${index}`}>…</span>
23
23
  ) : page === state.page ? (
24
- <strong key={page}>{page}</strong>
24
+ <span key={page} aria-current="page">
25
+ {page}
26
+ </span>
25
27
  ) : (
26
28
  <Link key={page} href={href(page)}>
27
29
  {page}