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,216 @@
|
|
|
1
|
+
import { formatMoney, type MoneyFormat } from '@magicstoreai/hydrogen/core';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
|
|
4
|
+
import { Accordion } from '@/components/ui/accordion';
|
|
5
|
+
import {
|
|
6
|
+
PRICE_MAX,
|
|
7
|
+
PRICE_MIN,
|
|
8
|
+
catalogQuery,
|
|
9
|
+
cleared,
|
|
10
|
+
filterParam,
|
|
11
|
+
hasFilters,
|
|
12
|
+
without,
|
|
13
|
+
type Catalog,
|
|
14
|
+
type Filter,
|
|
15
|
+
} from '@/lib/catalog-params';
|
|
16
|
+
import type { Messages } from '@/lib/i18n';
|
|
17
|
+
|
|
18
|
+
import { CatalogForm } from './catalog-form';
|
|
19
|
+
import { FacetsShell } from './facets-shell';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The facets of a catalog page, from the filters endpoint (`collectionsFilters`, `searchFilters`):
|
|
23
|
+
* LIST as checkboxes with counts, PRICE_RANGE as two number fields, BOOLEAN as one checkbox, each in
|
|
24
|
+
* a collapsible group, open when it has a choice. `hidden` carries what the URL keeps besides
|
|
25
|
+
* the filters (the search query). Without filters only the (empty) form renders, for the sort.
|
|
26
|
+
*/
|
|
27
|
+
export function Facets({
|
|
28
|
+
filters,
|
|
29
|
+
catalog,
|
|
30
|
+
formId,
|
|
31
|
+
hidden = {},
|
|
32
|
+
t,
|
|
33
|
+
}: {
|
|
34
|
+
filters: Filter[];
|
|
35
|
+
catalog: Catalog;
|
|
36
|
+
formId: string;
|
|
37
|
+
hidden?: Record<string, string>;
|
|
38
|
+
t: Messages;
|
|
39
|
+
}) {
|
|
40
|
+
const hiddenFields = Object.entries(hidden).map(([name, value]) => (
|
|
41
|
+
<input key={name} type="hidden" name={name} value={value} />
|
|
42
|
+
));
|
|
43
|
+
if (filters.length === 0) {
|
|
44
|
+
// No facets, but the sort select still submits through this form.
|
|
45
|
+
return <CatalogForm id={formId}>{hiddenFields}</CatalogForm>;
|
|
46
|
+
}
|
|
47
|
+
const activeCount =
|
|
48
|
+
Object.values(catalog.selected).reduce((sum, values) => sum + values.length, 0) +
|
|
49
|
+
(catalog.price.min !== undefined || catalog.price.max !== undefined ? 1 : 0);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<FacetsShell activeCount={activeCount} formId={formId}>
|
|
53
|
+
<CatalogForm id={formId} className="facets">
|
|
54
|
+
{hiddenFields}
|
|
55
|
+
{filters.map((filter) => {
|
|
56
|
+
if (filter.type === 'PRICE_RANGE') {
|
|
57
|
+
const chosen = catalog.price.min !== undefined || catalog.price.max !== undefined;
|
|
58
|
+
return (
|
|
59
|
+
<Accordion key={filter.id} title={filter.label} open={chosen} className="facet">
|
|
60
|
+
<div className="facet__price">
|
|
61
|
+
<label className="field">
|
|
62
|
+
<span className="field__label">{t.priceFrom}</span>
|
|
63
|
+
<input
|
|
64
|
+
type="number"
|
|
65
|
+
inputMode="decimal"
|
|
66
|
+
name={PRICE_MIN}
|
|
67
|
+
min={0}
|
|
68
|
+
defaultValue={catalog.price.min}
|
|
69
|
+
placeholder={filter.min?.amount.replace(/\.0+$/, '')}
|
|
70
|
+
/>
|
|
71
|
+
</label>
|
|
72
|
+
<label className="field">
|
|
73
|
+
<span className="field__label">{t.priceTo}</span>
|
|
74
|
+
<input
|
|
75
|
+
type="number"
|
|
76
|
+
inputMode="decimal"
|
|
77
|
+
name={PRICE_MAX}
|
|
78
|
+
min={0}
|
|
79
|
+
defaultValue={catalog.price.max}
|
|
80
|
+
placeholder={filter.max?.amount.replace(/\.0+$/, '')}
|
|
81
|
+
/>
|
|
82
|
+
</label>
|
|
83
|
+
</div>
|
|
84
|
+
</Accordion>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const chosen = catalog.selected[filter.id] ?? [];
|
|
88
|
+
if (filter.type === 'BOOLEAN') {
|
|
89
|
+
return (
|
|
90
|
+
<label key={filter.id} className="facet facet--boolean choice">
|
|
91
|
+
<input
|
|
92
|
+
type="checkbox"
|
|
93
|
+
name={filterParam(filter.id)}
|
|
94
|
+
value="true"
|
|
95
|
+
defaultChecked={chosen.length > 0}
|
|
96
|
+
/>
|
|
97
|
+
{filter.label}
|
|
98
|
+
</label>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (filter.values.length === 0) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return (
|
|
105
|
+
<Accordion
|
|
106
|
+
key={filter.id}
|
|
107
|
+
title={filter.label}
|
|
108
|
+
open={chosen.length > 0}
|
|
109
|
+
className="facet"
|
|
110
|
+
>
|
|
111
|
+
<fieldset className="facet__values">
|
|
112
|
+
<legend className="visually-hidden">{filter.label}</legend>
|
|
113
|
+
{filter.values.map((option) => (
|
|
114
|
+
<label key={option.value} className="choice">
|
|
115
|
+
<input
|
|
116
|
+
type="checkbox"
|
|
117
|
+
name={filterParam(filter.id)}
|
|
118
|
+
value={option.value}
|
|
119
|
+
defaultChecked={chosen.includes(option.value)}
|
|
120
|
+
/>
|
|
121
|
+
<span>{option.label}</span>
|
|
122
|
+
<span className="facet__count">{option.count}</span>
|
|
123
|
+
</label>
|
|
124
|
+
))}
|
|
125
|
+
</fieldset>
|
|
126
|
+
</Accordion>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
<button type="submit" className="button button--primary facets__apply">
|
|
130
|
+
{t.showResults}
|
|
131
|
+
</button>
|
|
132
|
+
</CatalogForm>
|
|
133
|
+
</FacetsShell>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The chosen filters as removable chips (plain links, so they work without JavaScript), and
|
|
139
|
+
* "Clear all". Price bounds are formatted as the shop formats money.
|
|
140
|
+
*/
|
|
141
|
+
export function ActiveFilters({
|
|
142
|
+
filters,
|
|
143
|
+
catalog,
|
|
144
|
+
path,
|
|
145
|
+
extra = {},
|
|
146
|
+
money,
|
|
147
|
+
t,
|
|
148
|
+
}: {
|
|
149
|
+
filters: Filter[];
|
|
150
|
+
catalog: Catalog;
|
|
151
|
+
path: string;
|
|
152
|
+
extra?: Record<string, string>;
|
|
153
|
+
money: { locale: string; format: MoneyFormat | null };
|
|
154
|
+
t: Messages;
|
|
155
|
+
}) {
|
|
156
|
+
if (!hasFilters(catalog)) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const href = (next: Catalog) => `${path}${catalogQuery(next, extra)}`;
|
|
160
|
+
const chips: { key: string; label: string; href: string }[] = [];
|
|
161
|
+
for (const filter of filters) {
|
|
162
|
+
if (filter.type === 'PRICE_RANGE') {
|
|
163
|
+
const { min, max } = catalog.price;
|
|
164
|
+
if (min === undefined && max === undefined) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const currencyCode = filter.min?.currencyCode ?? filter.max?.currencyCode ?? '';
|
|
168
|
+
const amount = (value: number) =>
|
|
169
|
+
formatMoney(
|
|
170
|
+
{ amount: String(value), currencyCode },
|
|
171
|
+
{ locale: money.locale, format: money.format },
|
|
172
|
+
);
|
|
173
|
+
const range = [
|
|
174
|
+
min !== undefined ? `${t.priceFrom} ${amount(min)}` : null,
|
|
175
|
+
max !== undefined ? `${t.priceTo} ${amount(max)}` : null,
|
|
176
|
+
]
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.join(' ');
|
|
179
|
+
chips.push({
|
|
180
|
+
key: 'price',
|
|
181
|
+
label: `${filter.label}: ${range}`,
|
|
182
|
+
href: href(without(catalog, 'price')),
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
for (const value of catalog.selected[filter.id] ?? []) {
|
|
187
|
+
const label =
|
|
188
|
+
filter.type === 'BOOLEAN'
|
|
189
|
+
? filter.label
|
|
190
|
+
: `${filter.values.find((option) => option.value === value)?.label ?? value}`;
|
|
191
|
+
chips.push({
|
|
192
|
+
key: `${filter.id}:${value}`,
|
|
193
|
+
label,
|
|
194
|
+
href: href(without(catalog, filter.id, value)),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className="active-filters">
|
|
201
|
+
<ul className="chips">
|
|
202
|
+
{chips.map((chip) => (
|
|
203
|
+
<li key={chip.key}>
|
|
204
|
+
<Link href={chip.href} scroll={false} aria-label={t.removeFilter(chip.label)}>
|
|
205
|
+
{chip.label}
|
|
206
|
+
<span aria-hidden="true">×</span>
|
|
207
|
+
</Link>
|
|
208
|
+
</li>
|
|
209
|
+
))}
|
|
210
|
+
</ul>
|
|
211
|
+
<Link href={href(cleared(catalog))} scroll={false} className="active-filters__clear">
|
|
212
|
+
{t.clearAll}
|
|
213
|
+
</Link>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Image, Money, moneyAmount } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
|
|
6
|
+
import type { Messages } from '@/lib/i18n';
|
|
7
|
+
|
|
8
|
+
import type { Checkout } from './use-checkout';
|
|
9
|
+
|
|
10
|
+
type MoneyValue = Schema<'Money'>;
|
|
11
|
+
|
|
12
|
+
/** The order as the checkout prices it: its lines, then the amounts from `checkout.cost`. */
|
|
13
|
+
export function OrderSummary({ checkout, t }: { checkout: Checkout; t: Messages }) {
|
|
14
|
+
const { cost } = checkout;
|
|
15
|
+
return (
|
|
16
|
+
<div className="order-summary">
|
|
17
|
+
<ul className="order-summary__lines">
|
|
18
|
+
{checkout.cart.lines.map((line) => (
|
|
19
|
+
<li key={line.id} className="order-summary__line">
|
|
20
|
+
<span className="order-summary__media">
|
|
21
|
+
<Image
|
|
22
|
+
data={line.merchandise.image}
|
|
23
|
+
width={56}
|
|
24
|
+
height={56}
|
|
25
|
+
alt=""
|
|
26
|
+
fallback={<span className="media-fallback" />}
|
|
27
|
+
/>
|
|
28
|
+
<span className="order-summary__quantity">{line.quantity}</span>
|
|
29
|
+
</span>
|
|
30
|
+
<span className="order-summary__name">
|
|
31
|
+
{line.merchandise.product?.title}
|
|
32
|
+
{line.merchandise.variant?.title &&
|
|
33
|
+
!/^Default( Title)?$/.test(line.merchandise.variant.title) && (
|
|
34
|
+
<span className="muted text-sm">{line.merchandise.variant.title}</span>
|
|
35
|
+
)}
|
|
36
|
+
</span>
|
|
37
|
+
<Money data={line.cost.total} className="order-summary__price" />
|
|
38
|
+
</li>
|
|
39
|
+
))}
|
|
40
|
+
</ul>
|
|
41
|
+
<dl className="order-summary__rows">
|
|
42
|
+
<Row label={t.subtotal} value={cost.subtotal} />
|
|
43
|
+
<Row label={t.discount} value={cost.discountTotal} negative />
|
|
44
|
+
<Row label={t.welcomeDiscount} value={cost.welcomeDiscount} negative />
|
|
45
|
+
<Row label={t.pointsDiscount} value={cost.pointsDiscount} negative />
|
|
46
|
+
{checkout.selectedDeliveryOption && (
|
|
47
|
+
<div className="order-summary__row">
|
|
48
|
+
<dt>{t.delivery}</dt>
|
|
49
|
+
<dd>
|
|
50
|
+
<Money data={cost.deliveryTotal} fallback={t.free} />
|
|
51
|
+
</dd>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
<div className="order-summary__row order-summary__row--total">
|
|
55
|
+
<dt>{t.total}</dt>
|
|
56
|
+
<dd>{cost.total && <Money data={cost.total} />}</dd>
|
|
57
|
+
</div>
|
|
58
|
+
</dl>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Row({
|
|
64
|
+
label,
|
|
65
|
+
value,
|
|
66
|
+
negative = false,
|
|
67
|
+
}: {
|
|
68
|
+
label: string;
|
|
69
|
+
value: MoneyValue | null;
|
|
70
|
+
negative?: boolean;
|
|
71
|
+
}) {
|
|
72
|
+
if (value === null || (negative && moneyAmount(value) <= 0)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<div className="order-summary__row">
|
|
77
|
+
<dt>{label}</dt>
|
|
78
|
+
<dd>
|
|
79
|
+
{negative && '−'}
|
|
80
|
+
<Money data={value} />
|
|
81
|
+
</dd>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One choice of a group (delivery option, pickup point, payment method) as a card: a native radio
|
|
5
|
+
* inside a label, so arrows move between cards and the whole card is the target.
|
|
6
|
+
*/
|
|
7
|
+
export function RadioCard({
|
|
8
|
+
name,
|
|
9
|
+
checked,
|
|
10
|
+
disabled = false,
|
|
11
|
+
onSelect,
|
|
12
|
+
title,
|
|
13
|
+
aside,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
name: string;
|
|
17
|
+
checked: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
onSelect: () => void;
|
|
20
|
+
title: ReactNode;
|
|
21
|
+
aside?: ReactNode;
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<label className={disabled ? 'radio-card radio-card--disabled' : 'radio-card'}>
|
|
26
|
+
<input type="radio" name={name} checked={checked} disabled={disabled} onChange={onSelect} />
|
|
27
|
+
<span className="radio-card__body">
|
|
28
|
+
<span className="radio-card__title">{title}</span>
|
|
29
|
+
{children && <span className="radio-card__detail">{children}</span>}
|
|
30
|
+
</span>
|
|
31
|
+
{aside && <span className="radio-card__aside">{aside}</span>}
|
|
32
|
+
</label>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Messages } from '@/lib/i18n';
|
|
2
|
+
|
|
3
|
+
import type { Checkout } from './use-checkout';
|
|
4
|
+
|
|
5
|
+
export type Step = 'contact' | 'delivery' | 'payment';
|
|
6
|
+
|
|
7
|
+
/** Which step a `missing` entry belongs to. */
|
|
8
|
+
const STEP_OF: Record<Checkout['missing'][number], Step> = {
|
|
9
|
+
CONTACT: 'contact',
|
|
10
|
+
DELIVERY_ADDRESS: 'delivery',
|
|
11
|
+
DELIVERY_OPTION: 'delivery',
|
|
12
|
+
PAYMENT_METHOD: 'payment',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const STEPS: Step[] = ['contact', 'delivery', 'payment'];
|
|
16
|
+
|
|
17
|
+
/** The step to fill next: the first one `missing` names; none once the checkout is ready. */
|
|
18
|
+
export function currentStep(missing: Checkout['missing']): Step | null {
|
|
19
|
+
return missing.length > 0 ? STEP_OF[missing[0]!] : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isDone(step: Step, missing: Checkout['missing']): boolean {
|
|
23
|
+
return !missing.some((entry) => STEP_OF[entry] === step);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Contact → delivery → payment, each marked done from `checkout.missing`. */
|
|
27
|
+
export function StepIndicator({ missing, t }: { missing: Checkout['missing']; t: Messages }) {
|
|
28
|
+
const current = currentStep(missing);
|
|
29
|
+
const labels: Record<Step, string> = {
|
|
30
|
+
contact: t.contact,
|
|
31
|
+
delivery: t.delivery,
|
|
32
|
+
payment: t.payment,
|
|
33
|
+
};
|
|
34
|
+
return (
|
|
35
|
+
<ol className="checkout-steps" aria-label={t.checkoutSteps}>
|
|
36
|
+
{STEPS.map((step, index) => {
|
|
37
|
+
const done = isDone(step, missing);
|
|
38
|
+
return (
|
|
39
|
+
<li
|
|
40
|
+
key={step}
|
|
41
|
+
className={
|
|
42
|
+
done ? 'checkout-steps__step checkout-steps__step--done' : 'checkout-steps__step'
|
|
43
|
+
}
|
|
44
|
+
aria-current={step === current ? 'step' : undefined}
|
|
45
|
+
>
|
|
46
|
+
<span className="checkout-steps__number" aria-hidden="true">
|
|
47
|
+
{index + 1}
|
|
48
|
+
</span>
|
|
49
|
+
{labels[step]}
|
|
50
|
+
{done && <span className="visually-hidden"> ({t.stepDone})</span>}
|
|
51
|
+
</li>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</ol>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Money, useCustomer } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { Icon } from '@/components/ui/icon';
|
|
8
|
+
import type { Messages } from '@/lib/i18n';
|
|
9
|
+
|
|
10
|
+
import { Field } from '@/components/ui/field';
|
|
11
|
+
import { RadioCard } from './radio-card';
|
|
12
|
+
import { isDone, type Step } from './step-indicator';
|
|
13
|
+
import type { useCheckout } from './use-checkout';
|
|
14
|
+
|
|
15
|
+
type State = ReturnType<typeof useCheckout>;
|
|
16
|
+
type Ready = State & { checkout: NonNullable<State['checkout']> };
|
|
17
|
+
|
|
18
|
+
/** A numbered step: a heading with its done mark, then its fields. */
|
|
19
|
+
function Section({
|
|
20
|
+
step,
|
|
21
|
+
index,
|
|
22
|
+
title,
|
|
23
|
+
done,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
step: Step;
|
|
27
|
+
index: number;
|
|
28
|
+
title: string;
|
|
29
|
+
done: boolean;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
return (
|
|
33
|
+
<section className="checkout-section" aria-labelledby={`checkout-${step}`}>
|
|
34
|
+
<h2 id={`checkout-${step}`} className="checkout-section__title">
|
|
35
|
+
<span className="checkout-section__number" aria-hidden="true">
|
|
36
|
+
{done ? <Icon name="check" size={16} /> : index}
|
|
37
|
+
</span>
|
|
38
|
+
{title}
|
|
39
|
+
</h2>
|
|
40
|
+
{children}
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Contact: name and phone (`checkoutsContact`), prefilled from the checkout or the customer. */
|
|
46
|
+
export function ContactStep({ state, t }: { state: Ready; t: Messages }) {
|
|
47
|
+
const { client, checkout, run, busy, fieldErrors } = state;
|
|
48
|
+
const { customer } = useCustomer();
|
|
49
|
+
const done = isDone('contact', checkout.missing);
|
|
50
|
+
return (
|
|
51
|
+
<Section step="contact" index={1} title={t.contact} done={done}>
|
|
52
|
+
<form
|
|
53
|
+
className="checkout-form"
|
|
54
|
+
onSubmit={(event) => {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
const form = new FormData(event.currentTarget);
|
|
57
|
+
void run(() =>
|
|
58
|
+
client.checkoutsContact({
|
|
59
|
+
path: { id: checkout.id },
|
|
60
|
+
body: { name: String(form.get('name')), phone: String(form.get('phone')) },
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<div className="checkout-form__row">
|
|
66
|
+
<Field
|
|
67
|
+
label={t.name}
|
|
68
|
+
name="name"
|
|
69
|
+
required
|
|
70
|
+
autoComplete="name"
|
|
71
|
+
defaultValue={checkout.contact?.name ?? customer?.name ?? ''}
|
|
72
|
+
error={fieldErrors.name}
|
|
73
|
+
/>
|
|
74
|
+
<Field
|
|
75
|
+
label={t.phone}
|
|
76
|
+
name="phone"
|
|
77
|
+
required
|
|
78
|
+
type="tel"
|
|
79
|
+
autoComplete="tel"
|
|
80
|
+
placeholder="+998 90 123 45 67"
|
|
81
|
+
defaultValue={checkout.contact?.phone ?? customer?.phone ?? ''}
|
|
82
|
+
error={fieldErrors.phone}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
<Button type="submit" variant={done ? 'secondary' : 'primary'} loading={busy}>
|
|
86
|
+
{t.saveContact}
|
|
87
|
+
</Button>
|
|
88
|
+
</form>
|
|
89
|
+
</Section>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Delivery: a pickup point (`checkoutsPickupLocation`), or an address (`checkoutsShippingAddress`)
|
|
95
|
+
* and then one of the options the API offers for it (`checkoutsDeliveryOption`).
|
|
96
|
+
*/
|
|
97
|
+
export function DeliveryStep({ state, t }: { state: Ready; t: Messages }) {
|
|
98
|
+
const { client, checkout, run, busy, fieldErrors, locations, options, loadOptions } = state;
|
|
99
|
+
const id = checkout.id;
|
|
100
|
+
const done = isDone('delivery', checkout.missing);
|
|
101
|
+
const needsAddress = checkout.missing.includes('DELIVERY_ADDRESS');
|
|
102
|
+
return (
|
|
103
|
+
<Section step="delivery" index={2} title={t.delivery} done={done}>
|
|
104
|
+
{locations.length > 0 && (
|
|
105
|
+
<fieldset className="checkout-choices">
|
|
106
|
+
<legend className="checkout-choices__legend">{t.pickUp}</legend>
|
|
107
|
+
{locations.map((location) => (
|
|
108
|
+
<RadioCard
|
|
109
|
+
key={location.id}
|
|
110
|
+
name="pickup"
|
|
111
|
+
checked={checkout.pickupLocation?.id === location.id}
|
|
112
|
+
onSelect={() =>
|
|
113
|
+
void run(() =>
|
|
114
|
+
client.checkoutsPickupLocation({
|
|
115
|
+
path: { id },
|
|
116
|
+
body: { locationId: location.id },
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
title={location.name}
|
|
121
|
+
>
|
|
122
|
+
{[location.address, location.hours].filter(Boolean).join(' · ')}
|
|
123
|
+
</RadioCard>
|
|
124
|
+
))}
|
|
125
|
+
</fieldset>
|
|
126
|
+
)}
|
|
127
|
+
<form
|
|
128
|
+
className="checkout-form"
|
|
129
|
+
onSubmit={(event) => {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
const form = new FormData(event.currentTarget);
|
|
132
|
+
void run(async () => {
|
|
133
|
+
const answer = await client.checkoutsShippingAddress({
|
|
134
|
+
path: { id },
|
|
135
|
+
body: {
|
|
136
|
+
address: String(form.get('address') || '') || null,
|
|
137
|
+
address2: null,
|
|
138
|
+
city: String(form.get('city') || '') || null,
|
|
139
|
+
latitude: Number(form.get('latitude')),
|
|
140
|
+
longitude: Number(form.get('longitude')),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
await loadOptions(id);
|
|
144
|
+
return answer;
|
|
145
|
+
});
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<p className="checkout-choices__legend">
|
|
149
|
+
{locations.length > 0 ? t.orDeliverTo : t.deliverTo}
|
|
150
|
+
</p>
|
|
151
|
+
<Field
|
|
152
|
+
label={t.street}
|
|
153
|
+
name="address"
|
|
154
|
+
autoComplete="street-address"
|
|
155
|
+
defaultValue={checkout.shippingAddress?.address ?? ''}
|
|
156
|
+
error={fieldErrors.address}
|
|
157
|
+
/>
|
|
158
|
+
<Field
|
|
159
|
+
label={t.city}
|
|
160
|
+
name="city"
|
|
161
|
+
autoComplete="address-level2"
|
|
162
|
+
defaultValue={checkout.shippingAddress?.city ?? ''}
|
|
163
|
+
error={fieldErrors.city}
|
|
164
|
+
/>
|
|
165
|
+
<div className="checkout-form__row checkout-form__row--location">
|
|
166
|
+
<Field
|
|
167
|
+
label={t.latitude}
|
|
168
|
+
name="latitude"
|
|
169
|
+
required
|
|
170
|
+
inputMode="decimal"
|
|
171
|
+
defaultValue={checkout.shippingAddress?.latitude ?? ''}
|
|
172
|
+
error={fieldErrors.latitude}
|
|
173
|
+
/>
|
|
174
|
+
<Field
|
|
175
|
+
label={t.longitude}
|
|
176
|
+
name="longitude"
|
|
177
|
+
required
|
|
178
|
+
inputMode="decimal"
|
|
179
|
+
defaultValue={checkout.shippingAddress?.longitude ?? ''}
|
|
180
|
+
error={fieldErrors.longitude}
|
|
181
|
+
/>
|
|
182
|
+
<Button
|
|
183
|
+
className="checkout-form__locate"
|
|
184
|
+
onClick={(event) => fillMyLocation(event.currentTarget.form)}
|
|
185
|
+
>
|
|
186
|
+
<Icon name="map-pin" size={18} />
|
|
187
|
+
{t.useMyLocation}
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
<Button type="submit" variant={needsAddress ? 'primary' : 'secondary'} loading={busy}>
|
|
191
|
+
{t.showDeliveryOptions}
|
|
192
|
+
</Button>
|
|
193
|
+
</form>
|
|
194
|
+
{options.length > 0 && (
|
|
195
|
+
<fieldset className="checkout-choices">
|
|
196
|
+
<legend className="checkout-choices__legend">{t.delivery}</legend>
|
|
197
|
+
{options.map((option) => (
|
|
198
|
+
<RadioCard
|
|
199
|
+
key={option.id}
|
|
200
|
+
name="delivery"
|
|
201
|
+
disabled={!option.available}
|
|
202
|
+
checked={checkout.selectedDeliveryOption?.id === option.id}
|
|
203
|
+
onSelect={() =>
|
|
204
|
+
void run(() =>
|
|
205
|
+
client.checkoutsDeliveryOption({
|
|
206
|
+
path: { id },
|
|
207
|
+
body: { deliveryOptionId: option.id },
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
title={option.title}
|
|
212
|
+
aside={<Money data={option.price} fallback={t.free} />}
|
|
213
|
+
>
|
|
214
|
+
{option.etaMinutes !== null ? t.etaMinutes(option.etaMinutes) : null}
|
|
215
|
+
</RadioCard>
|
|
216
|
+
))}
|
|
217
|
+
</fieldset>
|
|
218
|
+
)}
|
|
219
|
+
</Section>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Payment: one of the methods the checkout lists (`checkoutsPaymentMethod`). */
|
|
224
|
+
export function PaymentStep({ state, t }: { state: Ready; t: Messages }) {
|
|
225
|
+
const { client, checkout, run } = state;
|
|
226
|
+
return (
|
|
227
|
+
<Section step="payment" index={3} title={t.payment} done={isDone('payment', checkout.missing)}>
|
|
228
|
+
<fieldset className="checkout-choices">
|
|
229
|
+
<legend className="visually-hidden">{t.payment}</legend>
|
|
230
|
+
{checkout.paymentMethods.map((method) => (
|
|
231
|
+
<RadioCard
|
|
232
|
+
key={method.id}
|
|
233
|
+
name="payment"
|
|
234
|
+
checked={checkout.selectedPaymentMethod?.id === method.id}
|
|
235
|
+
onSelect={() =>
|
|
236
|
+
void run(() =>
|
|
237
|
+
client.checkoutsPaymentMethod({
|
|
238
|
+
path: { id: checkout.id },
|
|
239
|
+
body: { paymentMethodId: method.id },
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
title={method.title}
|
|
244
|
+
>
|
|
245
|
+
{method.description}
|
|
246
|
+
</RadioCard>
|
|
247
|
+
))}
|
|
248
|
+
</fieldset>
|
|
249
|
+
</Section>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function fillMyLocation(form: HTMLFormElement | null): void {
|
|
254
|
+
navigator.geolocation?.getCurrentPosition((position) => {
|
|
255
|
+
const set = (name: string, value: number) => {
|
|
256
|
+
const input = form?.elements.namedItem(name);
|
|
257
|
+
if (input instanceof HTMLInputElement) {
|
|
258
|
+
input.value = value.toFixed(6);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
set('latitude', position.coords.latitude);
|
|
262
|
+
set('longitude', position.coords.longitude);
|
|
263
|
+
});
|
|
264
|
+
}
|