create-magic-storefront 0.4.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 (143) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/.env.example +5 -0
  4. package/template/AGENTS.md +36 -17
  5. package/template/DESIGN.md +117 -0
  6. package/template/PAGES.md +116 -29
  7. package/template/README.md +15 -3
  8. package/template/app/account/addresses/page.tsx +16 -0
  9. package/template/app/account/layout.tsx +14 -0
  10. package/template/app/account/loading.tsx +15 -0
  11. package/template/app/account/orders/[id]/page.tsx +15 -0
  12. package/template/app/account/page.tsx +12 -244
  13. package/template/app/account/profile/page.tsx +16 -0
  14. package/template/app/blog/(index)/loading.tsx +22 -0
  15. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  16. package/template/app/blog/[handle]/page.tsx +92 -56
  17. package/template/app/blog/author/[handle]/page.tsx +1 -1
  18. package/template/app/cart/layout.tsx +14 -0
  19. package/template/app/cart/page.tsx +49 -101
  20. package/template/app/checkout/layout.tsx +14 -0
  21. package/template/app/checkout/page.tsx +98 -281
  22. package/template/app/collections/[handle]/layout.tsx +20 -0
  23. package/template/app/collections/[handle]/loading.tsx +23 -0
  24. package/template/app/collections/[handle]/page.tsx +131 -15
  25. package/template/app/collections/page.tsx +49 -0
  26. package/template/app/contacts/page.tsx +196 -0
  27. package/template/app/error.tsx +19 -8
  28. package/template/app/fonts/OFL.txt +93 -0
  29. package/template/app/fonts/index.ts +14 -0
  30. package/template/app/fonts/onest-variable.woff2 +0 -0
  31. package/template/app/globals.css +14 -518
  32. package/template/app/layout.tsx +74 -50
  33. package/template/app/not-found.tsx +34 -4
  34. package/template/app/page.tsx +1 -1
  35. package/template/app/pages/[handle]/page.tsx +1 -1
  36. package/template/app/products/[handle]/layout.tsx +20 -0
  37. package/template/app/products/[handle]/loading.tsx +25 -0
  38. package/template/app/products/[handle]/page.tsx +112 -15
  39. package/template/app/providers.tsx +20 -7
  40. package/template/app/search/loading.tsx +12 -0
  41. package/template/app/search/page.tsx +138 -25
  42. package/template/app/sitemap.ts +5 -1
  43. package/template/app/storefront-api/[...path]/route.ts +11 -66
  44. package/template/app/styles/base.css +138 -0
  45. package/template/app/styles/components.css +1108 -0
  46. package/template/app/styles/layout.css +455 -0
  47. package/template/app/styles/pages.css +1839 -0
  48. package/template/app/styles/reset.css +55 -0
  49. package/template/app/styles/sections.css +415 -0
  50. package/template/app/styles/tokens.css +167 -0
  51. package/template/app/styles/utilities.css +37 -0
  52. package/template/app/wishlist/page.tsx +26 -0
  53. package/template/components/account/account-shell.tsx +100 -0
  54. package/template/components/account/addresses.tsx +247 -0
  55. package/template/components/account/order-detail.tsx +281 -0
  56. package/template/components/account/order-list.tsx +94 -0
  57. package/template/components/account/order-status.tsx +19 -0
  58. package/template/components/account/profile.tsx +129 -0
  59. package/template/components/account/sign-in.tsx +164 -0
  60. package/template/components/article-body.tsx +3 -2
  61. package/template/components/article-card.tsx +37 -12
  62. package/template/components/cart/cart-drawer.tsx +149 -0
  63. package/template/components/cart/cart-lines.tsx +89 -0
  64. package/template/components/cart/cart-summary.tsx +191 -0
  65. package/template/components/catalog/catalog-form.tsx +107 -0
  66. package/template/components/catalog/catalog-layout.tsx +82 -0
  67. package/template/components/catalog/facets-shell.tsx +60 -0
  68. package/template/components/catalog/facets.tsx +216 -0
  69. package/template/components/checkout/order-summary.tsx +84 -0
  70. package/template/components/checkout/radio-card.tsx +34 -0
  71. package/template/components/checkout/step-indicator.tsx +56 -0
  72. package/template/components/checkout/steps.tsx +264 -0
  73. package/template/components/checkout/thank-you.tsx +48 -0
  74. package/template/components/checkout/use-checkout.ts +90 -0
  75. package/template/components/footer/site-footer.tsx +132 -0
  76. package/template/components/header/cart-button.tsx +43 -0
  77. package/template/components/header/desktop-nav.tsx +133 -0
  78. package/template/components/header/mobile-menu.tsx +102 -0
  79. package/template/components/header/site-header.tsx +78 -0
  80. package/template/components/page-loading.tsx +20 -0
  81. package/template/components/pager.tsx +3 -1
  82. package/template/components/product/buy-box.tsx +148 -0
  83. package/template/components/product/gallery.tsx +179 -0
  84. package/template/components/product/pre-order-form.tsx +93 -0
  85. package/template/components/product/product-selection.tsx +31 -0
  86. package/template/components/product/recommendations.tsx +26 -0
  87. package/template/components/product/review-list.tsx +159 -0
  88. package/template/components/product/reviews.tsx +80 -0
  89. package/template/components/product/variant-picker.tsx +75 -0
  90. package/template/components/product-card.tsx +103 -0
  91. package/template/components/product-grid.tsx +27 -57
  92. package/template/components/quick-add.tsx +62 -0
  93. package/template/components/search/predictive-search.tsx +282 -0
  94. package/template/components/sections/announcement-bar.tsx +1 -1
  95. package/template/components/sections/banner.tsx +1 -1
  96. package/template/components/sections/collections.tsx +50 -13
  97. package/template/components/sections/deal-of-day.tsx +2 -2
  98. package/template/components/sections/product-shelves.tsx +13 -5
  99. package/template/components/sections/shoppable-stories.tsx +2 -2
  100. package/template/components/sections/store-reviews.tsx +10 -4
  101. package/template/components/share-button.tsx +42 -0
  102. package/template/components/ui/accordion.tsx +35 -0
  103. package/template/components/ui/badge.tsx +20 -0
  104. package/template/components/ui/breadcrumbs.tsx +31 -0
  105. package/template/components/ui/button.tsx +90 -0
  106. package/template/components/ui/drawer.tsx +84 -0
  107. package/template/components/ui/empty-state.tsx +27 -0
  108. package/template/components/ui/field.tsx +33 -0
  109. package/template/components/ui/icon.tsx +112 -0
  110. package/template/components/ui/price.tsx +35 -0
  111. package/template/components/ui/quantity-stepper.tsx +91 -0
  112. package/template/components/ui/rating.tsx +48 -0
  113. package/template/components/ui/skeleton.tsx +46 -0
  114. package/template/components/wishlist/wishlist-view.tsx +134 -0
  115. package/template/lib/article-outline.ts +35 -0
  116. package/template/lib/catalog-params.ts +170 -0
  117. package/template/lib/contacts.ts +34 -0
  118. package/template/lib/errors.ts +29 -0
  119. package/template/lib/i18n.ts +484 -0
  120. package/template/lib/menu.ts +70 -0
  121. package/template/lib/theme-settings.ts +36 -0
  122. package/template/lib/upstream.ts +21 -0
  123. package/template/llms.txt +13 -0
  124. package/template/theme/index.ts +7 -1
  125. package/template/theme/sections/collapsible-content.tsx +75 -0
  126. package/template/theme/sections/collection-list.tsx +32 -4
  127. package/template/theme/sections/featured-collection.tsx +22 -2
  128. package/template/theme/sections/hero.tsx +94 -7
  129. package/template/theme/sections/image-with-text.tsx +19 -3
  130. package/template/theme/sections/multicolumn.tsx +113 -0
  131. package/template/theme/sections/page-content.tsx +10 -4
  132. package/template/theme/sections/product-shelf.tsx +26 -4
  133. package/template/theme/sections/rich-text.tsx +13 -1
  134. package/template/theme/sections/testimonials.tsx +14 -9
  135. package/template/theme/settings.ts +85 -1
  136. package/template/theme/templates/index.json +4 -2
  137. package/template/.screenshots/home-1280.png +0 -0
  138. package/template/.screenshots/home-390.png +0 -0
  139. package/template/.screenshots/home-filled-1280.png +0 -0
  140. package/template/.screenshots/home-filled-390.png +0 -0
  141. package/template/.screenshots/page-1280.png +0 -0
  142. package/template/components/buy-box.tsx +0 -89
  143. package/template/components/cart-link.tsx +0 -15
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ import { Money, useMagicStore, useStorefrontClient } from '@magicstoreai/hydrogen';
4
+ import type { Schema } from '@magicstoreai/storefront-client';
5
+ import Link from 'next/link';
6
+ import { useEffect, useState } from 'react';
7
+
8
+ import { buttonClass } from '@/components/ui/button';
9
+ import { EmptyState } from '@/components/ui/empty-state';
10
+ import { Icon } from '@/components/ui/icon';
11
+ import { Skeleton } from '@/components/ui/skeleton';
12
+ import { describe } from '@/lib/errors';
13
+ import { messages } from '@/lib/i18n';
14
+
15
+ import { formatDate, OrderStatus } from './order-status';
16
+
17
+ type Order = Schema<'Order'>;
18
+
19
+ /** The customer's orders, newest first, each a link to its page. */
20
+ export function OrderList() {
21
+ const client = useStorefrontClient();
22
+ const { locale } = useMagicStore();
23
+ const t = messages(locale);
24
+ const [orders, setOrders] = useState<Order[] | null>(null);
25
+ const [error, setError] = useState<string | null>(null);
26
+
27
+ useEffect(() => {
28
+ let current = true;
29
+ client.customerOrdersIndex({ query: { perPage: 20 } }).then(
30
+ ({ data }) => current && setOrders(data),
31
+ (e: unknown) => current && setError(describe(e, t.tryAgainLater)),
32
+ );
33
+ return () => {
34
+ current = false;
35
+ };
36
+ }, [client, t.tryAgainLater]);
37
+
38
+ if (error) {
39
+ return (
40
+ <p className="error" role="alert">
41
+ {error}
42
+ </p>
43
+ );
44
+ }
45
+ if (orders === null) {
46
+ return (
47
+ <div className="stack" aria-busy="true">
48
+ <p className="visually-hidden" role="status">
49
+ {t.loadingOrders}
50
+ </p>
51
+ {[0, 1, 2].map((index) => (
52
+ <Skeleton key={index} height="5.5rem" />
53
+ ))}
54
+ </div>
55
+ );
56
+ }
57
+ if (orders.length === 0) {
58
+ return (
59
+ <EmptyState
60
+ title={t.noOrders}
61
+ icon="cart"
62
+ action={
63
+ <Link href="/collections" className={buttonClass({ variant: 'primary' })}>
64
+ {t.shopNow}
65
+ </Link>
66
+ }
67
+ />
68
+ );
69
+ }
70
+ return (
71
+ <ul className="order-list">
72
+ {orders.map((order) => {
73
+ const date = formatDate(order.createdAt, locale);
74
+ return (
75
+ <li key={order.id}>
76
+ <Link href={`/account/orders/${order.id}`} className="order-card">
77
+ <span className="order-card__main">
78
+ <span className="order-card__number">{t.orderNumber(order.number)}</span>
79
+ <span className="muted text-sm">
80
+ {[date && t.placedOn(date), t.itemsCount(order.totalQuantity)]
81
+ .filter(Boolean)
82
+ .join(' · ')}
83
+ </span>
84
+ </span>
85
+ <OrderStatus status={order.status} t={t} />
86
+ <Money data={order.cost.total} className="order-card__total" />
87
+ <Icon name="chevron-right" className="order-card__arrow" />
88
+ </Link>
89
+ </li>
90
+ );
91
+ })}
92
+ </ul>
93
+ );
94
+ }
@@ -0,0 +1,19 @@
1
+ import type { Schema } from '@magicstoreai/storefront-client';
2
+
3
+ import type { Messages } from '@/lib/i18n';
4
+
5
+ /** An order's status as a pill; the colour follows the status, the words come from `i18n`. */
6
+ export function OrderStatus({ status, t }: { status: Schema<'Order'>['status']; t: Messages }) {
7
+ return (
8
+ <span className={`order-status order-status--${status.toLowerCase()}`}>
9
+ {t.orderStatus[status]}
10
+ </span>
11
+ );
12
+ }
13
+
14
+ /** A date as the shop's language writes it, or nothing. */
15
+ export function formatDate(value: string | null, locale: string): string | null {
16
+ return value
17
+ ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(value))
18
+ : null;
19
+ }
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import { useCustomer, useMagicStore, useStorefrontClient } from '@magicstoreai/hydrogen';
4
+ import type { Schema } from '@magicstoreai/storefront-client';
5
+ import { useId, useState, type FormEvent } from 'react';
6
+
7
+ import { Button } from '@/components/ui/button';
8
+ import { Field } from '@/components/ui/field';
9
+ import { formErrors } from '@/lib/errors';
10
+ import { messages } from '@/lib/i18n';
11
+
12
+ type Gender = NonNullable<Schema<'CustomerUpdateRequest'>['gender']>;
13
+
14
+ /** The languages a customer may choose (`CustomerUpdateRequest.locale`), each in its own words. */
15
+ const LANGUAGES = { ru: 'Русский', uz: "O'zbekcha", en: 'English' } as const;
16
+
17
+ /**
18
+ * The customer's details (`customerUpdate`): name, gender, language. The phone is how they sign in;
19
+ * it is shown, not edited here (changing it needs a code). Then sign out.
20
+ */
21
+ export function Profile() {
22
+ const client = useStorefrontClient();
23
+ const { customer, reload, signOut } = useCustomer();
24
+ const { locale } = useMagicStore();
25
+ const t = messages(locale);
26
+ const [errors, setErrors] = useState<Record<string, string>>({});
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [saved, setSaved] = useState(false);
29
+ const [busy, setBusy] = useState(false);
30
+ const genderId = useId();
31
+ const languageId = useId();
32
+
33
+ if (customer === null) {
34
+ return null;
35
+ }
36
+
37
+ async function submit(event: FormEvent<HTMLFormElement>) {
38
+ event.preventDefault();
39
+ const form = new FormData(event.currentTarget);
40
+ const gender = String(form.get('gender') ?? '');
41
+ setErrors({});
42
+ setError(null);
43
+ setSaved(false);
44
+ setBusy(true);
45
+ try {
46
+ await client.customerUpdate({
47
+ body: {
48
+ name: String(form.get('name') ?? '').trim(),
49
+ gender: gender === '' ? null : (gender as Gender),
50
+ locale: String(form.get('locale')) as keyof typeof LANGUAGES,
51
+ },
52
+ });
53
+ await reload();
54
+ setSaved(true);
55
+ } catch (e) {
56
+ const { fields, summary } = formErrors(e, ['name'], t.tryAgainLater);
57
+ setErrors(fields);
58
+ setError(summary);
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ }
63
+
64
+ return (
65
+ <div className="stack stack--loose">
66
+ <form className="profile-form" onSubmit={submit}>
67
+ <Field
68
+ label={t.name}
69
+ name="name"
70
+ autoComplete="name"
71
+ maxLength={255}
72
+ defaultValue={customer.name ?? ''}
73
+ error={errors.name}
74
+ />
75
+ {customer.phone && (
76
+ <div className="field">
77
+ <span className="field__label">{t.phone}</span>
78
+ <p>{customer.phone}</p>
79
+ <p className="muted text-sm">{t.phoneHint}</p>
80
+ </div>
81
+ )}
82
+ <div className="field">
83
+ <label className="field__label" htmlFor={genderId}>
84
+ {t.gender}
85
+ </label>
86
+ <select id={genderId} name="gender" defaultValue={customer.gender ?? ''}>
87
+ <option value="">{t.genderOptions.none}</option>
88
+ <option value="MALE">{t.genderOptions.MALE}</option>
89
+ <option value="FEMALE">{t.genderOptions.FEMALE}</option>
90
+ </select>
91
+ </div>
92
+ <div className="field">
93
+ <label className="field__label" htmlFor={languageId}>
94
+ {t.language}
95
+ </label>
96
+ <select
97
+ id={languageId}
98
+ name="locale"
99
+ defaultValue={
100
+ [customer.locale, locale].find((code) => code && code in LANGUAGES) ?? 'en'
101
+ }
102
+ >
103
+ {Object.entries(LANGUAGES).map(([code, name]) => (
104
+ <option key={code} value={code} lang={code}>
105
+ {name}
106
+ </option>
107
+ ))}
108
+ </select>
109
+ </div>
110
+ {error && (
111
+ <p className="error" role="alert">
112
+ {error}
113
+ </p>
114
+ )}
115
+ <div className="row">
116
+ <Button type="submit" variant="primary" loading={busy}>
117
+ {t.saveChanges}
118
+ </Button>
119
+ <span className="muted text-sm" role="status">
120
+ {saved ? t.changesSaved : ''}
121
+ </span>
122
+ </div>
123
+ </form>
124
+ <div>
125
+ <Button onClick={() => void signOut()}>{t.signOut}</Button>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCustomer,
5
+ useMagicStore,
6
+ useTelegramSignIn,
7
+ useTelegramWebApp,
8
+ } from '@magicstoreai/hydrogen';
9
+ import { useState } from 'react';
10
+
11
+ import { Button } from '@/components/ui/button';
12
+ import { Field } from '@/components/ui/field';
13
+ import { describe } from '@/lib/errors';
14
+ import { messages } from '@/lib/i18n';
15
+
16
+ /**
17
+ * Sign-in: inside a Telegram Mini App the Telegram button first, then the phone form (a code by
18
+ * SMS, or the phone alone where the shop has sign-in codes off).
19
+ */
20
+ export function SignIn() {
21
+ const { status, isTelegram } = useTelegramWebApp();
22
+ const { locale, shop } = useMagicStore();
23
+ const t = messages(locale);
24
+ // Until the app has looked (`unknown`, server render included), a shop with a bot may be open in
25
+ // Telegram: hold the Telegram block's place so the phone form does not jump down after mount.
26
+ const maybeTelegram = status === 'unknown' && shop?.features.telegramBot === true;
27
+ return (
28
+ <div className="auth-card">
29
+ <h1 className="auth-card__title">{t.signIn}</h1>
30
+ {isTelegram && <TelegramBlock />}
31
+ {maybeTelegram && <TelegramBlock placeholder />}
32
+ <PhoneSignIn />
33
+ </div>
34
+ );
35
+ }
36
+
37
+ /**
38
+ * The Telegram sign-in above the phone form. As a `placeholder` it renders the same markup, hidden,
39
+ * so the reserved space always matches the real block.
40
+ */
41
+ function TelegramBlock({ placeholder = false }: { placeholder?: boolean }) {
42
+ const t = messages(useMagicStore().locale);
43
+ return (
44
+ <div
45
+ className="stack"
46
+ aria-hidden={placeholder || undefined}
47
+ style={placeholder ? { visibility: 'hidden' } : undefined}
48
+ >
49
+ <TelegramSignIn />
50
+ <p className="auth-card__divider">{t.orWithPhone}</p>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Inside a Mini App: sign in with the launch data. The automatic sign-in runs in
57
+ * components/telegram-shell.tsx; this is the manual retry. The hook shares the attempt's state, so
58
+ * this shows the automatic attempt too: "Signing in…" while it runs, the relaunch hint when it failed.
59
+ */
60
+ function TelegramSignIn() {
61
+ const { status, error, signIn } = useTelegramSignIn({ auto: false });
62
+ const t = messages(useMagicStore().locale);
63
+ // FORBIDDEN: the shop has no bot, so retrying is pointless.
64
+ const unavailable = error?.code === 'FORBIDDEN';
65
+ return (
66
+ <div className="stack">
67
+ {!unavailable && (
68
+ <button
69
+ className="button button--primary button--lg button--full"
70
+ disabled={status === 'signing-in'}
71
+ onClick={() => void signIn()}
72
+ >
73
+ {status === 'signing-in' ? t.signingIn : t.signInWithTelegram}
74
+ </button>
75
+ )}
76
+ {status === 'failed' && (
77
+ <p className="error" role="alert">
78
+ {error?.code === 'UNAUTHENTICATED'
79
+ ? t.telegramRelaunch
80
+ : unavailable
81
+ ? t.telegramUnavailable
82
+ : describe(error, t.tryAgainLater)}
83
+ </p>
84
+ )}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Phone sign-in: a code by SMS, or — on a shop whose sign-in code is off
91
+ * (`shop.features.otpLogin === false`) — the phone alone.
92
+ */
93
+ function PhoneSignIn() {
94
+ const { requestOtp, verifyOtp, signInWithPhone } = useCustomer();
95
+ const { locale, shop } = useMagicStore();
96
+ const t = messages(locale);
97
+ const withoutCode = shop?.features.otpLogin === false;
98
+ const [phone, setPhone] = useState('');
99
+ const [sent, setSent] = useState(false);
100
+ const [code, setCode] = useState('');
101
+ const [busy, setBusy] = useState(false);
102
+ const [error, setError] = useState<string | null>(null);
103
+
104
+ async function attempt(step: () => Promise<unknown>) {
105
+ setError(null);
106
+ setBusy(true);
107
+ try {
108
+ await step();
109
+ } catch (e) {
110
+ setError(describe(e, t.tryAgainLater));
111
+ } finally {
112
+ setBusy(false);
113
+ }
114
+ }
115
+
116
+ return (
117
+ <form
118
+ className="stack"
119
+ onSubmit={(event) => {
120
+ event.preventDefault();
121
+ void attempt(async () => {
122
+ if (withoutCode) {
123
+ await signInWithPhone(phone);
124
+ } else if (!sent) {
125
+ await requestOtp(phone);
126
+ setSent(true);
127
+ } else {
128
+ await verifyOtp(phone, code);
129
+ }
130
+ });
131
+ }}
132
+ >
133
+ <Field
134
+ label={t.phone}
135
+ value={phone}
136
+ onChange={(e) => setPhone(e.target.value)}
137
+ placeholder="+998 90 123 45 67"
138
+ type="tel"
139
+ autoComplete="tel"
140
+ required
141
+ disabled={sent}
142
+ />
143
+ {sent && (
144
+ <Field
145
+ label={t.smsCode}
146
+ value={code}
147
+ onChange={(e) => setCode(e.target.value)}
148
+ inputMode="numeric"
149
+ autoComplete="one-time-code"
150
+ required
151
+ autoFocus
152
+ />
153
+ )}
154
+ <Button type="submit" variant="primary" size="lg" full loading={busy}>
155
+ {sent || withoutCode ? t.signIn : t.sendCode}
156
+ </Button>
157
+ {error && (
158
+ <p className="error" role="alert">
159
+ {error}
160
+ </p>
161
+ )}
162
+ </form>
163
+ );
164
+ }
@@ -1,6 +1,7 @@
1
1
  import { articleBodyBlocks, type ArticleBlock } from '@magicstoreai/hydrogen/core';
2
2
  import type { Schema } from '@magicstoreai/storefront-client';
3
- import { ProductCard } from '@/components/product-grid';
3
+ import { ProductCard } from '@/components/product-card';
4
+ import { locale } from '@/lib/api';
4
5
 
5
6
  type Product = Schema<'Product'>;
6
7
 
@@ -34,7 +35,7 @@ function Blocks({
34
35
  const product = products.get(block.handle);
35
36
  return product ? (
36
37
  <div key={index} className="article-product">
37
- <ProductCard product={product} />
38
+ <ProductCard product={product} locale={locale} />
38
39
  </div>
39
40
  ) : null;
40
41
  }
@@ -22,19 +22,44 @@ function formatDate(iso: string, timeZone: string): string {
22
22
  }
23
23
  }
24
24
 
25
- /** One article as a card: cover, category, title, excerpt, author, date and reading time. */
26
- export function ArticleCard({ article, timeZone }: { article: Article; timeZone: string }) {
25
+ /**
26
+ * One article as a card: cover, category, title, excerpt, then author, date and reading time.
27
+ * `feature` lays it out large, the cover beside the text: the blog's lead article.
28
+ */
29
+ export function ArticleCard({
30
+ article,
31
+ timeZone,
32
+ feature = false,
33
+ }: {
34
+ article: Article;
35
+ timeZone: string;
36
+ feature?: boolean;
37
+ }) {
27
38
  const t = messages(locale);
28
39
  return (
29
- <Link href={`/blog/${article.handle}`} className="card article-card">
30
- <Image data={article.image} alt={article.title} fallback={<div className="noimg" />} />
31
- {article.category && <span className="badge">{article.category.title}</span>}
32
- <strong>{article.title}</strong>
33
- {article.excerpt && <span className="muted">{article.excerpt}</span>}
34
- <span className="row muted">
35
- {article.authorName && <span>{article.authorName}</span>}
36
- <ArticleDate iso={article.publishedAt} timeZone={timeZone} />
37
- <span>{t.readingTime(article.readingMinutes)}</span>
40
+ <Link
41
+ href={`/blog/${article.handle}`}
42
+ className={feature ? 'article-card article-card--feature' : 'article-card'}
43
+ >
44
+ <span className="article-card__media">
45
+ <Image
46
+ data={article.image}
47
+ alt=""
48
+ loading={feature ? 'eager' : 'lazy'}
49
+ fallback={<span className="media-fallback" />}
50
+ />
51
+ </span>
52
+ <span className="article-card__body">
53
+ {article.category && (
54
+ <span className="article-card__category">{article.category.title}</span>
55
+ )}
56
+ <span className="article-card__title">{article.title}</span>
57
+ {article.excerpt && <span className="article-card__excerpt">{article.excerpt}</span>}
58
+ <span className="article-card__meta">
59
+ {article.authorName && <span>{article.authorName}</span>}
60
+ <ArticleDate iso={article.publishedAt} timeZone={timeZone} />
61
+ <span>{t.readingTime(article.readingMinutes)}</span>
62
+ </span>
38
63
  </span>
39
64
  </Link>
40
65
  );
@@ -45,7 +70,7 @@ export function ArticleGrid({ articles, timeZone }: { articles: Article[]; timeZ
45
70
  return <p className="muted">{messages(locale).nothingHere}</p>;
46
71
  }
47
72
  return (
48
- <div className="grid articles">
73
+ <div className="article-grid">
49
74
  {articles.map((article) => (
50
75
  <ArticleCard key={article.id} article={article} timeZone={timeZone} />
51
76
  ))}
@@ -0,0 +1,149 @@
1
+ 'use client';
2
+
3
+ import { Money, useCart, useMagicStore } from '@magicstoreai/hydrogen';
4
+ import Link from 'next/link';
5
+ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
6
+
7
+ import { buttonClass } from '@/components/ui/button';
8
+ import { Drawer } from '@/components/ui/drawer';
9
+ import { EmptyState } from '@/components/ui/empty-state';
10
+ import { describe } from '@/lib/errors';
11
+ import { messages } from '@/lib/i18n';
12
+
13
+ import { CartLines } from './cart-lines';
14
+
15
+ /** `DRAWER`: adding opens the side panel; `PAGE`: the buyer stays and hears "added". */
16
+ export type CartType = 'DRAWER' | 'PAGE';
17
+
18
+ type CartDrawerContext = {
19
+ cartType: CartType;
20
+ isOpen: boolean;
21
+ open: () => void;
22
+ close: () => void;
23
+ /** Call after a successful add: opens the drawer, or announces the add (`cartType: PAGE`). */
24
+ added: () => void;
25
+ };
26
+
27
+ const Context = createContext<CartDrawerContext | null>(null);
28
+
29
+ /** The cart drawer's state, for the header icon and every add-to-cart button. */
30
+ export function useCartDrawer(): CartDrawerContext {
31
+ const context = useContext(Context);
32
+ if (context === null) {
33
+ throw new Error(
34
+ 'useCartDrawer() must be used inside <CartDrawerProvider> (app/providers.tsx).',
35
+ );
36
+ }
37
+ return context;
38
+ }
39
+
40
+ /**
41
+ * Holds the drawer's open state and renders the drawer once for the whole app, plus a polite live
42
+ * region that announces an add to screen readers whatever `cartType` is.
43
+ */
44
+ export function CartDrawerProvider({
45
+ cartType,
46
+ children,
47
+ }: {
48
+ cartType: CartType;
49
+ children: ReactNode;
50
+ }) {
51
+ const t = messages(useMagicStore().locale);
52
+ const [isOpen, setOpen] = useState(false);
53
+ const [announcement, setAnnouncement] = useState('');
54
+ const open = useCallback(() => setOpen(true), []);
55
+ const close = useCallback(() => setOpen(false), []);
56
+ const added = useCallback(() => {
57
+ // A new string each time, so a second add is announced too.
58
+ setAnnouncement((previous) =>
59
+ previous === t.addedToCart ? `${t.addedToCart} ` : t.addedToCart,
60
+ );
61
+ if (cartType === 'DRAWER') {
62
+ setOpen(true);
63
+ }
64
+ }, [cartType, t.addedToCart]);
65
+ const value = useMemo(
66
+ () => ({ cartType, isOpen, open, close, added }),
67
+ [cartType, isOpen, open, close, added],
68
+ );
69
+
70
+ return (
71
+ <Context.Provider value={value}>
72
+ {children}
73
+ <CartDrawer open={isOpen} onClose={close} />
74
+ <p className="visually-hidden" aria-live="polite">
75
+ {announcement}
76
+ </p>
77
+ </Context.Provider>
78
+ );
79
+ }
80
+
81
+ /**
82
+ * The cart in a right-hand panel: its lines, the subtotal from the cart (never computed here), a
83
+ * note that delivery comes at checkout, and the ways on: checkout and the cart page.
84
+ */
85
+ function CartDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
86
+ const { cart, error } = useCart();
87
+ const t = messages(useMagicStore().locale);
88
+ const empty = cart === null || cart.lines.length === 0;
89
+
90
+ return (
91
+ <Drawer
92
+ open={open}
93
+ onClose={onClose}
94
+ title={t.cart}
95
+ closeLabel={t.close}
96
+ className="cart-drawer"
97
+ footer={
98
+ empty ? undefined : (
99
+ <div className="cart-drawer__summary">
100
+ <div className="cart-drawer__subtotal">
101
+ <span>{t.subtotal}</span>
102
+ <Money data={cart.cost.subtotal} />
103
+ </div>
104
+ <p className="cart-drawer__note">{t.deliveryAtCheckout}</p>
105
+ {error !== null && (
106
+ <p className="error" role="alert">
107
+ {describe(error, t.tryAgainLater)}
108
+ </p>
109
+ )}
110
+ <Link
111
+ href="/checkout"
112
+ className={buttonClass({ variant: 'primary', size: 'lg', full: true })}
113
+ onClick={onClose}
114
+ >
115
+ {t.checkout}
116
+ </Link>
117
+ <Link
118
+ href="/cart"
119
+ className={buttonClass({ variant: 'link', full: true })}
120
+ onClick={onClose}
121
+ >
122
+ {t.viewCart}
123
+ </Link>
124
+ </div>
125
+ )
126
+ }
127
+ >
128
+ {empty ? (
129
+ <EmptyState
130
+ title={t.cartEmpty}
131
+ icon="cart"
132
+ action={
133
+ <Link
134
+ href="/collections"
135
+ className={buttonClass({ variant: 'primary' })}
136
+ onClick={onClose}
137
+ >
138
+ {t.shopNow}
139
+ </Link>
140
+ }
141
+ >
142
+ {t.cartEmptyHint}
143
+ </EmptyState>
144
+ ) : (
145
+ <CartLines onNavigate={onClose} />
146
+ )}
147
+ </Drawer>
148
+ );
149
+ }