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.
- package/package.json +3 -3
- package/template/.claude/skills/storefront-design/SKILL.md +14 -10
- package/template/AGENTS.md +32 -16
- package/template/DESIGN.md +117 -0
- package/template/PAGES.md +107 -29
- package/template/README.md +15 -3
- package/template/app/account/addresses/page.tsx +16 -0
- package/template/app/account/layout.tsx +14 -0
- package/template/app/account/loading.tsx +15 -0
- package/template/app/account/orders/[id]/page.tsx +15 -0
- package/template/app/account/page.tsx +12 -244
- package/template/app/account/profile/page.tsx +16 -0
- package/template/app/blog/(index)/loading.tsx +22 -0
- package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
- package/template/app/blog/[handle]/page.tsx +92 -56
- package/template/app/blog/author/[handle]/page.tsx +1 -1
- package/template/app/cart/layout.tsx +14 -0
- package/template/app/cart/page.tsx +49 -101
- package/template/app/checkout/layout.tsx +14 -0
- package/template/app/checkout/page.tsx +98 -281
- package/template/app/collections/[handle]/layout.tsx +20 -0
- package/template/app/collections/[handle]/loading.tsx +23 -0
- package/template/app/collections/[handle]/page.tsx +131 -15
- package/template/app/collections/page.tsx +49 -0
- package/template/app/contacts/page.tsx +196 -0
- package/template/app/error.tsx +19 -8
- package/template/app/fonts/OFL.txt +93 -0
- package/template/app/fonts/index.ts +14 -0
- package/template/app/fonts/onest-variable.woff2 +0 -0
- package/template/app/globals.css +14 -518
- package/template/app/layout.tsx +73 -50
- package/template/app/not-found.tsx +34 -4
- package/template/app/page.tsx +1 -1
- package/template/app/pages/[handle]/page.tsx +1 -1
- package/template/app/products/[handle]/layout.tsx +20 -0
- package/template/app/products/[handle]/loading.tsx +25 -0
- package/template/app/products/[handle]/page.tsx +112 -15
- package/template/app/providers.tsx +5 -1
- package/template/app/search/loading.tsx +12 -0
- package/template/app/search/page.tsx +138 -25
- package/template/app/sitemap.ts +5 -1
- package/template/app/styles/base.css +138 -0
- package/template/app/styles/components.css +1108 -0
- package/template/app/styles/layout.css +455 -0
- package/template/app/styles/pages.css +1839 -0
- package/template/app/styles/reset.css +55 -0
- package/template/app/styles/sections.css +415 -0
- package/template/app/styles/tokens.css +167 -0
- package/template/app/styles/utilities.css +37 -0
- package/template/app/wishlist/page.tsx +26 -0
- package/template/components/account/account-shell.tsx +100 -0
- package/template/components/account/addresses.tsx +247 -0
- package/template/components/account/order-detail.tsx +281 -0
- package/template/components/account/order-list.tsx +94 -0
- package/template/components/account/order-status.tsx +19 -0
- package/template/components/account/profile.tsx +129 -0
- package/template/components/account/sign-in.tsx +164 -0
- package/template/components/article-body.tsx +3 -2
- package/template/components/article-card.tsx +37 -12
- package/template/components/cart/cart-drawer.tsx +149 -0
- package/template/components/cart/cart-lines.tsx +89 -0
- package/template/components/cart/cart-summary.tsx +191 -0
- package/template/components/catalog/catalog-form.tsx +107 -0
- package/template/components/catalog/catalog-layout.tsx +82 -0
- package/template/components/catalog/facets-shell.tsx +60 -0
- package/template/components/catalog/facets.tsx +216 -0
- package/template/components/checkout/order-summary.tsx +84 -0
- package/template/components/checkout/radio-card.tsx +34 -0
- package/template/components/checkout/step-indicator.tsx +56 -0
- package/template/components/checkout/steps.tsx +264 -0
- package/template/components/checkout/thank-you.tsx +48 -0
- package/template/components/checkout/use-checkout.ts +90 -0
- package/template/components/footer/site-footer.tsx +132 -0
- package/template/components/header/cart-button.tsx +43 -0
- package/template/components/header/desktop-nav.tsx +133 -0
- package/template/components/header/mobile-menu.tsx +102 -0
- package/template/components/header/site-header.tsx +78 -0
- package/template/components/page-loading.tsx +20 -0
- package/template/components/pager.tsx +3 -1
- package/template/components/product/buy-box.tsx +148 -0
- package/template/components/product/gallery.tsx +179 -0
- package/template/components/product/pre-order-form.tsx +93 -0
- package/template/components/product/product-selection.tsx +31 -0
- package/template/components/product/recommendations.tsx +26 -0
- package/template/components/product/review-list.tsx +159 -0
- package/template/components/product/reviews.tsx +80 -0
- package/template/components/product/variant-picker.tsx +75 -0
- package/template/components/product-card.tsx +103 -0
- package/template/components/product-grid.tsx +27 -57
- package/template/components/quick-add.tsx +62 -0
- package/template/components/search/predictive-search.tsx +282 -0
- package/template/components/sections/announcement-bar.tsx +1 -1
- package/template/components/sections/banner.tsx +1 -1
- package/template/components/sections/collections.tsx +50 -13
- package/template/components/sections/deal-of-day.tsx +2 -2
- package/template/components/sections/product-shelves.tsx +13 -5
- package/template/components/sections/shoppable-stories.tsx +2 -2
- package/template/components/sections/store-reviews.tsx +10 -4
- package/template/components/share-button.tsx +42 -0
- package/template/components/ui/accordion.tsx +35 -0
- package/template/components/ui/badge.tsx +20 -0
- package/template/components/ui/breadcrumbs.tsx +31 -0
- package/template/components/ui/button.tsx +90 -0
- package/template/components/ui/drawer.tsx +84 -0
- package/template/components/ui/empty-state.tsx +27 -0
- package/template/components/ui/field.tsx +33 -0
- package/template/components/ui/icon.tsx +112 -0
- package/template/components/ui/price.tsx +35 -0
- package/template/components/ui/quantity-stepper.tsx +91 -0
- package/template/components/ui/rating.tsx +48 -0
- package/template/components/ui/skeleton.tsx +46 -0
- package/template/components/wishlist/wishlist-view.tsx +134 -0
- package/template/lib/article-outline.ts +35 -0
- package/template/lib/catalog-params.ts +170 -0
- package/template/lib/contacts.ts +34 -0
- package/template/lib/errors.ts +29 -0
- package/template/lib/i18n.ts +484 -0
- package/template/lib/menu.ts +70 -0
- package/template/lib/theme-settings.ts +36 -0
- package/template/theme/index.ts +7 -1
- package/template/theme/sections/collapsible-content.tsx +75 -0
- package/template/theme/sections/collection-list.tsx +32 -4
- package/template/theme/sections/featured-collection.tsx +22 -2
- package/template/theme/sections/hero.tsx +94 -7
- package/template/theme/sections/image-with-text.tsx +19 -3
- package/template/theme/sections/multicolumn.tsx +113 -0
- package/template/theme/sections/page-content.tsx +10 -4
- package/template/theme/sections/product-shelf.tsx +26 -4
- package/template/theme/sections/rich-text.tsx +13 -1
- package/template/theme/sections/testimonials.tsx +14 -9
- package/template/theme/settings.ts +85 -1
- package/template/theme/templates/index.json +4 -2
- package/template/.screenshots/home-1280.png +0 -0
- package/template/.screenshots/home-390.png +0 -0
- package/template/.screenshots/home-filled-1280.png +0 -0
- package/template/.screenshots/home-filled-390.png +0 -0
- package/template/.screenshots/page-1280.png +0 -0
- package/template/components/buy-box.tsx +0 -89
- 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
|
+
}
|