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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/dist/index.js +222 -0
  4. package/package.json +31 -0
  5. package/template/.env.example +22 -0
  6. package/template/AGENTS.md +56 -0
  7. package/template/CLAUDE.md +1 -0
  8. package/template/PAGES.md +154 -0
  9. package/template/README.md +31 -0
  10. package/template/_gitignore +5 -0
  11. package/template/app/account/page.tsx +117 -0
  12. package/template/app/api/magicstore/webhook/route.ts +9 -0
  13. package/template/app/cart/page.tsx +124 -0
  14. package/template/app/checkout/page.tsx +268 -0
  15. package/template/app/collections/[handle]/page.tsx +48 -0
  16. package/template/app/error.tsx +18 -0
  17. package/template/app/globals.css +268 -0
  18. package/template/app/layout.tsx +63 -0
  19. package/template/app/not-found.tsx +10 -0
  20. package/template/app/page.tsx +16 -0
  21. package/template/app/pages/[handle]/page.tsx +28 -0
  22. package/template/app/products/[handle]/page.tsx +47 -0
  23. package/template/app/providers.tsx +49 -0
  24. package/template/app/search/page.tsx +37 -0
  25. package/template/app/sitemap.ts +36 -0
  26. package/template/app/storefront-api/[...path]/route.ts +83 -0
  27. package/template/components/analytics-views.tsx +18 -0
  28. package/template/components/buy-box.tsx +86 -0
  29. package/template/components/cart-link.tsx +9 -0
  30. package/template/components/pager.tsx +25 -0
  31. package/template/components/product-grid.tsx +32 -0
  32. package/template/components/sections/announcement-bar.tsx +13 -0
  33. package/template/components/sections/banner.tsx +41 -0
  34. package/template/components/sections/collections.tsx +67 -0
  35. package/template/components/sections/deal-of-day.tsx +48 -0
  36. package/template/components/sections/index.tsx +66 -0
  37. package/template/components/sections/product-shelves.tsx +123 -0
  38. package/template/components/sections/shoppable-stories.tsx +39 -0
  39. package/template/components/sections/store-reviews.tsx +67 -0
  40. package/template/components/sections/types.ts +10 -0
  41. package/template/lib/api.ts +35 -0
  42. package/template/lib/errors.ts +9 -0
  43. package/template/lib/upstream.ts +12 -0
  44. package/template/llms.txt +128 -0
  45. package/template/next.config.ts +9 -0
  46. package/template/package.json +26 -0
  47. 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
+ }