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
|
@@ -1,120 +1,68 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useCart, useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { CartLines } from '@/components/cart/cart-lines';
|
|
6
|
+
import { CartSummary } from '@/components/cart/cart-summary';
|
|
7
|
+
import { buttonClass } from '@/components/ui/button';
|
|
8
|
+
import { EmptyState } from '@/components/ui/empty-state';
|
|
9
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
7
10
|
import { messages } from '@/lib/i18n';
|
|
8
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The cart: its lines beside a sticky summary from 990px, the summary after the lines below. The
|
|
14
|
+
* cart is the browser's (`useCart`); every amount is the API's.
|
|
15
|
+
*/
|
|
9
16
|
export default function CartPage() {
|
|
10
|
-
const { cart, status
|
|
11
|
-
const [code, setCode] = useState('');
|
|
17
|
+
const { cart, status } = useCart();
|
|
12
18
|
const t = messages(useMagicStore().locale);
|
|
13
19
|
|
|
14
20
|
if (status === 'loading') {
|
|
15
|
-
return
|
|
21
|
+
return (
|
|
22
|
+
<div aria-busy="true">
|
|
23
|
+
<h1 className="page-title">{t.cart}</h1>
|
|
24
|
+
<p className="visually-hidden" role="status">
|
|
25
|
+
{t.loadingCart}
|
|
26
|
+
</p>
|
|
27
|
+
<div className="cart-page">
|
|
28
|
+
<div className="stack">
|
|
29
|
+
{[0, 1, 2].map((index) => (
|
|
30
|
+
<Skeleton key={index} height="7rem" />
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
<Skeleton height="18rem" />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
16
37
|
}
|
|
17
38
|
if (cart === null || cart.lines.length === 0) {
|
|
18
39
|
return (
|
|
19
|
-
|
|
20
|
-
<h1>{t.
|
|
21
|
-
<
|
|
22
|
-
|
|
40
|
+
<>
|
|
41
|
+
<h1 className="page-title">{t.cart}</h1>
|
|
42
|
+
<EmptyState
|
|
43
|
+
title={t.cartEmpty}
|
|
44
|
+
icon="cart"
|
|
45
|
+
action={
|
|
46
|
+
<Link href="/collections" className={buttonClass({ variant: 'primary' })}>
|
|
47
|
+
{t.shopNow}
|
|
48
|
+
</Link>
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{t.cartEmptyHint}
|
|
52
|
+
</EmptyState>
|
|
53
|
+
</>
|
|
23
54
|
);
|
|
24
55
|
}
|
|
25
56
|
|
|
26
|
-
const applied = cart.discountCodes[0];
|
|
27
|
-
|
|
28
57
|
return (
|
|
29
|
-
|
|
30
|
-
<h1>{t.cart}</h1>
|
|
31
|
-
<
|
|
32
|
-
{cart
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<div>{line.merchandise.product?.title}</div>
|
|
37
|
-
{line.merchandise.variant &&
|
|
38
|
-
!/^Default( Title)?$/.test(line.merchandise.variant.title) && (
|
|
39
|
-
<div className="muted">{line.merchandise.variant.title}</div>
|
|
40
|
-
)}
|
|
41
|
-
{!line.available && <div className="error">{t.notAvailable}</div>}
|
|
42
|
-
</div>
|
|
43
|
-
<div className="cart-line-controls">
|
|
44
|
-
<input
|
|
45
|
-
type="number"
|
|
46
|
-
min={0}
|
|
47
|
-
max={999}
|
|
48
|
-
value={line.quantity}
|
|
49
|
-
aria-label={t.quantity}
|
|
50
|
-
onChange={(event) =>
|
|
51
|
-
void updateLine(line.id, Number(event.target.value)).catch(() => undefined)
|
|
52
|
-
}
|
|
53
|
-
style={{ width: 72 }}
|
|
54
|
-
/>
|
|
55
|
-
<Money data={line.cost.total} />
|
|
56
|
-
<button onClick={() => void removeLine(line.id).catch(() => undefined)}>
|
|
57
|
-
{t.remove}
|
|
58
|
-
</button>
|
|
59
|
-
</div>
|
|
60
|
-
</li>
|
|
61
|
-
))}
|
|
62
|
-
</ul>
|
|
63
|
-
|
|
64
|
-
<form
|
|
65
|
-
className="row"
|
|
66
|
-
onSubmit={(event) => {
|
|
67
|
-
event.preventDefault();
|
|
68
|
-
void setDiscountCodes(code.trim() ? [code.trim()] : []).catch(() => undefined);
|
|
69
|
-
}}
|
|
70
|
-
>
|
|
71
|
-
<input
|
|
72
|
-
value={code}
|
|
73
|
-
onChange={(event) => setCode(event.target.value)}
|
|
74
|
-
placeholder={t.discountCode}
|
|
75
|
-
aria-label={t.discountCode}
|
|
76
|
-
/>
|
|
77
|
-
<button>{t.apply}</button>
|
|
78
|
-
{applied && (
|
|
79
|
-
<span className={applied.applicable ? 'muted' : 'error'}>
|
|
80
|
-
{applied.code}:{' '}
|
|
81
|
-
{applied.applicable ? t.codeApplied : `${t.codeNotApplied} (${applied.reasonCode})`}
|
|
82
|
-
</span>
|
|
83
|
-
)}
|
|
84
|
-
</form>
|
|
85
|
-
|
|
86
|
-
<table>
|
|
87
|
-
<tbody>
|
|
88
|
-
<tr>
|
|
89
|
-
<td>{t.subtotal}</td>
|
|
90
|
-
<td>
|
|
91
|
-
<Money data={cart.cost.subtotal} />
|
|
92
|
-
</td>
|
|
93
|
-
</tr>
|
|
94
|
-
{cart.cost.discountTotal && moneyAmount(cart.cost.discountTotal) > 0 && (
|
|
95
|
-
<tr>
|
|
96
|
-
<td>{t.discount}</td>
|
|
97
|
-
<td>
|
|
98
|
-
−<Money data={cart.cost.discountTotal} />
|
|
99
|
-
</td>
|
|
100
|
-
</tr>
|
|
101
|
-
)}
|
|
102
|
-
<tr>
|
|
103
|
-
<th>{t.total}</th>
|
|
104
|
-
<th>
|
|
105
|
-
<Money data={cart.cost.total} />
|
|
106
|
-
</th>
|
|
107
|
-
</tr>
|
|
108
|
-
</tbody>
|
|
109
|
-
</table>
|
|
110
|
-
|
|
111
|
-
{error && <p className="error">{describe(error, t.tryAgainLater)}</p>}
|
|
112
|
-
<div className="row">
|
|
113
|
-
<Link href="/checkout" className="button primary">
|
|
114
|
-
{t.checkout}
|
|
115
|
-
</Link>
|
|
116
|
-
{status === 'updating' && <span className="muted">{t.updating}</span>}
|
|
58
|
+
<>
|
|
59
|
+
<h1 className="page-title">{t.cart}</h1>
|
|
60
|
+
<div className="cart-page">
|
|
61
|
+
<section className="cart-page__lines" aria-label={t.cart}>
|
|
62
|
+
<CartLines />
|
|
63
|
+
</section>
|
|
64
|
+
<CartSummary cart={cart} />
|
|
117
65
|
</div>
|
|
118
|
-
|
|
66
|
+
</>
|
|
119
67
|
);
|
|
120
68
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { locale } from '@/lib/api';
|
|
4
|
+
import { messages } from '@/lib/i18n';
|
|
5
|
+
|
|
6
|
+
// The checkout is a client component; its title and robots come from here. Personal: not indexed.
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: messages(locale).checkoutTitle,
|
|
9
|
+
robots: { index: false, follow: false },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function CheckoutLayout({ children }: { children: ReactNode }) {
|
|
13
|
+
return children;
|
|
14
|
+
}
|
|
@@ -1,316 +1,133 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
Money,
|
|
5
|
-
useCart,
|
|
6
|
-
useCustomer,
|
|
7
|
-
useMagicStore,
|
|
8
|
-
useStorefrontClient,
|
|
9
|
-
} from '@magicstoreai/hydrogen';
|
|
10
|
-
import type { Schema } from '@magicstoreai/storefront-client';
|
|
3
|
+
import { Money, useMagicStore } from '@magicstoreai/hydrogen';
|
|
11
4
|
import Link from 'next/link';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
5
|
+
import { OrderSummary } from '@/components/checkout/order-summary';
|
|
6
|
+
import { StepIndicator } from '@/components/checkout/step-indicator';
|
|
7
|
+
import { ContactStep, DeliveryStep, PaymentStep } from '@/components/checkout/steps';
|
|
8
|
+
import { ThankYou } from '@/components/checkout/thank-you';
|
|
9
|
+
import { useCheckout } from '@/components/checkout/use-checkout';
|
|
10
|
+
import { Button, buttonClass } from '@/components/ui/button';
|
|
11
|
+
import { EmptyState } from '@/components/ui/empty-state';
|
|
12
|
+
import { Icon } from '@/components/ui/icon';
|
|
13
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
14
14
|
import { messages } from '@/lib/i18n';
|
|
15
15
|
|
|
16
|
-
type Checkout = Schema<'Checkout'>;
|
|
17
|
-
type DeliveryOption = Schema<'DeliveryOption'>;
|
|
18
|
-
type Location = Schema<'Location'>;
|
|
19
|
-
type Completion = Schema<'CheckoutCompletion'>;
|
|
20
|
-
|
|
21
16
|
/**
|
|
22
17
|
* One page, filled in the order the checkout's `missing` lists: contact → delivery (pickup point,
|
|
23
|
-
* or an address and a delivery option) → payment method → place the order.
|
|
24
|
-
*
|
|
18
|
+
* or an address and a delivery option) → payment method → place the order. Steps on the left, the
|
|
19
|
+
* order on the right (a collapsible bar with the total on phones). The server prices everything;
|
|
20
|
+
* this page only shows what it answers.
|
|
25
21
|
*/
|
|
26
22
|
export default function CheckoutPage() {
|
|
27
|
-
const client = useStorefrontClient();
|
|
28
|
-
const { cart, status: cartStatus } = useCart();
|
|
29
|
-
const { customer } = useCustomer();
|
|
30
23
|
const t = messages(useMagicStore().locale);
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const [options, setOptions] = useState<DeliveryOption[]>([]);
|
|
34
|
-
const [completion, setCompletion] = useState<Completion | null>(null);
|
|
35
|
-
const [error, setError] = useState<string | null>(null);
|
|
36
|
-
const [busy, setBusy] = useState(false);
|
|
37
|
-
|
|
38
|
-
const run = useCallback(
|
|
39
|
-
async (step: () => Promise<{ data: Checkout } | void>) => {
|
|
40
|
-
setBusy(true);
|
|
41
|
-
setError(null);
|
|
42
|
-
try {
|
|
43
|
-
const result = await step();
|
|
44
|
-
if (result) {
|
|
45
|
-
setCheckout(result.data);
|
|
46
|
-
}
|
|
47
|
-
} catch (e) {
|
|
48
|
-
setError(describe(e, t.tryAgainLater));
|
|
49
|
-
} finally {
|
|
50
|
-
setBusy(false);
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
[t],
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
if (cart === null || checkout !== null) {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
void run(() => client.checkoutsStore({ body: { cartId: cart.id } }));
|
|
61
|
-
void client.locationsIndex({ query: { perPage: 50 } }).then(
|
|
62
|
-
({ data }) => setLocations(data),
|
|
63
|
-
() => undefined,
|
|
64
|
-
);
|
|
65
|
-
}, [cart, checkout, client, run]);
|
|
66
|
-
|
|
67
|
-
const loadOptions = useCallback(
|
|
68
|
-
async (id: string) =>
|
|
69
|
-
setOptions((await client.checkoutsDeliveryOptions({ path: { id } })).data),
|
|
70
|
-
[client],
|
|
71
|
-
);
|
|
24
|
+
const state = useCheckout(t.tryAgainLater);
|
|
25
|
+
const { client, cart, cartStatus, checkout, completion, setCompletion, error, busy, run } = state;
|
|
72
26
|
|
|
73
27
|
if (completion) {
|
|
28
|
+
return <ThankYou completion={completion} t={t} />;
|
|
29
|
+
}
|
|
30
|
+
if (
|
|
31
|
+
cartStatus === 'loading' ||
|
|
32
|
+
(cart !== null && cart.lines.length > 0 && checkout === null && !error)
|
|
33
|
+
) {
|
|
74
34
|
return (
|
|
75
|
-
<div className="
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
{t.order} <strong>{completion.order.number}</strong> —{' '}
|
|
79
|
-
<Money data={completion.order.total} />
|
|
35
|
+
<div className="checkout" aria-busy="true">
|
|
36
|
+
<p className="visually-hidden" role="status">
|
|
37
|
+
{cartStatus === 'loading' ? t.loadingCart : t.preparingCheckout}
|
|
80
38
|
</p>
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
39
|
+
<div className="checkout__main stack">
|
|
40
|
+
<Skeleton height="2.5rem" width="60%" />
|
|
41
|
+
<Skeleton height="10rem" />
|
|
42
|
+
<Skeleton height="14rem" />
|
|
43
|
+
</div>
|
|
44
|
+
<Skeleton height="16rem" />
|
|
87
45
|
</div>
|
|
88
46
|
);
|
|
89
47
|
}
|
|
90
|
-
if (cartStatus === 'loading') {
|
|
91
|
-
return <p className="muted">{t.loadingCart}</p>;
|
|
92
|
-
}
|
|
93
48
|
if (cart === null || cart.lines.length === 0) {
|
|
94
49
|
return (
|
|
95
|
-
<
|
|
96
|
-
{t.cartEmpty}
|
|
97
|
-
|
|
50
|
+
<EmptyState
|
|
51
|
+
title={t.cartEmpty}
|
|
52
|
+
icon="cart"
|
|
53
|
+
action={
|
|
54
|
+
<Link href="/collections" className={buttonClass({ variant: 'primary' })}>
|
|
55
|
+
{t.shopNow}
|
|
56
|
+
</Link>
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
{t.cartEmptyHint}
|
|
60
|
+
</EmptyState>
|
|
98
61
|
);
|
|
99
62
|
}
|
|
100
63
|
if (checkout === null) {
|
|
101
|
-
return
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
64
|
+
return (
|
|
65
|
+
<EmptyState
|
|
66
|
+
title={t.somethingWentWrong}
|
|
67
|
+
action={
|
|
68
|
+
<Link href="/cart" className={buttonClass()}>
|
|
69
|
+
{t.backToCart}
|
|
70
|
+
</Link>
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
<span role="alert">{error}</span>
|
|
74
|
+
</EmptyState>
|
|
105
75
|
);
|
|
106
76
|
}
|
|
107
77
|
|
|
108
|
-
const
|
|
109
|
-
const missing = new Set(checkout.missing);
|
|
78
|
+
const ready = { ...state, checkout };
|
|
110
79
|
|
|
111
80
|
return (
|
|
112
|
-
<div className="
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}),
|
|
125
|
-
);
|
|
126
|
-
}}
|
|
127
|
-
>
|
|
128
|
-
<fieldset className="stack">
|
|
129
|
-
<legend>
|
|
130
|
-
{t.contact} {missing.has('CONTACT') ? '' : '✓'}
|
|
131
|
-
</legend>
|
|
132
|
-
<input
|
|
133
|
-
name="name"
|
|
134
|
-
required
|
|
135
|
-
placeholder={t.name}
|
|
136
|
-
aria-label={t.name}
|
|
137
|
-
autoComplete="name"
|
|
138
|
-
defaultValue={checkout.contact?.name ?? customer?.name ?? ''}
|
|
139
|
-
/>
|
|
140
|
-
<input
|
|
141
|
-
name="phone"
|
|
142
|
-
required
|
|
143
|
-
placeholder="+998 90 123 45 67"
|
|
144
|
-
aria-label={t.phone}
|
|
145
|
-
type="tel"
|
|
146
|
-
autoComplete="tel"
|
|
147
|
-
defaultValue={checkout.contact?.phone ?? customer?.phone ?? ''}
|
|
148
|
-
/>
|
|
149
|
-
<button disabled={busy}>{t.saveContact}</button>
|
|
150
|
-
</fieldset>
|
|
151
|
-
</form>
|
|
81
|
+
<div className="checkout">
|
|
82
|
+
<details className="checkout__summary-toggle">
|
|
83
|
+
<summary>
|
|
84
|
+
<span className="checkout__summary-label">
|
|
85
|
+
<Icon name="cart" size={18} />
|
|
86
|
+
{t.showOrderSummary}
|
|
87
|
+
<Icon name="chevron-down" size={16} className="checkout__summary-chevron" />
|
|
88
|
+
</span>
|
|
89
|
+
<strong>{checkout.cost.total && <Money data={checkout.cost.total} />}</strong>
|
|
90
|
+
</summary>
|
|
91
|
+
<OrderSummary checkout={checkout} t={t} />
|
|
92
|
+
</details>
|
|
152
93
|
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
{
|
|
162
|
-
|
|
163
|
-
<input
|
|
164
|
-
type="radio"
|
|
165
|
-
name="pickup"
|
|
166
|
-
checked={checkout.pickupLocation?.id === location.id}
|
|
167
|
-
onChange={() =>
|
|
168
|
-
void run(() =>
|
|
169
|
-
client.checkoutsPickupLocation({
|
|
170
|
-
path: { id },
|
|
171
|
-
body: { locationId: location.id },
|
|
172
|
-
}),
|
|
173
|
-
)
|
|
174
|
-
}
|
|
175
|
-
/>
|
|
176
|
-
{location.name}{' '}
|
|
177
|
-
{location.address && <span className="muted">— {location.address}</span>}
|
|
178
|
-
</label>
|
|
179
|
-
))}
|
|
180
|
-
</div>
|
|
94
|
+
<div className="checkout__main">
|
|
95
|
+
<h1 className="page-title">{t.checkoutTitle}</h1>
|
|
96
|
+
<StepIndicator missing={checkout.missing} t={t} />
|
|
97
|
+
<ContactStep state={ready} t={t} />
|
|
98
|
+
<DeliveryStep state={ready} t={t} />
|
|
99
|
+
<PaymentStep state={ready} t={t} />
|
|
100
|
+
{error && (
|
|
101
|
+
<p className="error" role="alert">
|
|
102
|
+
{error}
|
|
103
|
+
</p>
|
|
181
104
|
)}
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
105
|
+
<Button
|
|
106
|
+
variant="primary"
|
|
107
|
+
size="lg"
|
|
108
|
+
full
|
|
109
|
+
className="checkout__place"
|
|
110
|
+
disabled={!checkout.readyForCompletion}
|
|
111
|
+
loading={busy}
|
|
112
|
+
onClick={() =>
|
|
187
113
|
void run(async () => {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
address2: null,
|
|
193
|
-
city: String(form.get('city') || '') || null,
|
|
194
|
-
latitude: Number(form.get('latitude')),
|
|
195
|
-
longitude: Number(form.get('longitude')),
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
await loadOptions(id);
|
|
199
|
-
return answer;
|
|
200
|
-
});
|
|
201
|
-
}}
|
|
114
|
+
const { data } = await client.checkoutsCompletion({ path: { id: checkout.id } });
|
|
115
|
+
setCompletion(data);
|
|
116
|
+
})
|
|
117
|
+
}
|
|
202
118
|
>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
<input
|
|
211
|
-
name="city"
|
|
212
|
-
placeholder={t.city}
|
|
213
|
-
aria-label={t.city}
|
|
214
|
-
autoComplete="address-level2"
|
|
215
|
-
/>
|
|
216
|
-
<div className="row">
|
|
217
|
-
<input
|
|
218
|
-
name="latitude"
|
|
219
|
-
required
|
|
220
|
-
inputMode="decimal"
|
|
221
|
-
placeholder={t.latitude}
|
|
222
|
-
aria-label={t.latitude}
|
|
223
|
-
/>
|
|
224
|
-
<input
|
|
225
|
-
name="longitude"
|
|
226
|
-
required
|
|
227
|
-
inputMode="decimal"
|
|
228
|
-
placeholder={t.longitude}
|
|
229
|
-
aria-label={t.longitude}
|
|
230
|
-
/>
|
|
231
|
-
<button type="button" onClick={(event) => fillMyLocation(event.currentTarget.form)}>
|
|
232
|
-
{t.useMyLocation}
|
|
233
|
-
</button>
|
|
234
|
-
</div>
|
|
235
|
-
<button disabled={busy}>{t.showDeliveryOptions}</button>
|
|
236
|
-
</form>
|
|
237
|
-
{options.map((option) => (
|
|
238
|
-
<label key={option.id} className="row">
|
|
239
|
-
<input
|
|
240
|
-
type="radio"
|
|
241
|
-
name="delivery"
|
|
242
|
-
disabled={!option.available}
|
|
243
|
-
checked={checkout.selectedDeliveryOption?.id === option.id}
|
|
244
|
-
onChange={() =>
|
|
245
|
-
void run(() =>
|
|
246
|
-
client.checkoutsDeliveryOption({
|
|
247
|
-
path: { id },
|
|
248
|
-
body: { deliveryOptionId: option.id },
|
|
249
|
-
}),
|
|
250
|
-
)
|
|
251
|
-
}
|
|
252
|
-
/>
|
|
253
|
-
{option.title} — <Money data={option.price} fallback={t.free} />
|
|
254
|
-
</label>
|
|
255
|
-
))}
|
|
256
|
-
</fieldset>
|
|
257
|
-
|
|
258
|
-
<fieldset className="stack">
|
|
259
|
-
<legend>
|
|
260
|
-
{t.payment} {missing.has('PAYMENT_METHOD') ? '' : '✓'}
|
|
261
|
-
</legend>
|
|
262
|
-
{checkout.paymentMethods.map((method) => (
|
|
263
|
-
<label key={method.id} className="row">
|
|
264
|
-
<input
|
|
265
|
-
type="radio"
|
|
266
|
-
name="payment"
|
|
267
|
-
checked={checkout.selectedPaymentMethod?.id === method.id}
|
|
268
|
-
onChange={() =>
|
|
269
|
-
void run(() =>
|
|
270
|
-
client.checkoutsPaymentMethod({
|
|
271
|
-
path: { id },
|
|
272
|
-
body: { paymentMethodId: method.id },
|
|
273
|
-
}),
|
|
274
|
-
)
|
|
275
|
-
}
|
|
276
|
-
/>
|
|
277
|
-
{method.title}
|
|
278
|
-
</label>
|
|
279
|
-
))}
|
|
280
|
-
</fieldset>
|
|
119
|
+
{t.placeOrder}
|
|
120
|
+
</Button>
|
|
121
|
+
<Link href="/cart" className="checkout__back">
|
|
122
|
+
<Icon name="chevron-left" size={16} />
|
|
123
|
+
{t.backToCart}
|
|
124
|
+
</Link>
|
|
125
|
+
</div>
|
|
281
126
|
|
|
282
|
-
<
|
|
283
|
-
{t.
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
</strong>
|
|
287
|
-
</p>
|
|
288
|
-
{error && <p className="error">{error}</p>}
|
|
289
|
-
<button
|
|
290
|
-
className="primary"
|
|
291
|
-
disabled={busy || !checkout.readyForCompletion}
|
|
292
|
-
onClick={() =>
|
|
293
|
-
void run(async () => {
|
|
294
|
-
const { data } = await client.checkoutsCompletion({ path: { id } });
|
|
295
|
-
setCompletion(data);
|
|
296
|
-
})
|
|
297
|
-
}
|
|
298
|
-
>
|
|
299
|
-
{t.placeOrder}
|
|
300
|
-
</button>
|
|
127
|
+
<aside className="checkout__aside" aria-label={t.summary}>
|
|
128
|
+
<h2 className="checkout__aside-title">{t.summary}</h2>
|
|
129
|
+
<OrderSummary checkout={checkout} t={t} />
|
|
130
|
+
</aside>
|
|
301
131
|
</div>
|
|
302
132
|
);
|
|
303
133
|
}
|
|
304
|
-
|
|
305
|
-
function fillMyLocation(form: HTMLFormElement | null): void {
|
|
306
|
-
navigator.geolocation?.getCurrentPosition((position) => {
|
|
307
|
-
const set = (name: string, value: number) => {
|
|
308
|
-
const input = form?.elements.namedItem(name);
|
|
309
|
-
if (input instanceof HTMLInputElement) {
|
|
310
|
-
input.value = value.toFixed(6);
|
|
311
|
-
}
|
|
312
|
-
};
|
|
313
|
-
set('latitude', position.coords.latitude);
|
|
314
|
-
set('longitude', position.coords.longitude);
|
|
315
|
-
});
|
|
316
|
-
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { api, orNotFound } from '@/lib/api';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The collection must exist before the page streams: `loading.tsx` below sends the headers, so a
|
|
7
|
+
* `notFound()` in the page would answer 200. Checked here, a missing handle is a real 404. The page
|
|
8
|
+
* reads the same request from the cache.
|
|
9
|
+
*/
|
|
10
|
+
export default async function CollectionLayout({
|
|
11
|
+
children,
|
|
12
|
+
params,
|
|
13
|
+
}: {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
params: Promise<{ handle: string }>;
|
|
16
|
+
}) {
|
|
17
|
+
const { handle } = await params;
|
|
18
|
+
await orNotFound(api.collectionsShow({ path: { handle } }));
|
|
19
|
+
return children;
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PageLoading } from '@/components/page-loading';
|
|
2
|
+
import { ProductGridSkeleton, Skeleton, TitleSkeleton } from '@/components/ui/skeleton';
|
|
3
|
+
|
|
4
|
+
export default function CollectionLoading() {
|
|
5
|
+
return (
|
|
6
|
+
<PageLoading>
|
|
7
|
+
<Skeleton height="1rem" width="12rem" className="breadcrumbs" />
|
|
8
|
+
<TitleSkeleton />
|
|
9
|
+
<div className="catalog catalog--with-facets">
|
|
10
|
+
<div className="catalog__facets">
|
|
11
|
+
<Skeleton height="20rem" />
|
|
12
|
+
</div>
|
|
13
|
+
<div className="catalog__main">
|
|
14
|
+
<div className="catalog__toolbar">
|
|
15
|
+
<Skeleton height="1rem" width="6rem" />
|
|
16
|
+
<Skeleton height="2.5rem" width="12rem" />
|
|
17
|
+
</div>
|
|
18
|
+
<ProductGridSkeleton />
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</PageLoading>
|
|
22
|
+
);
|
|
23
|
+
}
|