create-magic-storefront 0.1.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/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/index.js +222 -0
- package/package.json +31 -0
- package/template/.env.example +22 -0
- package/template/AGENTS.md +56 -0
- package/template/CLAUDE.md +1 -0
- package/template/PAGES.md +154 -0
- package/template/README.md +31 -0
- package/template/_gitignore +5 -0
- package/template/app/account/page.tsx +117 -0
- package/template/app/api/magicstore/webhook/route.ts +9 -0
- package/template/app/cart/page.tsx +124 -0
- package/template/app/checkout/page.tsx +268 -0
- package/template/app/collections/[handle]/page.tsx +48 -0
- package/template/app/error.tsx +18 -0
- package/template/app/globals.css +268 -0
- package/template/app/layout.tsx +63 -0
- package/template/app/not-found.tsx +10 -0
- package/template/app/page.tsx +16 -0
- package/template/app/pages/[handle]/page.tsx +28 -0
- package/template/app/products/[handle]/page.tsx +47 -0
- package/template/app/providers.tsx +49 -0
- package/template/app/search/page.tsx +37 -0
- package/template/app/sitemap.ts +36 -0
- package/template/app/storefront-api/[...path]/route.ts +83 -0
- package/template/components/analytics-views.tsx +18 -0
- package/template/components/buy-box.tsx +86 -0
- package/template/components/cart-link.tsx +9 -0
- package/template/components/pager.tsx +25 -0
- package/template/components/product-grid.tsx +32 -0
- package/template/components/sections/announcement-bar.tsx +13 -0
- package/template/components/sections/banner.tsx +41 -0
- package/template/components/sections/collections.tsx +67 -0
- package/template/components/sections/deal-of-day.tsx +48 -0
- package/template/components/sections/index.tsx +66 -0
- package/template/components/sections/product-shelves.tsx +123 -0
- package/template/components/sections/shoppable-stories.tsx +39 -0
- package/template/components/sections/store-reviews.tsx +67 -0
- package/template/components/sections/types.ts +10 -0
- package/template/lib/api.ts +35 -0
- package/template/lib/errors.ts +9 -0
- package/template/lib/upstream.ts +12 -0
- package/template/llms.txt +128 -0
- package/template/next.config.ts +9 -0
- package/template/package.json +26 -0
- package/template/tsconfig.json +33 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Money, useCustomer, useStorefrontClient, useWishlist } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
import { describe } from '@/lib/errors';
|
|
7
|
+
|
|
8
|
+
export default function AccountPage() {
|
|
9
|
+
const { customer, isSignedIn, signOut } = useCustomer();
|
|
10
|
+
return isSignedIn && customer ? (
|
|
11
|
+
<div className="stack">
|
|
12
|
+
<h1>{customer.name || customer.phone}</h1>
|
|
13
|
+
<Orders />
|
|
14
|
+
<SavedCount />
|
|
15
|
+
<div>
|
|
16
|
+
<button onClick={() => void signOut()}>Sign out</button>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
) : (
|
|
20
|
+
<SignIn />
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function SignIn() {
|
|
25
|
+
const { requestOtp, verifyOtp } = useCustomer();
|
|
26
|
+
const [phone, setPhone] = useState('');
|
|
27
|
+
const [sent, setSent] = useState(false);
|
|
28
|
+
const [code, setCode] = useState('');
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
async function attempt(step: () => Promise<unknown>) {
|
|
32
|
+
setError(null);
|
|
33
|
+
try {
|
|
34
|
+
await step();
|
|
35
|
+
} catch (e) {
|
|
36
|
+
setError(describe(e));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<form
|
|
42
|
+
className="stack"
|
|
43
|
+
style={{ maxWidth: 360 }}
|
|
44
|
+
onSubmit={(event) => {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
void attempt(async () => {
|
|
47
|
+
if (!sent) {
|
|
48
|
+
await requestOtp(phone);
|
|
49
|
+
setSent(true);
|
|
50
|
+
} else {
|
|
51
|
+
await verifyOtp(phone, code);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<h1>Sign in</h1>
|
|
57
|
+
<input
|
|
58
|
+
value={phone}
|
|
59
|
+
onChange={(e) => setPhone(e.target.value)}
|
|
60
|
+
placeholder="+998 90 123 45 67"
|
|
61
|
+
required
|
|
62
|
+
disabled={sent}
|
|
63
|
+
/>
|
|
64
|
+
{sent && (
|
|
65
|
+
<input
|
|
66
|
+
value={code}
|
|
67
|
+
onChange={(e) => setCode(e.target.value)}
|
|
68
|
+
placeholder="Code from the SMS"
|
|
69
|
+
inputMode="numeric"
|
|
70
|
+
required
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
<button className="primary">{sent ? 'Sign in' : 'Send code'}</button>
|
|
74
|
+
{error && <p className="error">{error}</p>}
|
|
75
|
+
</form>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Orders() {
|
|
80
|
+
const client = useStorefrontClient();
|
|
81
|
+
const [orders, setOrders] = useState<Schema<'Order'>[] | null>(null);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
void client.customerOrdersIndex({ query: { perPage: 20 } }).then(
|
|
85
|
+
({ data }) => setOrders(data),
|
|
86
|
+
() => setOrders([]),
|
|
87
|
+
);
|
|
88
|
+
}, [client]);
|
|
89
|
+
|
|
90
|
+
if (orders === null) {
|
|
91
|
+
return <p className="muted">Loading orders…</p>;
|
|
92
|
+
}
|
|
93
|
+
return (
|
|
94
|
+
<section className="stack">
|
|
95
|
+
<h2>Orders</h2>
|
|
96
|
+
{orders.length === 0 && <p className="muted">No orders yet.</p>}
|
|
97
|
+
<table>
|
|
98
|
+
<tbody>
|
|
99
|
+
{orders.map((order) => (
|
|
100
|
+
<tr key={order.id}>
|
|
101
|
+
<td>#{order.number}</td>
|
|
102
|
+
<td>{order.status}</td>
|
|
103
|
+
<td>
|
|
104
|
+
<Money data={(order.cost as { total?: Schema<'Money'> | null }).total ?? null} />
|
|
105
|
+
</td>
|
|
106
|
+
</tr>
|
|
107
|
+
))}
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
</section>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function SavedCount() {
|
|
115
|
+
const { productIds } = useWishlist();
|
|
116
|
+
return <p className="muted">Saved products: {productIds.length}</p>;
|
|
117
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createWebhookHandler } from '@magicstoreai/hydrogen/server';
|
|
2
|
+
import { revalidateTag } from 'next/cache';
|
|
3
|
+
|
|
4
|
+
const secret = process.env.MAGICSTORE_WEBHOOK_SECRET;
|
|
5
|
+
|
|
6
|
+
/** The platform's webhook: a verified event revalidates the cached pages its topic covers. */
|
|
7
|
+
export const POST = secret
|
|
8
|
+
? createWebhookHandler({ secret, revalidateTag })
|
|
9
|
+
: () => new Response('MAGICSTORE_WEBHOOK_SECRET is not set', { status: 501 });
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Image, Money, moneyAmount, useCart } from '@magicstoreai/hydrogen';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { describe } from '@/lib/errors';
|
|
7
|
+
|
|
8
|
+
export default function CartPage() {
|
|
9
|
+
const { cart, status, error, updateLine, removeLine, setDiscountCodes } = useCart();
|
|
10
|
+
const [code, setCode] = useState('');
|
|
11
|
+
|
|
12
|
+
if (status === 'loading') {
|
|
13
|
+
return <p className="muted">Loading your cart…</p>;
|
|
14
|
+
}
|
|
15
|
+
if (cart === null || cart.lines.length === 0) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="stack">
|
|
18
|
+
<h1>Your cart is empty</h1>
|
|
19
|
+
<Link href="/">Continue shopping</Link>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const applied = cart.discountCodes[0];
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="stack">
|
|
28
|
+
<h1>Cart</h1>
|
|
29
|
+
<table>
|
|
30
|
+
<tbody>
|
|
31
|
+
{cart.lines.map((line) => (
|
|
32
|
+
<tr key={line.id}>
|
|
33
|
+
<td style={{ width: 72 }}>
|
|
34
|
+
<Image data={line.merchandise.image} width={64} height={64} alt="" />
|
|
35
|
+
</td>
|
|
36
|
+
<td>
|
|
37
|
+
<div>{line.merchandise.product?.title}</div>
|
|
38
|
+
{line.merchandise.variant &&
|
|
39
|
+
!/^Default( Title)?$/.test(line.merchandise.variant.title) && (
|
|
40
|
+
<div className="muted">{line.merchandise.variant.title}</div>
|
|
41
|
+
)}
|
|
42
|
+
{!line.available && <div className="error">Not available any more</div>}
|
|
43
|
+
</td>
|
|
44
|
+
<td>
|
|
45
|
+
<input
|
|
46
|
+
type="number"
|
|
47
|
+
min={0}
|
|
48
|
+
max={999}
|
|
49
|
+
value={line.quantity}
|
|
50
|
+
aria-label="Quantity"
|
|
51
|
+
onChange={(event) =>
|
|
52
|
+
void updateLine(line.id, Number(event.target.value)).catch(() => undefined)
|
|
53
|
+
}
|
|
54
|
+
style={{ width: 72 }}
|
|
55
|
+
/>
|
|
56
|
+
</td>
|
|
57
|
+
<td>
|
|
58
|
+
<Money data={line.cost.total} />
|
|
59
|
+
</td>
|
|
60
|
+
<td>
|
|
61
|
+
<button onClick={() => void removeLine(line.id).catch(() => undefined)}>
|
|
62
|
+
Remove
|
|
63
|
+
</button>
|
|
64
|
+
</td>
|
|
65
|
+
</tr>
|
|
66
|
+
))}
|
|
67
|
+
</tbody>
|
|
68
|
+
</table>
|
|
69
|
+
|
|
70
|
+
<form
|
|
71
|
+
className="row"
|
|
72
|
+
onSubmit={(event) => {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
void setDiscountCodes(code.trim() ? [code.trim()] : []).catch(() => undefined);
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<input
|
|
78
|
+
value={code}
|
|
79
|
+
onChange={(event) => setCode(event.target.value)}
|
|
80
|
+
placeholder="Discount code"
|
|
81
|
+
/>
|
|
82
|
+
<button>Apply</button>
|
|
83
|
+
{applied && (
|
|
84
|
+
<span className={applied.applicable ? 'muted' : 'error'}>
|
|
85
|
+
{applied.code}: {applied.applicable ? 'applied' : `not applied (${applied.reasonCode})`}
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</form>
|
|
89
|
+
|
|
90
|
+
<table>
|
|
91
|
+
<tbody>
|
|
92
|
+
<tr>
|
|
93
|
+
<td>Subtotal</td>
|
|
94
|
+
<td>
|
|
95
|
+
<Money data={cart.cost.subtotal} />
|
|
96
|
+
</td>
|
|
97
|
+
</tr>
|
|
98
|
+
{cart.cost.discountTotal && moneyAmount(cart.cost.discountTotal) > 0 && (
|
|
99
|
+
<tr>
|
|
100
|
+
<td>Discount</td>
|
|
101
|
+
<td>
|
|
102
|
+
−<Money data={cart.cost.discountTotal} />
|
|
103
|
+
</td>
|
|
104
|
+
</tr>
|
|
105
|
+
)}
|
|
106
|
+
<tr>
|
|
107
|
+
<th>Total</th>
|
|
108
|
+
<th>
|
|
109
|
+
<Money data={cart.cost.total} />
|
|
110
|
+
</th>
|
|
111
|
+
</tr>
|
|
112
|
+
</tbody>
|
|
113
|
+
</table>
|
|
114
|
+
|
|
115
|
+
{error && <p className="error">{describe(error)}</p>}
|
|
116
|
+
<div className="row">
|
|
117
|
+
<Link href="/checkout" className="button primary">
|
|
118
|
+
Checkout
|
|
119
|
+
</Link>
|
|
120
|
+
{status === 'updating' && <span className="muted">Updating…</span>}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Money, useCart, useCustomer, useStorefrontClient } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
7
|
+
import { describe } from '@/lib/errors';
|
|
8
|
+
|
|
9
|
+
type Checkout = Schema<'Checkout'>;
|
|
10
|
+
type DeliveryOption = Schema<'DeliveryOption'>;
|
|
11
|
+
type Location = Schema<'Location'>;
|
|
12
|
+
type Completion = Schema<'CheckoutCompletion'>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One page, filled in the order the checkout's `missing` lists: contact → delivery (pickup point,
|
|
16
|
+
* or an address and a delivery option) → payment method → place the order. The server prices
|
|
17
|
+
* everything; this page only shows what it answers.
|
|
18
|
+
*/
|
|
19
|
+
export default function CheckoutPage() {
|
|
20
|
+
const client = useStorefrontClient();
|
|
21
|
+
const { cart, status: cartStatus } = useCart();
|
|
22
|
+
const { customer } = useCustomer();
|
|
23
|
+
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
24
|
+
const [locations, setLocations] = useState<Location[]>([]);
|
|
25
|
+
const [options, setOptions] = useState<DeliveryOption[]>([]);
|
|
26
|
+
const [completion, setCompletion] = useState<Completion | null>(null);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
const [busy, setBusy] = useState(false);
|
|
29
|
+
|
|
30
|
+
const run = useCallback(async (step: () => Promise<{ data: Checkout } | void>) => {
|
|
31
|
+
setBusy(true);
|
|
32
|
+
setError(null);
|
|
33
|
+
try {
|
|
34
|
+
const result = await step();
|
|
35
|
+
if (result) {
|
|
36
|
+
setCheckout(result.data);
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
setError(describe(e));
|
|
40
|
+
} finally {
|
|
41
|
+
setBusy(false);
|
|
42
|
+
}
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (cart === null || checkout !== null) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
void run(() => client.checkoutsStore({ body: { cartId: cart.id } }));
|
|
50
|
+
void client.locationsIndex({ query: { perPage: 50 } }).then(
|
|
51
|
+
({ data }) => setLocations(data),
|
|
52
|
+
() => undefined,
|
|
53
|
+
);
|
|
54
|
+
}, [cart, checkout, client, run]);
|
|
55
|
+
|
|
56
|
+
const loadOptions = useCallback(
|
|
57
|
+
async (id: string) =>
|
|
58
|
+
setOptions((await client.checkoutsDeliveryOptions({ path: { id } })).data),
|
|
59
|
+
[client],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (completion) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="stack">
|
|
65
|
+
<h1>Thank you!</h1>
|
|
66
|
+
<p>
|
|
67
|
+
Order <strong>{completion.order.number}</strong> — <Money data={completion.order.total} />
|
|
68
|
+
</p>
|
|
69
|
+
{completion.payment.status === 'PENDING' && completion.payment.redirectUrl && (
|
|
70
|
+
<a className="button primary" href={completion.payment.redirectUrl}>
|
|
71
|
+
Pay now
|
|
72
|
+
</a>
|
|
73
|
+
)}
|
|
74
|
+
<Link href="/">Continue shopping</Link>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (cartStatus === 'loading') {
|
|
79
|
+
return <p className="muted">Loading your cart…</p>;
|
|
80
|
+
}
|
|
81
|
+
if (cart === null || cart.lines.length === 0) {
|
|
82
|
+
return (
|
|
83
|
+
<p>
|
|
84
|
+
Your cart is empty. <Link href="/">Continue shopping</Link>
|
|
85
|
+
</p>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (checkout === null) {
|
|
89
|
+
return error ? <p className="error">{error}</p> : <p className="muted">Preparing checkout…</p>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const id = checkout.id;
|
|
93
|
+
const missing = new Set(checkout.missing);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="stack">
|
|
97
|
+
<h1>Checkout</h1>
|
|
98
|
+
|
|
99
|
+
<form
|
|
100
|
+
className="stack"
|
|
101
|
+
onSubmit={(event) => {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
const form = new FormData(event.currentTarget);
|
|
104
|
+
void run(() =>
|
|
105
|
+
client.checkoutsContact({
|
|
106
|
+
path: { id },
|
|
107
|
+
body: { name: String(form.get('name')), phone: String(form.get('phone')) },
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<fieldset className="stack">
|
|
113
|
+
<legend>Contact {missing.has('CONTACT') ? '' : '✓'}</legend>
|
|
114
|
+
<input
|
|
115
|
+
name="name"
|
|
116
|
+
required
|
|
117
|
+
placeholder="Name"
|
|
118
|
+
defaultValue={checkout.contact?.name ?? customer?.name ?? ''}
|
|
119
|
+
/>
|
|
120
|
+
<input
|
|
121
|
+
name="phone"
|
|
122
|
+
required
|
|
123
|
+
placeholder="+998 90 123 45 67"
|
|
124
|
+
defaultValue={checkout.contact?.phone ?? customer?.phone ?? ''}
|
|
125
|
+
/>
|
|
126
|
+
<button disabled={busy}>Save contact</button>
|
|
127
|
+
</fieldset>
|
|
128
|
+
</form>
|
|
129
|
+
|
|
130
|
+
<fieldset className="stack">
|
|
131
|
+
<legend>
|
|
132
|
+
Delivery {missing.has('DELIVERY_ADDRESS') || missing.has('DELIVERY_OPTION') ? '' : '✓'}
|
|
133
|
+
</legend>
|
|
134
|
+
{locations.length > 0 && (
|
|
135
|
+
<div className="stack">
|
|
136
|
+
<strong>Pick up</strong>
|
|
137
|
+
{locations.map((location) => (
|
|
138
|
+
<label key={location.id} className="row">
|
|
139
|
+
<input
|
|
140
|
+
type="radio"
|
|
141
|
+
name="pickup"
|
|
142
|
+
checked={checkout.pickupLocation?.id === location.id}
|
|
143
|
+
onChange={() =>
|
|
144
|
+
void run(() =>
|
|
145
|
+
client.checkoutsPickupLocation({
|
|
146
|
+
path: { id },
|
|
147
|
+
body: { locationId: location.id },
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
/>
|
|
152
|
+
{location.name}{' '}
|
|
153
|
+
{location.address && <span className="muted">— {location.address}</span>}
|
|
154
|
+
</label>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
<form
|
|
159
|
+
className="stack"
|
|
160
|
+
onSubmit={(event) => {
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
const form = new FormData(event.currentTarget);
|
|
163
|
+
void run(async () => {
|
|
164
|
+
const answer = await client.checkoutsShippingAddress({
|
|
165
|
+
path: { id },
|
|
166
|
+
body: {
|
|
167
|
+
address: String(form.get('address') || '') || null,
|
|
168
|
+
address2: null,
|
|
169
|
+
city: String(form.get('city') || '') || null,
|
|
170
|
+
latitude: Number(form.get('latitude')),
|
|
171
|
+
longitude: Number(form.get('longitude')),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
await loadOptions(id);
|
|
175
|
+
return answer;
|
|
176
|
+
});
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<strong>Or deliver to</strong>
|
|
180
|
+
<input name="address" placeholder="Street, house" />
|
|
181
|
+
<input name="city" placeholder="City" />
|
|
182
|
+
<div className="row">
|
|
183
|
+
<input name="latitude" required inputMode="decimal" placeholder="Latitude" />
|
|
184
|
+
<input name="longitude" required inputMode="decimal" placeholder="Longitude" />
|
|
185
|
+
<button type="button" onClick={(event) => fillMyLocation(event.currentTarget.form)}>
|
|
186
|
+
Use my location
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
<button disabled={busy}>Show delivery options</button>
|
|
190
|
+
</form>
|
|
191
|
+
{options.map((option) => (
|
|
192
|
+
<label key={option.id} className="row">
|
|
193
|
+
<input
|
|
194
|
+
type="radio"
|
|
195
|
+
name="delivery"
|
|
196
|
+
disabled={!option.available}
|
|
197
|
+
checked={checkout.selectedDeliveryOption?.id === option.id}
|
|
198
|
+
onChange={() =>
|
|
199
|
+
void run(() =>
|
|
200
|
+
client.checkoutsDeliveryOption({
|
|
201
|
+
path: { id },
|
|
202
|
+
body: { deliveryOptionId: option.id },
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
/>
|
|
207
|
+
{option.title} — <Money data={option.price} fallback="free" />
|
|
208
|
+
</label>
|
|
209
|
+
))}
|
|
210
|
+
</fieldset>
|
|
211
|
+
|
|
212
|
+
<fieldset className="stack">
|
|
213
|
+
<legend>Payment {missing.has('PAYMENT_METHOD') ? '' : '✓'}</legend>
|
|
214
|
+
{checkout.paymentMethods.map((method) => (
|
|
215
|
+
<label key={method.id} className="row">
|
|
216
|
+
<input
|
|
217
|
+
type="radio"
|
|
218
|
+
name="payment"
|
|
219
|
+
checked={checkout.selectedPaymentMethod?.id === method.id}
|
|
220
|
+
onChange={() =>
|
|
221
|
+
void run(() =>
|
|
222
|
+
client.checkoutsPaymentMethod({
|
|
223
|
+
path: { id },
|
|
224
|
+
body: { paymentMethodId: method.id },
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
/>
|
|
229
|
+
{method.title}
|
|
230
|
+
</label>
|
|
231
|
+
))}
|
|
232
|
+
</fieldset>
|
|
233
|
+
|
|
234
|
+
<p>
|
|
235
|
+
Total:{' '}
|
|
236
|
+
<strong>
|
|
237
|
+
<Money data={checkout.cost.total} />
|
|
238
|
+
</strong>
|
|
239
|
+
</p>
|
|
240
|
+
{error && <p className="error">{error}</p>}
|
|
241
|
+
<button
|
|
242
|
+
className="primary"
|
|
243
|
+
disabled={busy || !checkout.readyForCompletion}
|
|
244
|
+
onClick={() =>
|
|
245
|
+
void run(async () => {
|
|
246
|
+
const { data } = await client.checkoutsCompletion({ path: { id } });
|
|
247
|
+
setCompletion(data);
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
>
|
|
251
|
+
Place order
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function fillMyLocation(form: HTMLFormElement | null): void {
|
|
258
|
+
navigator.geolocation?.getCurrentPosition((position) => {
|
|
259
|
+
const set = (name: string, value: number) => {
|
|
260
|
+
const input = form?.elements.namedItem(name);
|
|
261
|
+
if (input instanceof HTMLInputElement) {
|
|
262
|
+
input.value = value.toFixed(6);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
set('latitude', position.coords.latitude);
|
|
266
|
+
set('longitude', position.coords.longitude);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { breadcrumbJsonLd, jsonLdScript, pageMeta } from '@magicstoreai/hydrogen/seo';
|
|
3
|
+
import { CollectionView } from '@/components/analytics-views';
|
|
4
|
+
import { Pager } from '@/components/pager';
|
|
5
|
+
import { ProductGrid } from '@/components/product-grid';
|
|
6
|
+
import { api, orNotFound } from '@/lib/api';
|
|
7
|
+
|
|
8
|
+
type Props = { params: Promise<{ handle: string }>; searchParams: Promise<{ page?: string }> };
|
|
9
|
+
|
|
10
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
11
|
+
const { handle } = await params;
|
|
12
|
+
const { data: collection } = await orNotFound(api.collectionsShow({ path: { handle } }));
|
|
13
|
+
const meta = pageMeta({
|
|
14
|
+
seo: collection.seo,
|
|
15
|
+
title: collection.title,
|
|
16
|
+
description: collection.description,
|
|
17
|
+
});
|
|
18
|
+
return { title: meta.title, description: meta.description };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default async function CollectionPage({ params, searchParams }: Props) {
|
|
22
|
+
const { handle } = await params;
|
|
23
|
+
const page = Math.max(1, Number((await searchParams).page) || 1);
|
|
24
|
+
const [{ data: collection }, products] = await Promise.all([
|
|
25
|
+
orNotFound(api.collectionsShow({ path: { handle } })),
|
|
26
|
+
orNotFound(api.collectionsProducts({ path: { handle }, query: { page, perPage: 24 } })),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<script
|
|
32
|
+
type="application/ld+json"
|
|
33
|
+
dangerouslySetInnerHTML={{
|
|
34
|
+
__html: jsonLdScript(
|
|
35
|
+
breadcrumbJsonLd([
|
|
36
|
+
{ name: 'Home', url: '/' },
|
|
37
|
+
{ name: collection.title, url: `/collections/${collection.handle}` },
|
|
38
|
+
]),
|
|
39
|
+
),
|
|
40
|
+
}}
|
|
41
|
+
/>
|
|
42
|
+
<CollectionView collectionId={collection.id} />
|
|
43
|
+
<h1>{collection.title}</h1>
|
|
44
|
+
<ProductGrid products={products.data} />
|
|
45
|
+
<Pager meta={products.meta.pagination} href={(p) => `/collections/${handle}?page=${p}`} />
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { MagicStoreError } from '@magicstoreai/hydrogen';
|
|
4
|
+
|
|
5
|
+
/** Any failure a page did not handle itself: the API's detail when there is one, and a retry. */
|
|
6
|
+
export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) {
|
|
7
|
+
const unavailable = error instanceof MagicStoreError && error.code === 'STORE_UNAVAILABLE';
|
|
8
|
+
return (
|
|
9
|
+
<div className="stack">
|
|
10
|
+
<h1>{unavailable ? 'The shop is temporarily closed' : 'Something went wrong'}</h1>
|
|
11
|
+
{error instanceof MagicStoreError && error.detail && <p>{error.detail}</p>}
|
|
12
|
+
<div className="row">
|
|
13
|
+
<button onClick={reset}>Try again</button>
|
|
14
|
+
<a href="/">Back to the shop</a>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|