create-magic-storefront 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/.env.example +5 -0
  4. package/template/AGENTS.md +36 -17
  5. package/template/DESIGN.md +117 -0
  6. package/template/PAGES.md +116 -29
  7. package/template/README.md +15 -3
  8. package/template/app/account/addresses/page.tsx +16 -0
  9. package/template/app/account/layout.tsx +14 -0
  10. package/template/app/account/loading.tsx +15 -0
  11. package/template/app/account/orders/[id]/page.tsx +15 -0
  12. package/template/app/account/page.tsx +12 -244
  13. package/template/app/account/profile/page.tsx +16 -0
  14. package/template/app/blog/(index)/loading.tsx +22 -0
  15. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  16. package/template/app/blog/[handle]/page.tsx +92 -56
  17. package/template/app/blog/author/[handle]/page.tsx +1 -1
  18. package/template/app/cart/layout.tsx +14 -0
  19. package/template/app/cart/page.tsx +49 -101
  20. package/template/app/checkout/layout.tsx +14 -0
  21. package/template/app/checkout/page.tsx +98 -281
  22. package/template/app/collections/[handle]/layout.tsx +20 -0
  23. package/template/app/collections/[handle]/loading.tsx +23 -0
  24. package/template/app/collections/[handle]/page.tsx +131 -15
  25. package/template/app/collections/page.tsx +49 -0
  26. package/template/app/contacts/page.tsx +196 -0
  27. package/template/app/error.tsx +19 -8
  28. package/template/app/fonts/OFL.txt +93 -0
  29. package/template/app/fonts/index.ts +14 -0
  30. package/template/app/fonts/onest-variable.woff2 +0 -0
  31. package/template/app/globals.css +14 -518
  32. package/template/app/layout.tsx +74 -50
  33. package/template/app/not-found.tsx +34 -4
  34. package/template/app/page.tsx +1 -1
  35. package/template/app/pages/[handle]/page.tsx +1 -1
  36. package/template/app/products/[handle]/layout.tsx +20 -0
  37. package/template/app/products/[handle]/loading.tsx +25 -0
  38. package/template/app/products/[handle]/page.tsx +112 -15
  39. package/template/app/providers.tsx +20 -7
  40. package/template/app/search/loading.tsx +12 -0
  41. package/template/app/search/page.tsx +138 -25
  42. package/template/app/sitemap.ts +5 -1
  43. package/template/app/storefront-api/[...path]/route.ts +11 -66
  44. package/template/app/styles/base.css +138 -0
  45. package/template/app/styles/components.css +1108 -0
  46. package/template/app/styles/layout.css +455 -0
  47. package/template/app/styles/pages.css +1839 -0
  48. package/template/app/styles/reset.css +55 -0
  49. package/template/app/styles/sections.css +415 -0
  50. package/template/app/styles/tokens.css +167 -0
  51. package/template/app/styles/utilities.css +37 -0
  52. package/template/app/wishlist/page.tsx +26 -0
  53. package/template/components/account/account-shell.tsx +100 -0
  54. package/template/components/account/addresses.tsx +247 -0
  55. package/template/components/account/order-detail.tsx +281 -0
  56. package/template/components/account/order-list.tsx +94 -0
  57. package/template/components/account/order-status.tsx +19 -0
  58. package/template/components/account/profile.tsx +129 -0
  59. package/template/components/account/sign-in.tsx +164 -0
  60. package/template/components/article-body.tsx +3 -2
  61. package/template/components/article-card.tsx +37 -12
  62. package/template/components/cart/cart-drawer.tsx +149 -0
  63. package/template/components/cart/cart-lines.tsx +89 -0
  64. package/template/components/cart/cart-summary.tsx +191 -0
  65. package/template/components/catalog/catalog-form.tsx +107 -0
  66. package/template/components/catalog/catalog-layout.tsx +82 -0
  67. package/template/components/catalog/facets-shell.tsx +60 -0
  68. package/template/components/catalog/facets.tsx +216 -0
  69. package/template/components/checkout/order-summary.tsx +84 -0
  70. package/template/components/checkout/radio-card.tsx +34 -0
  71. package/template/components/checkout/step-indicator.tsx +56 -0
  72. package/template/components/checkout/steps.tsx +264 -0
  73. package/template/components/checkout/thank-you.tsx +48 -0
  74. package/template/components/checkout/use-checkout.ts +90 -0
  75. package/template/components/footer/site-footer.tsx +132 -0
  76. package/template/components/header/cart-button.tsx +43 -0
  77. package/template/components/header/desktop-nav.tsx +133 -0
  78. package/template/components/header/mobile-menu.tsx +102 -0
  79. package/template/components/header/site-header.tsx +78 -0
  80. package/template/components/page-loading.tsx +20 -0
  81. package/template/components/pager.tsx +3 -1
  82. package/template/components/product/buy-box.tsx +148 -0
  83. package/template/components/product/gallery.tsx +179 -0
  84. package/template/components/product/pre-order-form.tsx +93 -0
  85. package/template/components/product/product-selection.tsx +31 -0
  86. package/template/components/product/recommendations.tsx +26 -0
  87. package/template/components/product/review-list.tsx +159 -0
  88. package/template/components/product/reviews.tsx +80 -0
  89. package/template/components/product/variant-picker.tsx +75 -0
  90. package/template/components/product-card.tsx +103 -0
  91. package/template/components/product-grid.tsx +27 -57
  92. package/template/components/quick-add.tsx +62 -0
  93. package/template/components/search/predictive-search.tsx +282 -0
  94. package/template/components/sections/announcement-bar.tsx +1 -1
  95. package/template/components/sections/banner.tsx +1 -1
  96. package/template/components/sections/collections.tsx +50 -13
  97. package/template/components/sections/deal-of-day.tsx +2 -2
  98. package/template/components/sections/product-shelves.tsx +13 -5
  99. package/template/components/sections/shoppable-stories.tsx +2 -2
  100. package/template/components/sections/store-reviews.tsx +10 -4
  101. package/template/components/share-button.tsx +42 -0
  102. package/template/components/ui/accordion.tsx +35 -0
  103. package/template/components/ui/badge.tsx +20 -0
  104. package/template/components/ui/breadcrumbs.tsx +31 -0
  105. package/template/components/ui/button.tsx +90 -0
  106. package/template/components/ui/drawer.tsx +84 -0
  107. package/template/components/ui/empty-state.tsx +27 -0
  108. package/template/components/ui/field.tsx +33 -0
  109. package/template/components/ui/icon.tsx +112 -0
  110. package/template/components/ui/price.tsx +35 -0
  111. package/template/components/ui/quantity-stepper.tsx +91 -0
  112. package/template/components/ui/rating.tsx +48 -0
  113. package/template/components/ui/skeleton.tsx +46 -0
  114. package/template/components/wishlist/wishlist-view.tsx +134 -0
  115. package/template/lib/article-outline.ts +35 -0
  116. package/template/lib/catalog-params.ts +170 -0
  117. package/template/lib/contacts.ts +34 -0
  118. package/template/lib/errors.ts +29 -0
  119. package/template/lib/i18n.ts +484 -0
  120. package/template/lib/menu.ts +70 -0
  121. package/template/lib/theme-settings.ts +36 -0
  122. package/template/lib/upstream.ts +21 -0
  123. package/template/llms.txt +13 -0
  124. package/template/theme/index.ts +7 -1
  125. package/template/theme/sections/collapsible-content.tsx +75 -0
  126. package/template/theme/sections/collection-list.tsx +32 -4
  127. package/template/theme/sections/featured-collection.tsx +22 -2
  128. package/template/theme/sections/hero.tsx +94 -7
  129. package/template/theme/sections/image-with-text.tsx +19 -3
  130. package/template/theme/sections/multicolumn.tsx +113 -0
  131. package/template/theme/sections/page-content.tsx +10 -4
  132. package/template/theme/sections/product-shelf.tsx +26 -4
  133. package/template/theme/sections/rich-text.tsx +13 -1
  134. package/template/theme/sections/testimonials.tsx +14 -9
  135. package/template/theme/settings.ts +85 -1
  136. package/template/theme/templates/index.json +4 -2
  137. package/template/.screenshots/home-1280.png +0 -0
  138. package/template/.screenshots/home-390.png +0 -0
  139. package/template/.screenshots/home-filled-1280.png +0 -0
  140. package/template/.screenshots/home-filled-390.png +0 -0
  141. package/template/.screenshots/page-1280.png +0 -0
  142. package/template/components/buy-box.tsx +0 -89
  143. package/template/components/cart-link.tsx +0 -15
@@ -0,0 +1,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
+ }