create-magic-storefront 0.3.1 → 0.5.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 +12 -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 -18
  12. package/template/PAGES.md +48 -22
  13. package/template/README.md +6 -0
  14. package/template/_gitignore +2 -0
  15. package/template/app/api/magicstore/preview/route.ts +56 -0
  16. package/template/app/globals.css +108 -0
  17. package/template/app/layout.tsx +18 -7
  18. package/template/app/page.tsx +18 -9
  19. package/template/app/pages/[handle]/page.tsx +26 -11
  20. package/template/app/providers.tsx +15 -6
  21. package/template/app/storefront-api/[...path]/route.ts +11 -66
  22. package/template/components/sections/collections.tsx +2 -1
  23. package/template/components/sections/product-shelves.tsx +2 -1
  24. package/template/lib/api.ts +40 -10
  25. package/template/lib/i18n.ts +6 -0
  26. package/template/lib/preview.ts +21 -0
  27. package/template/lib/upstream.ts +21 -0
  28. package/template/llms.txt +153 -1
  29. package/template/package.json +6 -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
@@ -0,0 +1,157 @@
1
+ ---
2
+ name: theme-section
3
+ description: >-
4
+ Create or change a theme section — a part of a page the merchant edits in the admin's theme
5
+ editor (a hero, a size guide, a lookbook, a FAQ). Use whenever a page gets a new block of content,
6
+ when asked for a "section", or when a section's settings change. Covers the five steps, the setting
7
+ types a section may use, and a worked example.
8
+ ---
9
+
10
+ # Creating a new section
11
+
12
+ A section is a file in `theme/sections/`: its schema (`defineSection`) and its React component. The
13
+ backend has no code per section: `theme push` sends the schema, and the admin's editor, the API's
14
+ validation and the merchant's AI tools work for that section at once. **So invent any section the
15
+ design needs** (`size-guide`, `lookbook`, `faq`) — no one touches the backend.
16
+
17
+ What the backend does not know is a new **setting type**. Compose sections from the types below;
18
+ never invent one. A field no type covers is a contract change in magicbot, made there first.
19
+
20
+ `GET /home` is optional: the theme builds the home page. Data sections fetch through the catalog
21
+ operations (`productsIndex`, `collectionsProducts`, …), never through `/home`.
22
+
23
+ ## The five steps
24
+
25
+ 1. Create `theme/sections/<type>.tsx` with `defineSection` and the component (`SectionProps<typeof
26
+ section>` types its `settings` and `blocks`).
27
+ 2. Register it in `theme/index.ts`: in `sections` of `defineTheme`, and its component in `components`.
28
+ 3. Put it in a template (`theme/templates/index.json` or `page.json`) or give it `presets`, so the
29
+ merchant can add it in the editor.
30
+ 4. Every word the buyer sees comes from a setting with a default in `en`, `ru` and `uz` (or from
31
+ `lib/i18n.ts` through `message()`), so the merchant can change it. Editor labels use `label(en,
32
+ ru, uz)` from `theme/text.ts`. No copy written into the component.
33
+ 5. Run `npm run theme:check` (it must pass), then `npm run theme:push`, and bump `version` in
34
+ `theme/index.ts`. While developing, `npm run dev` with `MAGICSTORE_THEME_TOKEN` set pushes on
35
+ every change (`theme dev`).
36
+
37
+ `theme check` fails on a file in `theme/sections/` that is not registered (`SECTION_NOT_REGISTERED`),
38
+ a template that uses an unknown type, and a text setting without a default in every locale; it warns
39
+ on copy in a component (`HARDCODED_COPY`; `// theme-copy-ok` on the line opts out).
40
+
41
+ ## Setting types
42
+
43
+ The component receives the served value. Ids are camelCase (`^[a-z][a-zA-Z0-9]{0,63}$`); `SELECT`
44
+ values are `UPPER_SNAKE_CASE`; every setting takes `label`, optional `info` and `default`.
45
+
46
+ | Type | Extra members | The component receives |
47
+ | ----------------- | --------------------------------------- | --------------------------------------------------------- |
48
+ | `TEXT` | `maxLength` (≤ 500) | `string` |
49
+ | `TEXTAREA` | `maxLength` (≤ 5 000) | `string` |
50
+ | `RICHTEXT` | — | sanitised HTML `string` (the only HTML a section renders) |
51
+ | `URL` | — | `string \| null` |
52
+ | `COLOR` | — | `#rrggbb` or `null` |
53
+ | `CHECKBOX` | — | `boolean` |
54
+ | `NUMBER` | `min`, `max` (optional) | `number \| null` |
55
+ | `RANGE` | `min`, `max`, `step`, `unit` (optional) | `number` |
56
+ | `SELECT` | `options: [{ value, label }]` (≤ 100) | one option `value` |
57
+ | `IMAGE` | — | `{ url, altText: null, width, height }` or `null` |
58
+ | `COLLECTION` | — | `{ id, handle, title }` or `null` |
59
+ | `PRODUCT` | — | `{ id, handle, title }` or `null` |
60
+ | `COLLECTION_LIST` | `limit` (1–50) | `{ id, handle, title }[]` |
61
+ | `PRODUCT_LIST` | `limit` (1–50) | `{ id, handle, title }[]` |
62
+ | `HEADER` | `content` (no `id`) | — (a heading in the editor form) |
63
+ | `PARAGRAPH` | `content` (no `id`) | — (a note in the editor form) |
64
+
65
+ Text settings are stored per locale and served in the request's; the component only sees a string.
66
+ `IMAGE`, `COLLECTION`, `PRODUCT` and the lists reference shop data, so their default is empty; a
67
+ reference is not a full product — fetch what you show by `handle` through the catalog operations.
68
+ `IMAGE` has no alt text: declare a `TEXT` setting for it. Blocks (`defineBlock`) are repeatable
69
+ children with their own settings: slides, quotes, table rows (`maxBlocks` caps them).
70
+
71
+ ## Example: `size-guide`
72
+
73
+ A heading and a table of `row` blocks, in `theme/sections/size-guide.tsx`:
74
+
75
+ ```tsx
76
+ import { defineBlock, defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
77
+
78
+ import { label } from '../text';
79
+
80
+ const row = defineBlock({
81
+ type: 'row',
82
+ name: label('Size', 'Размер', "O'lcham"),
83
+ settings: [
84
+ { id: 'size', type: 'TEXT', label: label('Size', 'Размер', "O'lcham"), default: '' },
85
+ { id: 'chest', type: 'TEXT', label: label('Chest', 'Грудь', "Ko'krak"), default: '' },
86
+ { id: 'waist', type: 'TEXT', label: label('Waist', 'Талия', 'Bel'), default: '' },
87
+ ],
88
+ });
89
+
90
+ export const sizeGuide = defineSection({
91
+ type: 'size-guide',
92
+ name: label('Size guide', 'Таблица размеров', "O'lchamlar jadvali"),
93
+ maxBlocks: 30,
94
+ settings: [
95
+ {
96
+ id: 'heading',
97
+ type: 'TEXT',
98
+ label: label('Heading', 'Заголовок', 'Sarlavha'),
99
+ default: { en: 'Size guide', ru: 'Таблица размеров', uz: "O'lchamlar jadvali" },
100
+ },
101
+ {
102
+ id: 'columns',
103
+ type: 'TEXT',
104
+ label: label('Column names', 'Названия колонок', 'Ustun nomlari'),
105
+ info: label('Separated by commas.', 'Через запятую.', 'Vergul bilan.'),
106
+ default: {
107
+ en: 'Size, Chest cm, Waist cm',
108
+ ru: 'Размер, Грудь см, Талия см',
109
+ uz: "O'lcham, Ko'krak sm, Bel sm",
110
+ },
111
+ },
112
+ ],
113
+ blocks: [row],
114
+ presets: [
115
+ {
116
+ name: label('Size guide', 'Таблица размеров', "O'lchamlar jadvali"),
117
+ blocks: [{ type: 'row' }, { type: 'row' }, { type: 'row' }],
118
+ },
119
+ ],
120
+ });
121
+
122
+ /** A size table the merchant fills row by row; nothing shows until a row has a size. */
123
+ export function SizeGuide({ settings, blocks }: SectionProps<typeof sizeGuide>) {
124
+ const rows = blocks.filter((block) => block.settings.size !== '');
125
+ if (rows.length === 0) {
126
+ return null;
127
+ }
128
+ return (
129
+ <section className="stack">
130
+ <h2>{settings.heading}</h2>
131
+ <table>
132
+ <thead>
133
+ <tr>
134
+ {settings.columns.split(',').map((name) => (
135
+ <th key={name}>{name.trim()}</th>
136
+ ))}
137
+ </tr>
138
+ </thead>
139
+ <tbody>
140
+ {rows.map(({ id, settings: cells }) => (
141
+ <tr key={id}>
142
+ <td>{cells.size}</td>
143
+ <td>{cells.chest}</td>
144
+ <td>{cells.waist}</td>
145
+ </tr>
146
+ ))}
147
+ </tbody>
148
+ </table>
149
+ </section>
150
+ );
151
+ }
152
+ ```
153
+
154
+ Then in `theme/index.ts`: `import { SizeGuide, sizeGuide } from './sections/size-guide';`, add
155
+ `sizeGuide` to `sections` and `'size-guide': SizeGuide` to `components`. The preset lets the merchant
156
+ add it from the editor; to show it by default, add `"size": { "type": "size-guide" }` to a template's
157
+ `sections` and `"size"` to its `order`. Run `npm run theme:check` and `npm run theme:push`.
@@ -10,8 +10,19 @@ MAGICSTORE_API_URL=
10
10
  # browser calls go through this app's /storefront-api proxy (app/storefront-api/[...path]).
11
11
  NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN=
12
12
 
13
+ # Optional. Who holds the buyer's sign-in tokens and cart id: "server" (default) keeps them in httpOnly
14
+ # cookies behind app/storefront-api, out of reach of page JavaScript; "browser" keeps them in
15
+ # localStorage. A public storefront token (below) always means "browser".
16
+ MAGICSTORE_CREDENTIALS=
17
+
18
+ # Optional. The storefront access token (msf_…) the server sends with its reads: the API then serves
19
+ # this storefront's own theme, and theme previews work (they need it). Server-only; when empty, the
20
+ # public token below is used, else the shop is found by its domain.
21
+ MAGICSTORE_STOREFRONT_TOKEN=
22
+
13
23
  # Optional. The signing secret of the token's webhook (whsec_…): /api/magicstore/webhook
14
- # revalidates cached pages when the merchant changes the catalog, pages or home.
24
+ # revalidates cached pages when the merchant changes the catalog, pages or home, and
25
+ # /api/magicstore/preview verifies the theme editor's preview links with it.
15
26
  MAGICSTORE_WEBHOOK_SECRET=
16
27
 
17
28
  # The language pages render in (a locale the shop enables).
@@ -19,13 +19,16 @@ each error code means — then `PAGES.md` for how each page type is built, and
19
19
  ## Rules
20
20
 
21
21
  - Import the SDK only from `@magicstoreai/storefront-client`, `@magicstoreai/hydrogen`,
22
- `@magicstoreai/hydrogen/core`, `/server` and `/seo`. Call the API through client methods, never
23
- by `/api/v2/storefront/…` URL. A server component takes only components from
22
+ `@magicstoreai/hydrogen/core`, `/server`, `/seo` and `/theme`. Call the API through client
23
+ methods, never by `/api/v2/storefront/…` URL. A server component takes only components from
24
24
  `@magicstoreai/hydrogen` (a client module); functions and classes come from `/core`, `/server`,
25
- `/seo` or `@magicstoreai/storefront-client`. `npx create-magic-storefront check` must pass.
25
+ `/seo`, `/theme` or `@magicstoreai/storefront-client`. `npx create-magic-storefront check` must pass.
26
26
  - Never compute money: render `Money` values with `<Money>` / `formatMoney`, totals from the cart
27
27
  and the checkout.
28
- - Cart id, checkout id, tokens, OTP codes, phone numbers: never logged, never in a URL.
28
+ - Cart id, checkout id, tokens, OTP codes, phone numbers: never logged, never in a URL. By default
29
+ the browser never holds them at all: `app/storefront-api` keeps them in httpOnly cookies and
30
+ pages see `"current"` (`MAGICSTORE_CREDENTIALS`, `lib/upstream.ts`). Never read or parse a cart
31
+ or checkout id — pass back what the API returned.
29
32
  - Public reads render on the server through `lib/api.ts` (cached by tag, revalidated by the
30
33
  webhook). Anything personal — cart, customer, wishlist — lives in client components under
31
34
  `MagicStoreProvider` (`app/providers.tsx`).
@@ -55,20 +58,55 @@ screenshot every page type at 390 and 1280px, fix what you see. Interactive UI f
55
58
 
56
59
  ## Where things go
57
60
 
58
- | Path | What |
59
- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
60
- | `app/` | Pages (server components) and route handlers |
61
- | `app/error.tsx` | Error boundary: the API's `detail`, a retry |
62
- | `app/blog/` | Blog: index, category, author, tag and article pages (`PAGES.md`, Blog) |
63
- | `app/sitemap.ts` | Sitemap from `GET /sitemap` |
64
- | `app/page.tsx` | Home: renders `GET /home` sections in the merchant's order. Optional: a custom home composes its own (`PAGES.md`) |
65
- | `components/sections/` | One renderer per home section type; an unknown type renders nothing |
66
- | `components/` | Shared UI; client components start with `'use client'` |
67
- | `components/telegram-shell.tsx` | Telegram Mini App: theme marks, back arrow, the one automatic Telegram sign-in (`PAGES.md`) |
68
- | `lib/api.ts` | The server-side client (`nextCacheFetch`), `orNotFound` |
69
- | `app/providers.tsx` | `MagicStoreProvider`: customer, cart, wishlist, analytics in the browser |
70
- | `app/storefront-api/[...path]` | Same-origin proxy for browser calls without a public storefront token |
71
- | `app/api/magicstore/webhook` | Signed webhook → `revalidateTag` |
61
+ | Path | What |
62
+ | ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
63
+ | `app/` | Pages (server components) and route handlers |
64
+ | `app/error.tsx` | Error boundary: the API's `detail`, a retry |
65
+ | `app/blog/` | Blog: index, category, author, tag and article pages (`PAGES.md`, Blog) |
66
+ | `app/sitemap.ts` | Sitemap from `GET /sitemap` |
67
+ | `app/page.tsx` | Home: the theme's `index` template (`loadTemplate` + `ThemeSections`, `PAGES.md`) |
68
+ | `theme/` | The theme: `sections/` (schema + component), `templates/*.json`, `settings.ts`, registered in `index.ts` |
69
+ | `components/sections/` | One renderer per `GET /home` section type (the `platform-home` theme section); an unknown type renders nothing |
70
+ | `components/` | Shared UI; client components start with `'use client'` |
71
+ | `components/telegram-shell.tsx` | Telegram Mini App: theme marks, back arrow, the one automatic Telegram sign-in (`PAGES.md`) |
72
+ | `lib/api.ts` | The server-side client (`nextCacheFetch`), `orNotFound` |
73
+ | `app/providers.tsx` | `MagicStoreProvider`: customer, cart, wishlist, analytics in the browser |
74
+ | `app/storefront-api/[...path]` | Same-origin proxy (`createStorefrontProxy`); holds tokens and cart / checkout ids in httpOnly cookies |
75
+ | `app/api/magicstore/webhook` | Signed webhook → `revalidateTag` |
76
+ | `app/api/magicstore/preview` | Theme editor preview: signed token → draft mode; pages pass `themePreview()` (`lib/preview.ts`) as `preview` |
77
+
78
+ ## Creating a new section
79
+
80
+ A page part the merchant edits in the admin is a theme section. Invent any section the design needs
81
+ (`size-guide`, `lookbook`): the backend has no code per section, so a type it has never seen is fine.
82
+ A **setting type** it has never seen is not — compose the types below, never invent one; a missing
83
+ type is a contract change in magicbot. `GET /home` is optional. Full recipe and a worked example
84
+ (`size-guide`): `.claude/skills/theme-section/SKILL.md`.
85
+
86
+ 1. `theme/sections/<type>.tsx`: `defineSection` + the component (`SectionProps<typeof section>`).
87
+ 2. Register it in `theme/index.ts` (`sections` and `components`).
88
+ 3. Put it in `theme/templates/*.json` or give it `presets`.
89
+ 4. Every buyer-facing word comes from a setting with an `en`/`ru`/`uz` default. No copy in the component.
90
+ 5. `npm run theme:check`, then `npm run theme:push` (bump `version`); `theme dev` pushes while developing.
91
+
92
+ | Type | Extra members | The component receives |
93
+ | ----------------- | --------------------------------------- | --------------------------------------------------------- |
94
+ | `TEXT` | `maxLength` (≤ 500) | `string` |
95
+ | `TEXTAREA` | `maxLength` (≤ 5 000) | `string` |
96
+ | `RICHTEXT` | — | sanitised HTML `string` (the only HTML a section renders) |
97
+ | `URL` | — | `string \| null` |
98
+ | `COLOR` | — | `#rrggbb` or `null` |
99
+ | `CHECKBOX` | — | `boolean` |
100
+ | `NUMBER` | `min`, `max` (optional) | `number \| null` |
101
+ | `RANGE` | `min`, `max`, `step`, `unit` (optional) | `number` |
102
+ | `SELECT` | `options: [{ value, label }]` (≤ 100) | one option `value` |
103
+ | `IMAGE` | — | `{ url, altText: null, width, height }` or `null` |
104
+ | `COLLECTION` | — | `{ id, handle, title }` or `null` |
105
+ | `PRODUCT` | — | `{ id, handle, title }` or `null` |
106
+ | `COLLECTION_LIST` | `limit` (1–50) | `{ id, handle, title }[]` |
107
+ | `PRODUCT_LIST` | `limit` (1–50) | `{ id, handle, title }[]` |
108
+ | `HEADER` | `content` (no `id`) | — (a heading in the editor form) |
109
+ | `PARAGRAPH` | `content` (no `id`) | — (a note in the editor form) |
72
110
 
73
111
  ## Commands
74
112
 
@@ -77,4 +115,6 @@ npm run dev # needs MAGICSTORE_SHOP_DOMAIN (or MAGICSTORE_API_URL) in .
77
115
  npm run build
78
116
  npm run typecheck
79
117
  npx create-magic-storefront check # SDK used only through its public API
118
+ npm run theme:check # theme sections and templates; npm run build runs it too
119
+ npm run theme:push # send the theme to the shop (MAGICSTORE_THEME_TOKEN, mtt_…)
80
120
  ```
package/template/PAGES.md CHANGED
@@ -17,17 +17,28 @@ errors).
17
17
  `nextCacheFetch` tags every public `GET` by its path (`tagForPath`), and
18
18
  `app/api/magicstore/webhook` revalidates the tags of each webhook topic:
19
19
 
20
- | Tag | Paths | Webhook topic |
21
- | -------------------- | ----------------------------------------------------------------------------------------------- | ----------------- |
22
- | `magicstore:shop` | `/shop`, `/contacts`, `/theme/section-schema` | `SHOP_UPDATED` |
23
- | `magicstore:home` | `/home` | `HOME_UPDATED` |
24
- | `magicstore:pages` | `/pages*`, `/blog/*`, `/menus/footer`, `/sitemap` | `PAGES_UPDATED` |
25
- | `magicstore:catalog` | `/products*`, `/collections*`, other `/menus/*`, `/reels`, `/sitemap` (both tags), `/locations` | `CATALOG_UPDATED` |
26
- | none (`no-store`) | `/search*`, `/reviews/site`, cart, checkout, customer, orders | — |
20
+ | Tag | Paths | Webhook topic |
21
+ | ---------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------- |
22
+ | `magicstore:shop` | `/shop`, `/contacts`, `/theme/section-schema` | `SHOP_UPDATED` |
23
+ | `magicstore:home` | `/home` | `HOME_UPDATED` |
24
+ | `magicstore:pages` | `/pages*`, `/blog/*`, `/menus/footer`, `/sitemap` | `PAGES_UPDATED` |
25
+ | `magicstore:catalog` | `/products*`, `/collections*`, other `/menus/*`, `/reels`, `/sitemap` (both tags), `/locations` | `CATALOG_UPDATED` |
26
+ | `magicstore:theme` | `/theme`, `/theme/settings`, `/templates/*` (both tags) | `THEME_UPDATED` |
27
+ | `magicstore:template:<name>` | `/templates/<name>` | `TEMPLATE_UPDATED` (`data.name`) |
28
+ | none (`no-store`) | `/search*`, `/reviews/site`, cart, checkout, customer, orders, theme previews | — |
27
29
 
28
30
  Every page renders per request (`dynamic = 'force-dynamic'` in `app/layout.tsx`): the shop is the
29
31
  API's, not the build's. The fetch cache still spares the API.
30
32
 
33
+ ### Who holds the buyer's credentials
34
+
35
+ By default (`MAGICSTORE_CREDENTIALS` unset or `server`, `lib/upstream.ts`) every browser call goes
36
+ through `app/storefront-api`, which keeps the sign-in tokens and the cart and checkout ids in
37
+ httpOnly cookies. Page JavaScript only ever sees `"current"` in their place — `cart.id`,
38
+ `checkout.id`, the session's tokens — and passes it back as is. Choose `browser` only when a public
39
+ storefront token sends the browser straight to the API; then the tokens and the cart id live in
40
+ `localStorage`.
41
+
31
42
  ## Layout — `app/layout.tsx`
32
43
 
33
44
  - Reads `api.shop()` and the navigation (`api.collectionsIndex` or `api.menusShow({ path: { handle: 'main' } })`).
@@ -44,25 +55,31 @@ API's, not the build's. The fetch cache still spares the API.
44
55
  `TelegramShell` (`components/telegram-shell.tsx`) inside `Providers` (see "Running as a Telegram
45
56
  Mini App").
46
57
 
47
- ## Home — `app/page.tsx`, `components/sections/`
58
+ ## Home — `app/page.tsx`, `theme/`
59
+
60
+ The home page belongs to the storefront. This starter is a **theme** (Online Store 2.0 style):
48
61
 
49
- The home page belongs to the storefront. `GET /home` is one way to fill it, not a requirement.
50
- Pick one per project:
62
+ - `theme/sections/<type>.tsx` holds one section: its schema (`defineSection`) next to its component.
63
+ `theme/index.ts` registers every section (`defineTheme`) and its component (`components`).
64
+ - `theme/templates/index.json` is the home's default content; `theme/templates/page.json` lays out
65
+ content pages; `theme/settings.ts` holds global settings (the announcement above the header).
66
+ - `create-magic-storefront theme push` sends the theme to the shop, and the merchant edits texts,
67
+ images, products and the order of sections in the admin's theme editor. `app/page.tsx` reads the
68
+ merchant's template with `loadTemplate(api, 'index', theme)` and renders it with `ThemeSections`;
69
+ until the theme is pushed, the bundled `templates/index.json` renders instead.
70
+ - Every buyer-facing string in a section comes from a setting with a default (from `lib/i18n.ts`),
71
+ so the merchant can change it. `npm run theme:check` flags copy written into a component.
51
72
 
52
- - **Merchant-managed** (this starter's default): render `GET /home`, described below. The merchant
53
- arranges the page in the admin and the storefront follows.
54
- - **Custom**: compose the page yourself from any API data — `productsIndex` with its sorts and
55
- filters, `collectionsIndex`, `collectionsProducts`, `reviewsSite`, `pagesShow`, `shop.branding` —
56
- in whatever layout the design calls for. Not calling `api.home()` at all is fine, and so is
57
- taking only some sections from it (the merchant's `BANNER` slides, the `ANNOUNCEMENT_BAR` text)
58
- and placing them where the design wants. `components/sections/` can then be deleted.
73
+ `GET /home` is optional: the default template never calls it. The `platform-home` section renders
74
+ the platform's home editor layout (below) when the merchant adds it in the theme editor. Theme reads
75
+ are cached under `magicstore:theme` and `magicstore:template:<name>` (table above).
59
76
 
60
- A custom home is a trade-off: the merchant's home editor in the admin no longer controls the page.
61
- Tell whoever owns the store. Everything else still holds: data only from the API, nothing invented,
62
- and the page is revalidated by the tags of what it reads (`magicstore:catalog`, `magicstore:shop`),
63
- not by `HOME_UPDATED` unless it calls `api.home()`.
77
+ A storefront may still drop the theme and compose a **custom** home from any API data —
78
+ `productsIndex` with its sorts and filters, `collectionsIndex`, `collectionsProducts`,
79
+ `reviewsSite`, `pagesShow`, `shop.branding`. The merchant's editor then no longer controls the page:
80
+ tell whoever owns the store. Everything else still holds: data only from the API, nothing invented.
64
81
 
65
- ### Rendering `GET /home`
82
+ ### Rendering `GET /home` — `components/sections/`, the `platform-home` section
66
83
 
67
84
  `api.home()` answers `{ sections: HomeSection[] }` in the merchant's order. Render each by `type`
68
85
  with one component per type; a type the storefront does not know renders **nothing** (the API can
@@ -95,6 +112,15 @@ render nothing then.
95
112
  personal; render it in a client component or not at all. A shop with no sections still needs a
96
113
  home: show new arrivals.
97
114
 
115
+ ## Creating a new section
116
+
117
+ A section is one file in `theme/sections/`, registered in `theme/index.ts`, placed in a template or
118
+ given `presets`, with every buyer-facing word in a setting, then `npm run theme:check` and
119
+ `npm run theme:push`. The backend needs nothing for a new section type — only a new setting type is
120
+ a contract change. The steps, the setting types and a worked example (`size-guide`) are in
121
+ `AGENTS.md` and `.claude/skills/theme-section/SKILL.md`; `theme/sections/testimonials.tsx` is a
122
+ section with blocks to copy from.
123
+
98
124
  ## Collection — `app/collections/[handle]/page.tsx`
99
125
 
100
126
  - `api.collectionsShow({ path: { handle } })` and `api.collectionsProducts({ path: { handle }, query: { page, perPage } })`,
@@ -20,6 +20,7 @@ npm run dev
20
20
  | `app/providers.tsx` | `MagicStoreProvider`: customer, cart, wishlist and analytics in the browser. |
21
21
  | `app/storefront-api/[...path]` | Same-origin proxy for browser calls when there is no public storefront token. |
22
22
  | `app/api/magicstore/webhook` | Verifies the platform's webhook and revalidates the tags its topic covers. |
23
+ | `app/api/magicstore/preview` | The admin's theme preview: verifies its token, turns on draft mode. |
23
24
 
24
25
  **Browser calls.** With `NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN` (a token that lists this site's
25
26
  origin) the browser calls the shop's API directly. Without it, calls go through the proxy route,
@@ -35,3 +36,8 @@ serves every page from the spec's example data.
35
36
 
36
37
  **Webhook.** In Shop settings → Storefront API, set the token's webhook to
37
38
  `https://<this site>/api/magicstore/webhook` and put its secret in `MAGICSTORE_WEBHOOK_SECRET`.
39
+ The same secret signs the theme editor's preview links: the admin opens
40
+ `https://<this site>/api/magicstore/preview?token=…&path=/`, which turns on draft mode for ten minutes
41
+ and serves the draft theme to that browser only (`lib/preview.ts`); `?exit=1` ends it. Previews also need
42
+ `MAGICSTORE_STOREFRONT_TOKEN`, the storefront token that webhook belongs to: the API previews only a
43
+ read that names its token.
@@ -4,3 +4,5 @@ node_modules/
4
4
  next-env.d.ts
5
5
  *.tsbuildinfo
6
6
  .screenshots/
7
+ .magicstore/
8
+ .tmp/
@@ -0,0 +1,56 @@
1
+ import {
2
+ THEME_PREVIEW_COOKIE,
3
+ previewRedirectPath,
4
+ verifyThemePreview,
5
+ } from '@magicstoreai/hydrogen/server';
6
+ import { cookies, draftMode } from 'next/headers';
7
+ import { redirect } from 'next/navigation';
8
+
9
+ import { storefrontToken } from '@/lib/api';
10
+
11
+ /**
12
+ * The admin's theme editor opens `/api/magicstore/preview?token=…&path=/` (in a frame or a tab) to
13
+ * show an unpublished theme or unsaved changes. A valid token turns on draft mode and is kept in an
14
+ * httpOnly cookie for its ten minutes; pages then send it as `X-Theme-Preview`, uncached.
15
+ * `?exit=1` ends the preview. The token is signed with the webhook secret and never logged.
16
+ */
17
+ export async function GET(request: Request): Promise<Response> {
18
+ const secret = process.env.MAGICSTORE_WEBHOOK_SECRET;
19
+ if (!secret) {
20
+ return new Response('MAGICSTORE_WEBHOOK_SECRET is not set', { status: 501 });
21
+ }
22
+ if (!storefrontToken()) {
23
+ // The API previews only a request that names its storefront token.
24
+ return new Response('MAGICSTORE_STOREFRONT_TOKEN is not set', { status: 501 });
25
+ }
26
+ const params = new URL(request.url).searchParams;
27
+ const path = previewRedirectPath(params.get('path'));
28
+ const jar = await cookies();
29
+
30
+ if (params.has('exit')) {
31
+ (await draftMode()).disable();
32
+ jar.delete(THEME_PREVIEW_COOKIE);
33
+ redirect(path);
34
+ }
35
+
36
+ const token = params.get('token');
37
+ const now = Math.floor(Date.now() / 1000);
38
+ const preview = await verifyThemePreview(secret, token, now);
39
+ if (preview === null || token === null) {
40
+ return new Response('The preview link is invalid or has expired.', {
41
+ status: 403,
42
+ headers: { 'Cache-Control': 'no-store' },
43
+ });
44
+ }
45
+
46
+ (await draftMode()).enable();
47
+ jar.set(THEME_PREVIEW_COOKIE, token, {
48
+ httpOnly: true,
49
+ secure: true,
50
+ // The editor shows the storefront in a frame on the admin's origin.
51
+ sameSite: 'none',
52
+ path: '/',
53
+ maxAge: Math.max(1, preview.expiresAt - now),
54
+ });
55
+ redirect(path);
56
+ }
@@ -410,3 +410,111 @@ footer.site nav {
410
410
  .author h1 {
411
411
  margin: 0;
412
412
  }
413
+ /* Theme sections (theme/sections/): the merchant edits their content in the admin. */
414
+ /* A section with nothing to show yet renders nothing, but ThemeSections still wraps it. */
415
+ [data-section-id]:empty {
416
+ display: none;
417
+ }
418
+ .site-announcement {
419
+ margin: 0;
420
+ padding: 8px 16px;
421
+ background: var(--accent);
422
+ color: var(--on-accent);
423
+ text-align: center;
424
+ }
425
+ .hero {
426
+ display: grid;
427
+ border-radius: 12px;
428
+ overflow: hidden;
429
+ background: var(--placeholder);
430
+ }
431
+ .hero > img,
432
+ .hero-text {
433
+ grid-area: 1 / 1;
434
+ }
435
+ .hero > img {
436
+ width: 100%;
437
+ height: auto;
438
+ aspect-ratio: 21 / 9;
439
+ object-fit: cover;
440
+ }
441
+ .hero-text {
442
+ align-self: center;
443
+ padding: 40px 24px;
444
+ }
445
+ .hero > img + .hero-text {
446
+ background: linear-gradient(to top, rgb(0 0 0 / 0.7), rgb(0 0 0 / 0.35) 55%, transparent);
447
+ color: #fff;
448
+ text-shadow: 0 1px 2px rgb(0 0 0 / 0.4);
449
+ align-self: stretch;
450
+ justify-content: flex-end;
451
+ }
452
+ .hero-center .hero-text {
453
+ align-items: center;
454
+ text-align: center;
455
+ }
456
+ .hero-text h1,
457
+ .hero-text p {
458
+ margin: 0;
459
+ max-width: 40ch;
460
+ }
461
+ .hero-text .button,
462
+ .image-with-text .button {
463
+ align-self: flex-start;
464
+ text-shadow: none;
465
+ }
466
+ .hero-center .hero-text .button {
467
+ align-self: center;
468
+ }
469
+ @media (max-width: 720px) {
470
+ .hero > img {
471
+ aspect-ratio: 4 / 5;
472
+ }
473
+ }
474
+ .rich-text-narrow {
475
+ max-width: 760px;
476
+ }
477
+ .image-with-text {
478
+ display: grid;
479
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
480
+ gap: 32px;
481
+ align-items: center;
482
+ }
483
+ .image-with-text.image-right > img {
484
+ order: 2;
485
+ }
486
+ .image-with-text > img {
487
+ width: 100%;
488
+ height: auto;
489
+ aspect-ratio: 4 / 3;
490
+ object-fit: cover;
491
+ border-radius: 12px;
492
+ }
493
+ @media (max-width: 720px) {
494
+ .image-with-text {
495
+ grid-template-columns: 1fr;
496
+ }
497
+ .image-with-text.image-right > img {
498
+ order: 0;
499
+ }
500
+ }
501
+ .testimonials {
502
+ display: grid;
503
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
504
+ gap: 20px;
505
+ }
506
+ .testimonials figure {
507
+ margin: 0;
508
+ padding: 16px;
509
+ border: 1px solid var(--line);
510
+ border-radius: 12px;
511
+ }
512
+ .testimonials blockquote {
513
+ margin: 0;
514
+ padding: 0;
515
+ border: 0;
516
+ }
517
+ .testimonials .rating {
518
+ color: var(--accent);
519
+ letter-spacing: 2px;
520
+ }
@@ -5,11 +5,15 @@ import type { ReactNode } from 'react';
5
5
  import { Image } from '@magicstoreai/hydrogen';
6
6
  // From /core, not the "use client" entry: a plain string a server component can read.
7
7
  import { TELEGRAM_WEB_APP_SCRIPT } from '@magicstoreai/hydrogen/core';
8
+ import { loadThemeSettings } from '@magicstoreai/hydrogen/server';
8
9
  import { CartLink } from '@/components/cart-link';
9
10
  import { TelegramShell } from '@/components/telegram-shell';
10
11
  import { api, locale } from '@/lib/api';
12
+ import { themePreview } from '@/lib/preview';
11
13
  import { brandStyle } from '@/lib/brand';
14
+ import { credentialsMode } from '@/lib/upstream';
12
15
  import { messages } from '@/lib/i18n';
16
+ import theme from '@/theme';
13
17
  import { Providers } from './providers';
14
18
  import './globals.css';
15
19
 
@@ -27,23 +31,30 @@ export async function generateMetadata(): Promise<Metadata> {
27
31
  }
28
32
 
29
33
  export default async function RootLayout({ children }: { children: ReactNode }) {
30
- const [{ data: shop }, { data: collections }, { data: pages }] = await Promise.all([
31
- api.shop(),
32
- api.collectionsIndex({ query: { perPage: 6 } }),
33
- api.pagesIndex(),
34
- ]);
34
+ const [{ data: shop }, { data: collections }, { data: pages }, themeSettings] = await Promise.all(
35
+ [
36
+ api.shop(),
37
+ api.collectionsIndex({ query: { perPage: 6 } }),
38
+ api.pagesIndex(),
39
+ themePreview().then((preview) => loadThemeSettings(api, theme, { locale, preview })),
40
+ ],
41
+ );
42
+ const announcement = themeSettings.announcement as string;
35
43
  const t = messages(locale);
36
44
 
37
45
  return (
38
46
  // The merchant's colors, read per request: a change in the admin shows after SHOP_UPDATED.
39
- <html lang={locale} style={brandStyle(shop.branding)}>
47
+ // Telegram's script (beforeInteractive) writes --tg-viewport-* into <html style> before React
48
+ // hydrates; suppressHydrationWarning covers this element's attributes only, not its children.
49
+ <html lang={locale} style={brandStyle(shop.branding)} suppressHydrationWarning>
40
50
  <body>
41
51
  {/* Telegram's Mini App script, only for a shop with a bot: nobody else can open it in Telegram. */}
42
52
  {shop.features.telegramBot && (
43
53
  <Script src={TELEGRAM_WEB_APP_SCRIPT} strategy="beforeInteractive" />
44
54
  )}
45
- <Providers shop={shop}>
55
+ <Providers shop={shop} credentials={credentialsMode()}>
46
56
  <TelegramShell />
57
+ {announcement && <p className="site-announcement">{announcement}</p>}
47
58
  <header className="site">
48
59
  <nav>
49
60
  <Link href="/" className="brand">