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,100 @@
1
+ 'use client';
2
+
3
+ import { useCustomer, useMagicStore, useTelegramWebApp } from '@magicstoreai/hydrogen';
4
+ import Link from 'next/link';
5
+ import { usePathname } from 'next/navigation';
6
+ import { useState, type ReactNode } from 'react';
7
+
8
+ import { Button } from '@/components/ui/button';
9
+ import { describePhoneShare } from '@/lib/errors';
10
+ import { messages } from '@/lib/i18n';
11
+
12
+ import { SignIn } from './sign-in';
13
+
14
+ /**
15
+ * Every account page: signed out, the sign-in card instead of the page; signed in, the customer's
16
+ * name, the account navigation, then the page. Inside a Mini App, a customer without a phone is
17
+ * asked to share it first.
18
+ */
19
+ export function AccountShell({ children }: { children: ReactNode }) {
20
+ const { customer, isSignedIn } = useCustomer();
21
+ const { canRequestContact } = useTelegramWebApp();
22
+ const { locale, shop } = useMagicStore();
23
+ const pathname = usePathname();
24
+ const t = messages(locale);
25
+
26
+ if (!isSignedIn || !customer) {
27
+ return <SignIn />;
28
+ }
29
+ const links = [
30
+ { href: '/account', label: t.overview },
31
+ { href: '/account/addresses', label: t.addresses },
32
+ { href: '/account/profile', label: t.profile },
33
+ ...(shop?.features.wishlist ? [{ href: '/wishlist', label: t.wishlist }] : []),
34
+ ];
35
+ const current = (href: string) =>
36
+ href === '/account'
37
+ ? pathname === '/account' || pathname?.startsWith('/account/orders') === true
38
+ : pathname?.startsWith(href) === true;
39
+
40
+ return (
41
+ <div className="account">
42
+ <header className="account__header">
43
+ <h1 className="account__name">{customer.name || customer.phone}</h1>
44
+ {customer.name && customer.phone && <p className="muted">{customer.phone}</p>}
45
+ </header>
46
+ {/* A customer who only ever signed in through Telegram has no phone yet; older Telegram
47
+ clients cannot share it. */}
48
+ {canRequestContact && !customer.phone && <SharePhone />}
49
+ <nav className="account__nav" aria-label={t.accountNav}>
50
+ {links.map((link) => (
51
+ <Link
52
+ key={link.href}
53
+ href={link.href}
54
+ aria-current={current(link.href) ? 'page' : undefined}
55
+ >
56
+ {link.label}
57
+ </Link>
58
+ ))}
59
+ </nav>
60
+ <div className="account__content">{children}</div>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Signed in inside a Mini App without a phone: share the Telegram contact, which proves the phone.
67
+ * A declined request changes nothing.
68
+ */
69
+ function SharePhone() {
70
+ const { shareTelegramPhone } = useCustomer();
71
+ const t = messages(useMagicStore().locale);
72
+ const [busy, setBusy] = useState(false);
73
+ const [error, setError] = useState<string | null>(null);
74
+
75
+ async function share() {
76
+ setBusy(true);
77
+ setError(null);
78
+ try {
79
+ await shareTelegramPhone();
80
+ } catch (e) {
81
+ setError(describePhoneShare(e, t));
82
+ } finally {
83
+ setBusy(false);
84
+ }
85
+ }
86
+
87
+ return (
88
+ <section className="account__notice">
89
+ <p>{t.sharePhoneHint}</p>
90
+ <Button variant="primary" loading={busy} onClick={() => void share()}>
91
+ {t.sharePhone}
92
+ </Button>
93
+ {error && (
94
+ <p className="error" role="alert">
95
+ {error}
96
+ </p>
97
+ )}
98
+ </section>
99
+ );
100
+ }
@@ -0,0 +1,247 @@
1
+ 'use client';
2
+
3
+ import { useMagicStore, useStorefrontClient } from '@magicstoreai/hydrogen';
4
+ import type { Schema } from '@magicstoreai/storefront-client';
5
+ import { useEffect, useState, type FormEvent } from 'react';
6
+
7
+ import { Button } from '@/components/ui/button';
8
+ import { Field } from '@/components/ui/field';
9
+ import { Skeleton } from '@/components/ui/skeleton';
10
+ import { describe, formErrors } from '@/lib/errors';
11
+ import { messages, type Messages } from '@/lib/i18n';
12
+
13
+ type Address = Schema<'CustomerAddress'>;
14
+ type Errors = Record<string, string>;
15
+
16
+ /** The request members the address form has an input for. */
17
+ const ADDRESS_FIELDS = ['address', 'address2', 'city'] as const;
18
+
19
+ function line(address: Address): string {
20
+ return [address.address, address.address2, address.city].filter(Boolean).join(', ');
21
+ }
22
+
23
+ /**
24
+ * The address book (`customerAddresses*`): each address with edit, delete and "make default", and
25
+ * a form to add one. A refused field says why under it.
26
+ */
27
+ export function Addresses() {
28
+ const client = useStorefrontClient();
29
+ const t = messages(useMagicStore().locale);
30
+ const [addresses, setAddresses] = useState<Address[] | null>(null);
31
+ // `null`: no form open; `'new'`: adding; an id: editing that address.
32
+ const [editing, setEditing] = useState<string | null>(null);
33
+ const [error, setError] = useState<string | null>(null);
34
+
35
+ async function reload() {
36
+ setAddresses((await client.customerAddressesIndex()).data);
37
+ }
38
+
39
+ useEffect(() => {
40
+ let current = true;
41
+ client.customerAddressesIndex().then(
42
+ ({ data }) => current && setAddresses(data),
43
+ (e: unknown) => current && setError(describe(e, t.tryAgainLater)),
44
+ );
45
+ return () => {
46
+ current = false;
47
+ };
48
+ }, [client, t.tryAgainLater]);
49
+
50
+ async function change(step: () => Promise<unknown>) {
51
+ setError(null);
52
+ try {
53
+ await step();
54
+ await reload();
55
+ } catch (e) {
56
+ setError(describe(e, t.tryAgainLater));
57
+ }
58
+ }
59
+
60
+ if (addresses === null) {
61
+ return error ? (
62
+ <p className="error" role="alert">
63
+ {error}
64
+ </p>
65
+ ) : (
66
+ <div className="stack" aria-busy="true">
67
+ <Skeleton height="5rem" />
68
+ <Skeleton height="5rem" />
69
+ </div>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <div className="stack stack--loose">
75
+ {addresses.length === 0 && editing !== 'new' && <p className="muted">{t.noAddresses}</p>}
76
+ <ul className="address-list">
77
+ {addresses.map((address) =>
78
+ editing === address.id ? (
79
+ <li key={address.id}>
80
+ <AddressForm
81
+ address={address}
82
+ t={t}
83
+ onCancel={() => setEditing(null)}
84
+ onSave={async (body) => {
85
+ await client.customerAddressesUpdate({ path: { addressId: address.id }, body });
86
+ await reload();
87
+ setEditing(null);
88
+ }}
89
+ />
90
+ </li>
91
+ ) : (
92
+ <li key={address.id} className="address-card">
93
+ <div className="address-card__text">
94
+ <p>{line(address)}</p>
95
+ {address.isDefault && (
96
+ <span className="badge badge--neutral">{t.defaultAddress}</span>
97
+ )}
98
+ </div>
99
+ <div className="address-card__actions">
100
+ <Button variant="link" onClick={() => setEditing(address.id)}>
101
+ {t.editAddress}
102
+ </Button>
103
+ {!address.isDefault && address.address && (
104
+ <Button
105
+ variant="link"
106
+ onClick={() =>
107
+ void change(() =>
108
+ client.customerAddressesUpdate({
109
+ path: { addressId: address.id },
110
+ body: {
111
+ address: address.address ?? '',
112
+ address2: address.address2,
113
+ city: address.city,
114
+ latitude: address.latitude,
115
+ longitude: address.longitude,
116
+ isDefault: true,
117
+ },
118
+ }),
119
+ )
120
+ }
121
+ >
122
+ {t.makeDefault}
123
+ </Button>
124
+ )}
125
+ <Button
126
+ variant="link"
127
+ aria-label={t.deleteAddress(line(address))}
128
+ onClick={() =>
129
+ void change(() =>
130
+ client.customerAddressesDestroy({ path: { addressId: address.id } }),
131
+ )
132
+ }
133
+ >
134
+ {t.remove}
135
+ </Button>
136
+ </div>
137
+ </li>
138
+ ),
139
+ )}
140
+ </ul>
141
+ {error && (
142
+ <p className="error" role="alert">
143
+ {error}
144
+ </p>
145
+ )}
146
+ {editing === 'new' ? (
147
+ <AddressForm
148
+ address={null}
149
+ t={t}
150
+ onCancel={() => setEditing(null)}
151
+ onSave={async (body) => {
152
+ await client.customerAddressesStore({ body });
153
+ await reload();
154
+ setEditing(null);
155
+ }}
156
+ />
157
+ ) : (
158
+ <div>
159
+ <Button variant="primary" onClick={() => setEditing('new')}>
160
+ {t.addAddress}
161
+ </Button>
162
+ </div>
163
+ )}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ type AddressBody = Schema<'CustomerAddressRequest'>;
169
+
170
+ /** Add or edit one address; the API's refusals show under their fields. */
171
+ function AddressForm({
172
+ address,
173
+ t,
174
+ onSave,
175
+ onCancel,
176
+ }: {
177
+ address: Address | null;
178
+ t: Messages;
179
+ onSave: (body: AddressBody) => Promise<void>;
180
+ onCancel: () => void;
181
+ }) {
182
+ const [errors, setErrors] = useState<Errors>({});
183
+ const [error, setError] = useState<string | null>(null);
184
+ const [busy, setBusy] = useState(false);
185
+
186
+ async function submit(event: FormEvent<HTMLFormElement>) {
187
+ event.preventDefault();
188
+ const form = new FormData(event.currentTarget);
189
+ const text = (name: string) => String(form.get(name) ?? '').trim();
190
+ setErrors({});
191
+ setError(null);
192
+ setBusy(true);
193
+ try {
194
+ await onSave({
195
+ address: text('address'),
196
+ address2: text('address2') || null,
197
+ city: text('city') || null,
198
+ latitude: address?.latitude ?? null,
199
+ longitude: address?.longitude ?? null,
200
+ });
201
+ } catch (e) {
202
+ const { fields, summary } = formErrors(e, ADDRESS_FIELDS, t.tryAgainLater);
203
+ setErrors(fields);
204
+ setError(summary);
205
+ } finally {
206
+ setBusy(false);
207
+ }
208
+ }
209
+
210
+ return (
211
+ <form className="address-form" onSubmit={submit}>
212
+ <Field
213
+ label={t.street}
214
+ name="address"
215
+ required
216
+ autoComplete="street-address"
217
+ defaultValue={address?.address ?? ''}
218
+ error={errors.address}
219
+ />
220
+ <Field
221
+ label={t.address2}
222
+ name="address2"
223
+ autoComplete="address-line2"
224
+ defaultValue={address?.address2 ?? ''}
225
+ error={errors.address2}
226
+ />
227
+ <Field
228
+ label={t.city}
229
+ name="city"
230
+ autoComplete="address-level2"
231
+ defaultValue={address?.city ?? ''}
232
+ error={errors.city}
233
+ />
234
+ {error && (
235
+ <p className="error" role="alert">
236
+ {error}
237
+ </p>
238
+ )}
239
+ <div className="row">
240
+ <Button type="submit" variant="primary" loading={busy}>
241
+ {t.saveChanges}
242
+ </Button>
243
+ <Button onClick={onCancel}>{t.cancel}</Button>
244
+ </div>
245
+ </form>
246
+ );
247
+ }
@@ -0,0 +1,281 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Image,
5
+ MagicStoreError,
6
+ Money,
7
+ moneyAmount,
8
+ useCart,
9
+ useMagicStore,
10
+ useStorefrontClient,
11
+ } from '@magicstoreai/hydrogen';
12
+ import type { Schema } from '@magicstoreai/storefront-client';
13
+ import Link from 'next/link';
14
+ import { useEffect, useState } from 'react';
15
+
16
+ import { useCartDrawer } from '@/components/cart/cart-drawer';
17
+ import { Button } from '@/components/ui/button';
18
+ import { EmptyState } from '@/components/ui/empty-state';
19
+ import { Icon } from '@/components/ui/icon';
20
+ import { Skeleton } from '@/components/ui/skeleton';
21
+ import { describe } from '@/lib/errors';
22
+ import { messages, type Messages } from '@/lib/i18n';
23
+
24
+ import { formatDate, OrderStatus } from './order-status';
25
+
26
+ type Order = Schema<'Order'>;
27
+ type MoneyValue = Schema<'Money'>;
28
+
29
+ /**
30
+ * One of the customer's orders (`customerOrdersShow`; another customer's is `NOT_FOUND`): its
31
+ * lines, amounts, delivery and payment, "order again" (a new cart, then the cart drawer) and, while
32
+ * `cancellable`, a cancel with a confirm step.
33
+ */
34
+ export function OrderDetail({ id }: { id: string }) {
35
+ const client = useStorefrontClient();
36
+ const { reorder } = useCart();
37
+ const drawer = useCartDrawer();
38
+ const { locale } = useMagicStore();
39
+ const t = messages(locale);
40
+ const [order, setOrder] = useState<Order | null>(null);
41
+ const [missing, setMissing] = useState(false);
42
+ const [confirming, setConfirming] = useState(false);
43
+ const [busy, setBusy] = useState<'reorder' | 'cancel' | null>(null);
44
+ const [error, setError] = useState<string | null>(null);
45
+
46
+ useEffect(() => {
47
+ let current = true;
48
+ client.customerOrdersShow({ path: { id } }).then(
49
+ ({ data }) => current && setOrder(data),
50
+ (e: unknown) => {
51
+ if (!current) {
52
+ return;
53
+ }
54
+ if (e instanceof MagicStoreError && e.code === 'NOT_FOUND') {
55
+ setMissing(true);
56
+ } else {
57
+ setError(describe(e, t.tryAgainLater));
58
+ }
59
+ },
60
+ );
61
+ return () => {
62
+ current = false;
63
+ };
64
+ }, [client, id, t.tryAgainLater]);
65
+
66
+ async function act(kind: 'reorder' | 'cancel', step: () => Promise<void>) {
67
+ setBusy(kind);
68
+ setError(null);
69
+ try {
70
+ await step();
71
+ } catch (e) {
72
+ setError(describe(e, t.tryAgainLater));
73
+ } finally {
74
+ setBusy(null);
75
+ }
76
+ }
77
+
78
+ if (missing) {
79
+ return <EmptyState title={t.notFound} action={<Link href="/account">{t.backToOrders}</Link>} />;
80
+ }
81
+ if (order === null) {
82
+ return error ? (
83
+ <p className="error" role="alert">
84
+ {error}
85
+ </p>
86
+ ) : (
87
+ <div className="stack" aria-busy="true">
88
+ <Skeleton height="2rem" width="40%" />
89
+ <Skeleton height="12rem" />
90
+ </div>
91
+ );
92
+ }
93
+
94
+ const date = formatDate(order.createdAt, locale);
95
+ const address = order.shippingAddress;
96
+ const pickup = order.delivery?.pickupLocation ?? null;
97
+
98
+ return (
99
+ <article className="order-detail">
100
+ <Link href="/account" className="order-detail__back">
101
+ <Icon name="chevron-left" size={16} />
102
+ {t.backToOrders}
103
+ </Link>
104
+ <header className="order-detail__header">
105
+ <div>
106
+ <h2 className="order-detail__title">{t.orderNumber(order.number)}</h2>
107
+ {date && <p className="muted text-sm">{t.placedOn(date)}</p>}
108
+ </div>
109
+ <OrderStatus status={order.status} t={t} />
110
+ </header>
111
+ {order.status === 'CANCELLED' && <p className="muted">{t.orderCancelledNote}</p>}
112
+
113
+ <ul className="order-detail__lines">
114
+ {order.lineItems.map((line) => (
115
+ <li key={line.id} className="order-detail__line">
116
+ <span className="order-detail__media">
117
+ <Image
118
+ data={line.image}
119
+ width={64}
120
+ height={64}
121
+ alt=""
122
+ fallback={<span className="media-fallback" />}
123
+ />
124
+ </span>
125
+ <span className="order-detail__name">
126
+ {line.productHandle ? (
127
+ <Link href={`/products/${line.productHandle}`}>{line.title}</Link>
128
+ ) : (
129
+ line.title
130
+ )}
131
+ <span className="muted text-sm">
132
+ {[line.variantTitle, `× ${line.quantity}`].filter(Boolean).join(' · ')}
133
+ </span>
134
+ </span>
135
+ <Money data={line.cost.total} className="order-detail__price" />
136
+ </li>
137
+ ))}
138
+ </ul>
139
+
140
+ <div className="order-detail__grid">
141
+ <dl className="order-summary__rows">
142
+ <Row label={t.subtotal} value={order.cost.subtotal} />
143
+ <Row label={t.discount} value={order.cost.discountTotal} negative />
144
+ <Row label={t.pointsDiscount} value={order.cost.pointsDiscount} negative />
145
+ <Row label={t.delivery} value={order.cost.deliveryTotal} />
146
+ <div className="order-summary__row order-summary__row--total">
147
+ <dt>{t.total}</dt>
148
+ <dd>{order.cost.total && <Money data={order.cost.total} />}</dd>
149
+ </div>
150
+ </dl>
151
+ <div className="order-detail__facts">
152
+ {(pickup || address) && (
153
+ <section>
154
+ <h3 className="order-detail__fact-title">
155
+ {pickup ? t.pickupPoint : t.deliveryAddress}
156
+ </h3>
157
+ <p className="muted">
158
+ {pickup
159
+ ? [pickup.name, pickup.address].filter(Boolean).join(', ')
160
+ : [address?.address, address?.address2, address?.city].filter(Boolean).join(', ')}
161
+ </p>
162
+ </section>
163
+ )}
164
+ {order.payment && (
165
+ <section>
166
+ <h3 className="order-detail__fact-title">{t.payment}</h3>
167
+ <p className="muted">
168
+ {[order.payment.methodTitle, t.paymentStatus[order.payment.status]]
169
+ .filter(Boolean)
170
+ .join(' · ')}
171
+ </p>
172
+ </section>
173
+ )}
174
+ </div>
175
+ </div>
176
+
177
+ {error && (
178
+ <p className="error" role="alert">
179
+ {error}
180
+ </p>
181
+ )}
182
+ <Actions
183
+ order={order}
184
+ t={t}
185
+ busy={busy}
186
+ confirming={confirming}
187
+ setConfirming={setConfirming}
188
+ onReorder={() =>
189
+ void act('reorder', async () => {
190
+ await reorder(order.id);
191
+ drawer.added();
192
+ })
193
+ }
194
+ onCancel={() =>
195
+ void act('cancel', async () => {
196
+ try {
197
+ const { data } = await client.customerOrdersCancellation({ path: { id: order.id } });
198
+ setOrder(data);
199
+ } catch (e) {
200
+ // The shop refused (cancellation off, already in progress, paid): `detail` says why,
201
+ // and offering the button again would only repeat the refusal.
202
+ if (e instanceof MagicStoreError && e.code === 'ORDER_NOT_CANCELLABLE') {
203
+ setOrder({ ...order, cancellable: false });
204
+ }
205
+ throw e;
206
+ } finally {
207
+ setConfirming(false);
208
+ }
209
+ })
210
+ }
211
+ />
212
+ </article>
213
+ );
214
+ }
215
+
216
+ function Actions({
217
+ order,
218
+ t,
219
+ busy,
220
+ confirming,
221
+ setConfirming,
222
+ onReorder,
223
+ onCancel,
224
+ }: {
225
+ order: Order;
226
+ t: Messages;
227
+ busy: 'reorder' | 'cancel' | null;
228
+ confirming: boolean;
229
+ setConfirming: (confirming: boolean) => void;
230
+ onReorder: () => void;
231
+ onCancel: () => void;
232
+ }) {
233
+ if (confirming) {
234
+ return (
235
+ <div className="order-detail__confirm" role="group" aria-label={t.cancelOrder}>
236
+ <p>{t.cancelOrderConfirm}</p>
237
+ <div className="row">
238
+ <Button variant="primary" loading={busy === 'cancel'} onClick={onCancel}>
239
+ {t.cancelOrder}
240
+ </Button>
241
+ <Button onClick={() => setConfirming(false)}>{t.keepOrder}</Button>
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+ return (
247
+ <div className="order-detail__actions">
248
+ <Button variant="primary" loading={busy === 'reorder'} onClick={onReorder}>
249
+ {t.orderAgain}
250
+ </Button>
251
+ {order.cancellable && (
252
+ <Button variant="link" onClick={() => setConfirming(true)}>
253
+ {t.cancelOrder}
254
+ </Button>
255
+ )}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ function Row({
261
+ label,
262
+ value,
263
+ negative = false,
264
+ }: {
265
+ label: string;
266
+ value: MoneyValue | null;
267
+ negative?: boolean;
268
+ }) {
269
+ if (value === null || (negative && moneyAmount(value) <= 0)) {
270
+ return null;
271
+ }
272
+ return (
273
+ <div className="order-summary__row">
274
+ <dt>{label}</dt>
275
+ <dd>
276
+ {negative && '−'}
277
+ <Money data={value} />
278
+ </dd>
279
+ </div>
280
+ );
281
+ }