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
@@ -1,120 +1,68 @@
1
1
  'use client';
2
2
 
3
- import { Image, Money, moneyAmount, useCart, useMagicStore } from '@magicstoreai/hydrogen';
3
+ import { useCart, useMagicStore } from '@magicstoreai/hydrogen';
4
4
  import Link from 'next/link';
5
- import { useState } from 'react';
6
- import { describe } from '@/lib/errors';
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, error, updateLine, removeLine, setDiscountCodes } = useCart();
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 <p className="muted">{t.loadingCart}</p>;
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
- <div className="stack">
20
- <h1>{t.cartEmpty}</h1>
21
- <Link href="/">{t.continueShopping}</Link>
22
- </div>
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
- <div className="stack">
30
- <h1>{t.cart}</h1>
31
- <ul className="cart-lines">
32
- {cart.lines.map((line) => (
33
- <li key={line.id} className="cart-line">
34
- <Image data={line.merchandise.image} width={64} height={64} alt="" />
35
- <div>
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
- </div>
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 { useCallback, useEffect, useState } from 'react';
13
- import { describe } from '@/lib/errors';
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. The server prices
24
- * everything; this page only shows what it answers.
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 [checkout, setCheckout] = useState<Checkout | null>(null);
32
- const [locations, setLocations] = useState<Location[]>([]);
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="stack">
76
- <h1>{t.thankYou}</h1>
77
- <p>
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
- {completion.payment.status === 'PENDING' && completion.payment.redirectUrl && (
82
- <a className="button primary" href={completion.payment.redirectUrl}>
83
- {t.payNow}
84
- </a>
85
- )}
86
- <Link href="/">{t.continueShopping}</Link>
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
- <p>
96
- {t.cartEmpty}. <Link href="/">{t.continueShopping}</Link>
97
- </p>
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 error ? (
102
- <p className="error">{error}</p>
103
- ) : (
104
- <p className="muted">{t.preparingCheckout}</p>
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 id = checkout.id;
109
- const missing = new Set(checkout.missing);
78
+ const ready = { ...state, checkout };
110
79
 
111
80
  return (
112
- <div className="stack">
113
- <h1>{t.checkoutTitle}</h1>
114
-
115
- <form
116
- className="stack"
117
- onSubmit={(event) => {
118
- event.preventDefault();
119
- const form = new FormData(event.currentTarget);
120
- void run(() =>
121
- client.checkoutsContact({
122
- path: { id },
123
- body: { name: String(form.get('name')), phone: String(form.get('phone')) },
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
- <fieldset className="stack">
154
- <legend>
155
- {t.delivery}{' '}
156
- {missing.has('DELIVERY_ADDRESS') || missing.has('DELIVERY_OPTION') ? '' : '✓'}
157
- </legend>
158
- {locations.length > 0 && (
159
- <div className="stack">
160
- <strong>{t.pickUp}</strong>
161
- {locations.map((location) => (
162
- <label key={location.id} className="row">
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
- <form
183
- className="stack"
184
- onSubmit={(event) => {
185
- event.preventDefault();
186
- const form = new FormData(event.currentTarget);
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 answer = await client.checkoutsShippingAddress({
189
- path: { id },
190
- body: {
191
- address: String(form.get('address') || '') || null,
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
- <strong>{locations.length > 0 ? t.orDeliverTo : t.deliverTo}</strong>
204
- <input
205
- name="address"
206
- placeholder={t.street}
207
- aria-label={t.street}
208
- autoComplete="street-address"
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
- <p>
283
- {t.total}:{' '}
284
- <strong>
285
- <Money data={checkout.cost.total} />
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
+ }