create-magic-storefront 0.2.0 → 0.4.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 (45) hide show
  1. package/README.md +18 -1
  2. package/dist/index.js +523 -9
  3. package/package.json +7 -1
  4. package/template/.claude/skills/theme-section/SKILL.md +157 -0
  5. package/template/.env.example +7 -1
  6. package/template/.screenshots/home-1280.png +0 -0
  7. package/template/.screenshots/home-390.png +0 -0
  8. package/template/.screenshots/home-filled-1280.png +0 -0
  9. package/template/.screenshots/home-filled-390.png +0 -0
  10. package/template/.screenshots/page-1280.png +0 -0
  11. package/template/AGENTS.md +58 -16
  12. package/template/PAGES.md +107 -30
  13. package/template/README.md +13 -1
  14. package/template/_gitignore +2 -0
  15. package/template/app/account/page.tsx +126 -10
  16. package/template/app/api/magicstore/preview/route.ts +56 -0
  17. package/template/app/globals.css +146 -4
  18. package/template/app/layout.tsx +25 -6
  19. package/template/app/page.tsx +18 -9
  20. package/template/app/pages/[handle]/page.tsx +26 -11
  21. package/template/components/sections/collections.tsx +2 -1
  22. package/template/components/sections/product-shelves.tsx +2 -1
  23. package/template/components/telegram-shell.tsx +54 -0
  24. package/template/lib/api.ts +40 -10
  25. package/template/lib/errors.ts +21 -0
  26. package/template/lib/i18n.ts +33 -0
  27. package/template/lib/preview.ts +21 -0
  28. package/template/llms.txt +175 -2
  29. package/template/package.json +8 -3
  30. package/template/scripts/theme.mjs +53 -0
  31. package/template/theme/index.ts +53 -0
  32. package/template/theme/sections/collection-list.tsx +64 -0
  33. package/template/theme/sections/featured-collection.tsx +56 -0
  34. package/template/theme/sections/hero.tsx +95 -0
  35. package/template/theme/sections/image-with-text.tsx +71 -0
  36. package/template/theme/sections/page-content.tsx +45 -0
  37. package/template/theme/sections/platform-home.tsx +37 -0
  38. package/template/theme/sections/product-shelf.tsx +96 -0
  39. package/template/theme/sections/rich-text.tsx +43 -0
  40. package/template/theme/sections/testimonials.tsx +84 -0
  41. package/template/theme/settings.ts +19 -0
  42. package/template/theme/templates/index.json +9 -0
  43. package/template/theme/templates/page.json +6 -0
  44. package/template/theme/text.ts +21 -0
  45. package/template/tsconfig.json +1 -1
@@ -1,25 +1,55 @@
1
1
  import 'server-only';
2
- import { MagicStoreError, createStorefrontClient } from '@magicstoreai/storefront-client';
2
+ import {
3
+ MagicStoreError,
4
+ createStorefrontClient,
5
+ type StorefrontClient,
6
+ } from '@magicstoreai/storefront-client';
3
7
  import { nextCacheFetch } from '@magicstoreai/hydrogen/server';
4
8
  import { notFound } from 'next/navigation';
5
9
 
6
10
  import { upstreamBaseUrl } from './upstream';
7
11
 
8
- const baseUrl = upstreamBaseUrl();
9
- if (baseUrl === null) {
10
- throw new Error('Set MAGICSTORE_SHOP_DOMAIN or MAGICSTORE_API_URL (see .env.example).');
12
+ export const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
13
+
14
+ /**
15
+ * The storefront access token server-side reads go with (`X-Storefront-Token`), or `null` to be
16
+ * identified by the shop's domain. It picks this storefront's own theme, and a theme preview needs
17
+ * it: the preview is signed with this token's webhook secret.
18
+ */
19
+ export function storefrontToken(): string | null {
20
+ return (
21
+ process.env.MAGICSTORE_STOREFRONT_TOKEN ||
22
+ process.env.NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN ||
23
+ null
24
+ );
11
25
  }
12
26
 
13
- export const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
27
+ let client: StorefrontClient | null = null;
28
+
29
+ function storefrontClient(): StorefrontClient {
30
+ if (client === null) {
31
+ const baseUrl = upstreamBaseUrl();
32
+ if (baseUrl === null) {
33
+ throw new Error('Set MAGICSTORE_SHOP_DOMAIN or MAGICSTORE_API_URL (see .env.example).');
34
+ }
35
+ const token = storefrontToken();
36
+ client = createStorefrontClient({
37
+ baseUrl,
38
+ locale,
39
+ ...(token ? { storefrontToken: token } : {}),
40
+ fetch: nextCacheFetch({ revalidate: 600 }),
41
+ });
42
+ }
43
+ return client;
44
+ }
14
45
 
15
46
  /**
16
47
  * The server-side client. Public reads are cached under the tags the webhook revalidates; nothing
17
- * personal is ever called from here.
48
+ * personal is ever called from here. It is created on first use, so importing a module that uses
49
+ * it (a theme section, for `create-magic-storefront theme check`) needs no shop configured.
18
50
  */
19
- export const api = createStorefrontClient({
20
- baseUrl,
21
- locale,
22
- fetch: nextCacheFetch({ revalidate: 600 }),
51
+ export const api: StorefrontClient = new Proxy({} as StorefrontClient, {
52
+ get: (_, method) => Reflect.get(storefrontClient(), method),
23
53
  });
24
54
 
25
55
  /** Runs a read and turns the API's NOT_FOUND into the page's 404. */
@@ -5,3 +5,24 @@ import { MagicStoreError } from '@magicstoreai/storefront-client';
5
5
  export function describe(error: unknown, fallback: string): string {
6
6
  return error instanceof MagicStoreError ? (error.detail ?? error.message) : fallback;
7
7
  }
8
+
9
+ /**
10
+ * Why sharing the Telegram phone (`POST /customer/phone-verification`) failed. `CONTACT_EXPIRED`
11
+ * asks to share again; `CONTACT_INVALID`, `CONTACT_NOT_OWN`, `INVALID_PHONE` and `TAKEN` show the
12
+ * API's localized message for `telegramContact`; anything else falls back to `describe()`.
13
+ */
14
+ export function describePhoneShare(
15
+ error: unknown,
16
+ text: { sharePhoneAgain: string; tryAgainLater: string },
17
+ ): string {
18
+ if (error instanceof MagicStoreError) {
19
+ const refusal = error.errors.find((entry) => entry.field === 'telegramContact');
20
+ if (refusal?.code === 'CONTACT_EXPIRED') {
21
+ return text.sharePhoneAgain;
22
+ }
23
+ if (refusal !== undefined) {
24
+ return refusal.message;
25
+ }
26
+ }
27
+ return describe(error, text.tryAgainLater);
28
+ }
@@ -57,6 +57,8 @@ const en = {
57
57
  `${count} ${plural('en', count, { one: 'review', other: 'reviews' })}`,
58
58
  recommendUs: (percent: number) => `${percent}% recommend us`,
59
59
  leaveReview: 'Leave a review',
60
+ shopNow: 'Shop now',
61
+ ratedOutOf: (rating: number) => `Rated ${rating} out of 5`,
60
62
  // Blog
61
63
  blog: 'Blog',
62
64
  allArticles: 'All articles',
@@ -117,6 +119,16 @@ const en = {
117
119
  signIn: 'Sign in',
118
120
  sendCode: 'Send code',
119
121
  smsCode: 'Code from the SMS',
122
+ // Telegram Mini App
123
+ signInWithTelegram: 'Sign in with Telegram',
124
+ signingIn: 'Signing in…',
125
+ telegramRelaunch:
126
+ 'Your Telegram sign-in has expired. Close the shop and open it again from the bot.',
127
+ telegramUnavailable: 'Sign-in with Telegram is not available in this shop.',
128
+ orWithPhone: 'Or sign in with your phone number',
129
+ sharePhone: 'Share phone number',
130
+ sharePhoneHint: 'Share your Telegram phone number so the shop can reach you about orders.',
131
+ sharePhoneAgain: 'That contact has expired. Share it again.',
120
132
  signOut: 'Sign out',
121
133
  orders: 'Orders',
122
134
  loadingOrders: 'Loading orders…',
@@ -172,6 +184,8 @@ const ru: Messages = {
172
184
  `${count} ${plural('ru', count, { one: 'отзыв', few: 'отзыва', other: 'отзывов' })}`,
173
185
  recommendUs: (percent) => `${percent}% рекомендуют нас`,
174
186
  leaveReview: 'Оставить отзыв',
187
+ shopNow: 'Перейти в каталог',
188
+ ratedOutOf: (rating: number) => `Оценка ${rating} из 5`,
175
189
  blog: 'Блог',
176
190
  allArticles: 'Все статьи',
177
191
  featured: 'Выбор редакции',
@@ -227,6 +241,14 @@ const ru: Messages = {
227
241
  signIn: 'Войти',
228
242
  sendCode: 'Получить код',
229
243
  smsCode: 'Код из SMS',
244
+ signInWithTelegram: 'Войти через Telegram',
245
+ signingIn: 'Входим…',
246
+ telegramRelaunch: 'Вход через Telegram устарел. Закройте магазин и откройте его снова из бота.',
247
+ telegramUnavailable: 'Вход через Telegram в этом магазине недоступен.',
248
+ orWithPhone: 'Или войдите по номеру телефона',
249
+ sharePhone: 'Поделиться номером',
250
+ sharePhoneHint: 'Поделитесь номером из Telegram, чтобы магазин мог связаться с вами по заказам.',
251
+ sharePhoneAgain: 'Контакт устарел. Поделитесь им ещё раз.',
230
252
  signOut: 'Выйти',
231
253
  orders: 'Заказы',
232
254
  loadingOrders: 'Загружаем заказы…',
@@ -279,6 +301,8 @@ const uz: Messages = {
279
301
  reviewsCount: (count) => `${count} ta sharh`,
280
302
  recommendUs: (percent) => `${percent}% bizni tavsiya qiladi`,
281
303
  leaveReview: 'Sharh qoldirish',
304
+ shopNow: "Katalogga o'tish",
305
+ ratedOutOf: (rating: number) => `Baho: ${rating} / 5`,
282
306
  blog: 'Blog',
283
307
  allArticles: 'Barcha maqolalar',
284
308
  featured: 'Muharrir tanlovi',
@@ -334,6 +358,15 @@ const uz: Messages = {
334
358
  signIn: 'Kirish',
335
359
  sendCode: 'Kod olish',
336
360
  smsCode: 'SMS dagi kod',
361
+ signInWithTelegram: 'Telegram orqali kirish',
362
+ signingIn: 'Kirilmoqda…',
363
+ telegramRelaunch: "Telegram orqali kirish eskirdi. Do'konni yoping va botdan qayta oching.",
364
+ telegramUnavailable: "Bu do'konda Telegram orqali kirish mavjud emas.",
365
+ orWithPhone: 'Yoki telefon raqami bilan kiring',
366
+ sharePhone: 'Raqamni ulashish',
367
+ sharePhoneHint:
368
+ "Do'kon buyurtmalar bo'yicha bog'lana olishi uchun Telegram raqamingizni ulashing.",
369
+ sharePhoneAgain: 'Kontakt eskirdi. Uni qayta ulashing.',
337
370
  signOut: 'Chiqish',
338
371
  orders: 'Buyurtmalar',
339
372
  loadingOrders: 'Buyurtmalar yuklanmoqda…',
@@ -0,0 +1,21 @@
1
+ import 'server-only';
2
+ import { THEME_PREVIEW_COOKIE, verifyThemePreview } from '@magicstoreai/hydrogen/server';
3
+ import { cookies, draftMode } from 'next/headers';
4
+
5
+ import { storefrontToken } from './api';
6
+
7
+ /**
8
+ * The admin's theme preview token for this request, or `null` for every ordinary visitor. Only
9
+ * read in draft mode (set by /api/magicstore/preview), so pages stay static and cached for
10
+ * everyone else; re-verified here so an expired token falls back to the live theme instead of a
11
+ * THEME_PREVIEW_INVALID error. Off without a storefront token: the API cannot preview a request it
12
+ * identifies by its domain.
13
+ */
14
+ export async function themePreview(): Promise<string | null> {
15
+ const secret = process.env.MAGICSTORE_WEBHOOK_SECRET;
16
+ if (!secret || !storefrontToken() || !(await draftMode()).isEnabled) {
17
+ return null;
18
+ }
19
+ const token = (await cookies()).get(THEME_PREVIEW_COOKIE)?.value ?? null;
20
+ return (await verifyThemePreview(secret, token)) ? token : null;
21
+ }
package/template/llms.txt CHANGED
@@ -12,8 +12,9 @@ Every storefront — hand-written or generated — imports the SDK **only** thro
12
12
  | `@magicstoreai/storefront-client` | `createStorefrontClient`: one typed method per API `operationId`, `MagicStoreError`, `paginate`, `Schema<>` | anywhere, no React |
13
13
  | `@magicstoreai/hydrogen` | React: `MagicStoreProvider`, `useCart`, `useCustomer`, `useWishlist`, `<Money>`, `<Image>`, … | client (`"use client"`) |
14
14
  | `@magicstoreai/hydrogen/core` | The same state without React: `CartController`, `CustomerSessionController`, `formatMoney`, variants, blog | anywhere |
15
- | `@magicstoreai/hydrogen/server` | `nextCacheFetch` (cache tags), `createWebhookHandler` (signed webhooks → revalidation) | server, edge |
15
+ | `@magicstoreai/hydrogen/server` | `nextCacheFetch` (cache tags), `createWebhookHandler`, `loadTemplate`, `loadThemeSettings`, `verifyThemePreview` | server, edge |
16
16
  | `@magicstoreai/hydrogen/seo` | `pageMeta`, `productJsonLd`, `articleMeta`, `articleJsonLd`, `breadcrumbJsonLd`, `jsonLdScript` | anywhere |
17
+ | `@magicstoreai/hydrogen/theme` | Themes: `defineSection`, `defineBlock`, `defineTheme`, `ThemeSections`, `validateTemplate`, `resolveTemplate` | anywhere |
17
18
 
18
19
  Never import from `src/` or `dist/` of a package, and never call `/api/v2/storefront/…` by URL —
19
20
  call the client method. A server component takes only components (`<Image>`, `<Money>`, …) from
@@ -54,7 +55,7 @@ product, search, blog, cart, checkout, sign-in, orders, SEO, webhook revalidatio
54
55
  any other origin the API answers `ORIGIN_NOT_ALLOWED`. Without a token for the browser, proxy
55
56
  browser calls through your own server (the starter's `app/storefront-api/[...path]`).
56
57
  - **Which customer** — a customer session: access token (60 min) + refresh token (30 days,
57
- single-use), only on customer routes. `useCustomer()` signs in (OTP, Telegram, OQ, Click),
58
+ single-use), only on customer routes. `useCustomer()` signs in (OTP, Telegram, phone-only, OQ, Click),
58
59
  refreshes and signs out; `MagicStoreProvider` keeps the session in storage.
59
60
 
60
61
  Public catalog reads need no customer. Render them on the server with a client whose `fetch` is
@@ -144,6 +145,178 @@ for buyers), quote `error.requestId` when reporting. GET calls retry on 429 / 5x
144
145
 
145
146
  The full list, with the `meta` each code carries, is in the backend's `docs/api/v2/standards.md`.
146
147
 
148
+ ## Telegram Mini App
149
+
150
+ The same storefront runs inside Telegram as a Mini App (`shop.features.telegramBot` says the shop has
151
+ a bot). The app loads `TELEGRAM_WEB_APP_SCRIPT` (from `@magicstoreai/hydrogen/core`) in the document
152
+ head; the SDK never injects it.
153
+
154
+ - `useTelegramWebApp({ documentAttributes: true })`: `status` is `unknown` on the server and the first
155
+ client render, then `telegram` or `browser` (a Mini App = `window.Telegram.WebApp` with a non-empty
156
+ `initData`). Marks `<html data-telegram data-color-scheme>`; map Telegram's `--tg-theme-*` CSS
157
+ variables onto your tokens under `html[data-telegram]`.
158
+ - `useTelegramSignIn()`: signs in once per provider with `initData` (`POST /auth/telegram`) when
159
+ opened from the bot, unless the stored session was signed in as that same Telegram user (any other
160
+ session is signed out first). `failed` + `UNAUTHENTICATED` → "reopen the shop from the bot";
161
+ `FORBIDDEN` → the shop has no bot. `status` / `error` are shared under one provider, so an
162
+ `{ auto: false }` page shows the automatic attempt's progress and failure.
163
+ - `useTelegramBackButton(visible, onBack)`, `useTelegramMainButton(options | null)`: one button per
164
+ page, registrations stack (the latest owns it); MainButton changes apply in place.
165
+ - `useCustomer().shareTelegramPhone()`: `requestContact` → `POST /customer/phone-verification` with
166
+ the signed `response`; `null` when declined; rejects `UNAUTHENTICATED` at once when nobody is
167
+ signed in (show the button to signed-in customers only, and only while
168
+ `useTelegramWebApp().canRequestContact` — older clients resolve `null` without asking). Refusals: `VALIDATION_FAILED` with `CONTACT_EXPIRED`
169
+ (share again), `CONTACT_INVALID`, `CONTACT_NOT_OWN`, `INVALID_PHONE`, `TAKEN` on `telegramContact`.
170
+ - `useCustomer().signInWithPhone(phone)`: only when `shop.features.otpLogin === false`.
171
+
172
+ - `useCustomer().verifyPhoneWithTelegram(telegramContact)`: the same proof with a contact the app
173
+ got itself. `/core`: `TelegramWebAppController.mainButton(options)` → `{ update, release }`.
174
+ - Call `useTelegramSignIn()` with `auto` in exactly one app-wide component; anywhere else
175
+ `{ auto: false }` + `signIn()`. Load the script only when `shop.features.telegramBot` is on. Starter
176
+ reference: `examples/starter/PAGES.md` → "Running as a Telegram Mini App".
177
+
178
+ The SDK does not validate `initData` (the server does), does not parse referrals out of `startapp`
179
+ (the server attributes them), and never sends `responseUnsafe`. Never put `initData` in a URL, a log
180
+ or analytics.
181
+
182
+ ## Themes
183
+
184
+ A storefront is a **theme** (Shopify Online Store 2.0 style): its pages are templates
185
+ (`theme/templates/index.json` for the home, `page.json` for content pages) made of sections the
186
+ merchant reorders and edits in the admin's theme editor. `create-magic-storefront theme push` (a
187
+ `mtt_` theme deploy token, server-side only, never in a browser) sends the manifest; pages read the
188
+ merchant's version with `loadTemplate(api, 'index', theme)` and render it with `ThemeSections`. Until
189
+ a theme is pushed, the bundled templates render. Webhooks `THEME_UPDATED` / `TEMPLATE_UPDATED`
190
+ revalidate `magicstore:theme` / `magicstore:template:<name>`. The editor's preview opens
191
+ `/api/magicstore/preview?token=…`, verified with `verifyThemePreview` (the webhook secret). Guide:
192
+ `docs/themes.md` in the SDK repository.
193
+
194
+ ## Creating a new section
195
+
196
+ Invent any section the design needs (`size-guide`, `lookbook`, `faq`): the backend has no code per
197
+ section, so a section type it has never seen is fine — its schema arrives with `theme push`, and the
198
+ editor, the validation and the merchant's AI tools work for it at once. A **setting type** it has
199
+ never seen is not: compose the types below and never invent one; a missing type is a contract change
200
+ in magicbot, made there first. `GET /home` is optional — data sections fetch through the catalog
201
+ operations.
202
+
203
+ 1. Create `theme/sections/<type>.tsx` with `defineSection` and the component
204
+ (`SectionProps<typeof section>` types its `settings` and `blocks`).
205
+ 2. Register it in `theme/index.ts`: `sections` of `defineTheme` and the `components` map.
206
+ 3. Put it in a template (`theme/templates/*.json`) or give it `presets`, so the merchant can add it.
207
+ 4. Every word the buyer sees comes from a setting with a default in every locale (`en`, `ru`, `uz`).
208
+ No copy written into the component.
209
+ 5. Run `create-magic-storefront theme check` (must pass), then `theme push`, bumping the theme's
210
+ `version`; `theme dev` pushes on every change while developing.
211
+
212
+ | Type | Extra members | The component receives |
213
+ | ----------------- | --------------------------------------- | --------------------------------------------------------- |
214
+ | `TEXT` | `maxLength` (≤ 500) | `string` |
215
+ | `TEXTAREA` | `maxLength` (≤ 5 000) | `string` |
216
+ | `RICHTEXT` | — | sanitised HTML `string` (the only HTML a section renders) |
217
+ | `URL` | — | `string \| null` |
218
+ | `COLOR` | — | `#rrggbb` or `null` |
219
+ | `CHECKBOX` | — | `boolean` |
220
+ | `NUMBER` | `min`, `max` (optional) | `number \| null` |
221
+ | `RANGE` | `min`, `max`, `step`, `unit` (optional) | `number` |
222
+ | `SELECT` | `options: [{ value, label }]` (≤ 100) | one option `value` |
223
+ | `IMAGE` | — | `{ url, altText: null, width, height }` or `null` |
224
+ | `COLLECTION` | — | `{ id, handle, title }` or `null` |
225
+ | `PRODUCT` | — | `{ id, handle, title }` or `null` |
226
+ | `COLLECTION_LIST` | `limit` (1–50) | `{ id, handle, title }[]` |
227
+ | `PRODUCT_LIST` | `limit` (1–50) | `{ id, handle, title }[]` |
228
+ | `HEADER` | `content` (no `id`) | — (a heading in the editor form) |
229
+ | `PARAGRAPH` | `content` (no `id`) | — (a note in the editor form) |
230
+
231
+ Ids are camelCase; `SELECT` values are `UPPER_SNAKE_CASE`. Text is stored per locale and served in
232
+ the request's. References (`IMAGE`, `COLLECTION`, `PRODUCT`, lists) default to empty and are not full
233
+ products — fetch what you show by `handle`. Blocks (`defineBlock`) are repeatable children: slides,
234
+ quotes, table rows.
235
+
236
+ Worked example, `theme/sections/size-guide.tsx` (a heading and a table of `row` blocks):
237
+
238
+ ```tsx
239
+ import { defineBlock, defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
240
+
241
+ import { label } from '../text';
242
+
243
+ const row = defineBlock({
244
+ type: 'row',
245
+ name: label('Size', 'Размер', "O'lcham"),
246
+ settings: [
247
+ { id: 'size', type: 'TEXT', label: label('Size', 'Размер', "O'lcham"), default: '' },
248
+ { id: 'chest', type: 'TEXT', label: label('Chest', 'Грудь', "Ko'krak"), default: '' },
249
+ { id: 'waist', type: 'TEXT', label: label('Waist', 'Талия', 'Bel'), default: '' },
250
+ ],
251
+ });
252
+
253
+ export const sizeGuide = defineSection({
254
+ type: 'size-guide',
255
+ name: label('Size guide', 'Таблица размеров', "O'lchamlar jadvali"),
256
+ maxBlocks: 30,
257
+ settings: [
258
+ {
259
+ id: 'heading',
260
+ type: 'TEXT',
261
+ label: label('Heading', 'Заголовок', 'Sarlavha'),
262
+ default: { en: 'Size guide', ru: 'Таблица размеров', uz: "O'lchamlar jadvali" },
263
+ },
264
+ {
265
+ id: 'columns',
266
+ type: 'TEXT',
267
+ label: label('Column names', 'Названия колонок', 'Ustun nomlari'),
268
+ info: label('Separated by commas.', 'Через запятую.', 'Vergul bilan.'),
269
+ default: {
270
+ en: 'Size, Chest cm, Waist cm',
271
+ ru: 'Размер, Грудь см, Талия см',
272
+ uz: "O'lcham, Ko'krak sm, Bel sm",
273
+ },
274
+ },
275
+ ],
276
+ blocks: [row],
277
+ presets: [
278
+ {
279
+ name: label('Size guide', 'Таблица размеров', "O'lchamlar jadvali"),
280
+ blocks: [{ type: 'row' }, { type: 'row' }, { type: 'row' }],
281
+ },
282
+ ],
283
+ });
284
+
285
+ /** A size table the merchant fills row by row; nothing shows until a row has a size. */
286
+ export function SizeGuide({ settings, blocks }: SectionProps<typeof sizeGuide>) {
287
+ const rows = blocks.filter((block) => block.settings.size !== '');
288
+ if (rows.length === 0) {
289
+ return null;
290
+ }
291
+ return (
292
+ <section className="stack">
293
+ <h2>{settings.heading}</h2>
294
+ <table>
295
+ <thead>
296
+ <tr>
297
+ {settings.columns.split(',').map((name) => (
298
+ <th key={name}>{name.trim()}</th>
299
+ ))}
300
+ </tr>
301
+ </thead>
302
+ <tbody>
303
+ {rows.map(({ id, settings: cells }) => (
304
+ <tr key={id}>
305
+ <td>{cells.size}</td>
306
+ <td>{cells.chest}</td>
307
+ <td>{cells.waist}</td>
308
+ </tr>
309
+ ))}
310
+ </tbody>
311
+ </table>
312
+ </section>
313
+ );
314
+ }
315
+ ```
316
+
317
+ Register it (`sizeGuide` in `sections`, `'size-guide': SizeGuide` in `components`), then
318
+ `theme check` and `theme push`.
319
+
147
320
  ## What the SDK does not do
148
321
 
149
322
  - Compute or round prices, discounts, taxes, delivery fees or stock — the API returns them.
@@ -4,10 +4,12 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "next dev",
8
- "build": "next build",
7
+ "dev": "node scripts/theme.mjs dev",
8
+ "build": "node scripts/theme.mjs prebuild && next build",
9
9
  "start": "next start",
10
- "typecheck": "tsc --noEmit"
10
+ "typecheck": "tsc --noEmit",
11
+ "theme:check": "create-magic-storefront theme check",
12
+ "theme:push": "create-magic-storefront theme push"
11
13
  },
12
14
  "dependencies": {
13
15
  "@magicstoreai/hydrogen": "workspace:*",
@@ -18,9 +20,12 @@
18
20
  "server-only": "^0.0.1"
19
21
  },
20
22
  "devDependencies": {
23
+ "@testing-library/react": "^16.3.3",
21
24
  "@types/node": "^26.6.2",
22
25
  "@types/react": "^19.3.0",
23
26
  "@types/react-dom": "^19.3.0",
27
+ "create-magic-storefront": "workspace:*",
28
+ "jsdom": "^30.1.1",
24
29
  "typescript": "5.9.3"
25
30
  }
26
31
  }
@@ -0,0 +1,53 @@
1
+ // Keeps the theme the admin edits in step with this code.
2
+ //
3
+ // node scripts/theme.mjs dev next dev, plus `theme dev` (push to DEVELOPMENT on every change
4
+ // under theme/) when MAGICSTORE_THEME_TOKEN is set
5
+ // node scripts/theme.mjs prebuild (before `next build`) `theme check`, plus `theme push --publish` when
6
+ // MAGICSTORE_THEME_PUBLISH=1, so a deployed storefront never
7
+ // renders a section the backend does not know
8
+ //
9
+ // MAGICSTORE_THEME_TOKEN is the theme deploy token (mtt_…): server-side only, never NEXT_PUBLIC_.
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import { existsSync } from 'node:fs';
13
+ import { createRequire } from 'node:module';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ for (const file of ['.env.local', '.env']) {
17
+ if (existsSync(file)) {
18
+ process.loadEnvFile(file);
19
+ }
20
+ }
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const cliPackage = require.resolve('create-magic-storefront/package.json');
24
+ const cli = join(dirname(cliPackage), require(cliPackage).bin['create-magic-storefront']);
25
+
26
+ function run(command, args) {
27
+ return new Promise((resolve) => {
28
+ const child = spawn(command, args, { stdio: 'inherit' });
29
+ child.on('exit', (code) => resolve(code ?? 1));
30
+ });
31
+ }
32
+
33
+ const theme = (...args) => run(process.execPath, [cli, 'theme', ...args]);
34
+ const mode = process.argv[2];
35
+
36
+ if (mode === 'dev') {
37
+ const next = run(process.execPath, [require.resolve('next/dist/bin/next'), 'dev']);
38
+ if (process.env.MAGICSTORE_THEME_TOKEN) {
39
+ theme('dev');
40
+ }
41
+ process.exit(await next);
42
+ } else if (mode === 'prebuild') {
43
+ const checked = await theme('check');
44
+ if (checked !== 0) {
45
+ process.exit(checked);
46
+ }
47
+ if (process.env.MAGICSTORE_THEME_PUBLISH === '1') {
48
+ process.exit(await theme('push', '--publish'));
49
+ }
50
+ } else {
51
+ console.error('Usage: node scripts/theme.mjs dev|prebuild');
52
+ process.exit(1);
53
+ }
@@ -0,0 +1,53 @@
1
+ import { defineTheme, type SectionComponents } from '@magicstoreai/hydrogen/theme';
2
+
3
+ import { CollectionList, collectionList } from './sections/collection-list';
4
+ import { FeaturedCollection, featuredCollection } from './sections/featured-collection';
5
+ import { Hero, hero } from './sections/hero';
6
+ import { ImageWithText, imageWithText } from './sections/image-with-text';
7
+ import { pageContent } from './sections/page-content';
8
+ import { PlatformHome, platformHome } from './sections/platform-home';
9
+ import { ProductShelf, productShelf } from './sections/product-shelf';
10
+ import { RichText, richText } from './sections/rich-text';
11
+ import { Testimonials, testimonials } from './sections/testimonials';
12
+ import { settings } from './settings';
13
+ import index from './templates/index.json';
14
+ import page from './templates/page.json';
15
+
16
+ /**
17
+ * The storefront's theme: what `create-magic-storefront theme push` sends, so the merchant edits
18
+ * these sections in the admin. A new section is registered here and in `components`, then pushed
19
+ * (`PAGES.md`, "Creating a new section"). Bump `version` with every push of a changed theme.
20
+ */
21
+ const theme = defineTheme({
22
+ name: 'starter',
23
+ version: '1.0.0',
24
+ sections: [
25
+ hero,
26
+ richText,
27
+ imageWithText,
28
+ featuredCollection,
29
+ collectionList,
30
+ testimonials,
31
+ productShelf,
32
+ pageContent,
33
+ platformHome,
34
+ ],
35
+ settings,
36
+ templates: { index, page },
37
+ });
38
+
39
+ export default theme;
40
+
41
+ /**
42
+ * One component per section type. `page-content` is bound to the page by `app/pages/[handle]`.
43
+ */
44
+ export const components: SectionComponents<typeof theme.sections> = {
45
+ hero: Hero,
46
+ 'rich-text': RichText,
47
+ 'image-with-text': ImageWithText,
48
+ 'featured-collection': FeaturedCollection,
49
+ 'collection-list': CollectionList,
50
+ testimonials: Testimonials,
51
+ 'product-shelf': ProductShelf,
52
+ 'platform-home': PlatformHome,
53
+ };
@@ -0,0 +1,64 @@
1
+ import { MagicStoreError } from '@magicstoreai/storefront-client';
2
+ import { defineBlock, defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
3
+
4
+ import { CollectionTiles } from '@/components/sections/collections';
5
+ import { api } from '@/lib/api';
6
+
7
+ import { label } from '../text';
8
+
9
+ export const collectionBlock = defineBlock({
10
+ type: 'collection',
11
+ name: label('Collection', 'Коллекция', "To'plam"),
12
+ settings: [
13
+ { id: 'collection', type: 'COLLECTION', label: label('Collection', 'Коллекция', "To'plam") },
14
+ ],
15
+ });
16
+
17
+ export const collectionList = defineSection({
18
+ type: 'collection-list',
19
+ name: label('Collections', 'Коллекции', "To'plamlar"),
20
+ icon: 'COLLECTIONS',
21
+ category: 'COLLECTIONS',
22
+ maxBlocks: 8,
23
+ settings: [
24
+ { id: 'heading', type: 'TEXT', label: label('Heading', 'Заголовок', 'Sarlavha'), default: '' },
25
+ ],
26
+ blocks: [collectionBlock],
27
+ presets: [
28
+ {
29
+ name: label('Collections', 'Коллекции', "To'plamlar"),
30
+ blocks: [{ type: 'collection' }, { type: 'collection' }, { type: 'collection' }],
31
+ },
32
+ ],
33
+ });
34
+
35
+ /** Tiles of the collections the merchant picked, in their order. */
36
+ export async function CollectionList({ settings, blocks }: SectionProps<typeof collectionList>) {
37
+ const handles = blocks.flatMap((block) =>
38
+ block.settings.collection ? [block.settings.collection.handle] : [],
39
+ );
40
+ const collections = await Promise.all(
41
+ handles.map((handle) =>
42
+ api.collectionsShow({ path: { handle } }).then(
43
+ ({ data }) => data,
44
+ (error: unknown) => {
45
+ // Unpublished between the template read and this one: leave the tile out.
46
+ if (error instanceof MagicStoreError && error.code === 'NOT_FOUND') {
47
+ return null;
48
+ }
49
+ throw error;
50
+ },
51
+ ),
52
+ ),
53
+ );
54
+ const found = collections.filter((collection) => collection !== null);
55
+ if (found.length === 0) {
56
+ return null;
57
+ }
58
+ return (
59
+ <section className="stack">
60
+ {settings.heading && <h2>{settings.heading}</h2>}
61
+ <CollectionTiles collections={found} />
62
+ </section>
63
+ );
64
+ }
@@ -0,0 +1,56 @@
1
+ import { defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
2
+
3
+ import { Shelf } from '@/components/sections/product-shelves';
4
+ import { api } from '@/lib/api';
5
+
6
+ import { label } from '../text';
7
+
8
+ export const featuredCollection = defineSection({
9
+ type: 'featured-collection',
10
+ name: label('Featured collection', 'Подборка из коллекции', "To'plamdan tanlov"),
11
+ icon: 'PRODUCTS',
12
+ category: 'PRODUCTS',
13
+ settings: [
14
+ { id: 'collection', type: 'COLLECTION', label: label('Collection', 'Коллекция', "To'plam") },
15
+ {
16
+ id: 'heading',
17
+ type: 'TEXT',
18
+ label: label('Heading', 'Заголовок', 'Sarlavha'),
19
+ info: label(
20
+ 'Empty: the collection title.',
21
+ 'Пусто — название коллекции.',
22
+ "Bo'sh — to'plam nomi.",
23
+ ),
24
+ default: '',
25
+ },
26
+ {
27
+ id: 'limit',
28
+ type: 'RANGE',
29
+ label: label('Products', 'Товаров', 'Mahsulotlar'),
30
+ min: 2,
31
+ max: 24,
32
+ step: 1,
33
+ default: 8,
34
+ },
35
+ ],
36
+ presets: [{ name: label('Featured collection', 'Подборка из коллекции', "To'plamdan tanlov") }],
37
+ });
38
+
39
+ /** Products of the collection the merchant picked; nothing until one is picked. */
40
+ export async function FeaturedCollection({ settings }: SectionProps<typeof featuredCollection>) {
41
+ const { collection } = settings;
42
+ if (collection === null) {
43
+ return null;
44
+ }
45
+ const { data } = await api.collectionsProducts({
46
+ path: { handle: collection.handle },
47
+ query: { perPage: settings.limit },
48
+ });
49
+ return (
50
+ <Shelf
51
+ title={settings.heading || collection.title}
52
+ href={`/collections/${collection.handle}`}
53
+ products={data}
54
+ />
55
+ );
56
+ }