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.
- package/README.md +18 -1
- package/dist/index.js +523 -9
- package/package.json +7 -1
- package/template/.claude/skills/theme-section/SKILL.md +157 -0
- package/template/.env.example +12 -1
- package/template/.screenshots/home-1280.png +0 -0
- package/template/.screenshots/home-390.png +0 -0
- package/template/.screenshots/home-filled-1280.png +0 -0
- package/template/.screenshots/home-filled-390.png +0 -0
- package/template/.screenshots/page-1280.png +0 -0
- package/template/AGENTS.md +58 -18
- package/template/PAGES.md +48 -22
- package/template/README.md +6 -0
- package/template/_gitignore +2 -0
- package/template/app/api/magicstore/preview/route.ts +56 -0
- package/template/app/globals.css +108 -0
- package/template/app/layout.tsx +18 -7
- package/template/app/page.tsx +18 -9
- package/template/app/pages/[handle]/page.tsx +26 -11
- package/template/app/providers.tsx +15 -6
- package/template/app/storefront-api/[...path]/route.ts +11 -66
- package/template/components/sections/collections.tsx +2 -1
- package/template/components/sections/product-shelves.tsx +2 -1
- package/template/lib/api.ts +40 -10
- package/template/lib/i18n.ts +6 -0
- package/template/lib/preview.ts +21 -0
- package/template/lib/upstream.ts +21 -0
- package/template/llms.txt +153 -1
- package/template/package.json +6 -3
- package/template/scripts/theme.mjs +53 -0
- package/template/theme/index.ts +53 -0
- package/template/theme/sections/collection-list.tsx +64 -0
- package/template/theme/sections/featured-collection.tsx +56 -0
- package/template/theme/sections/hero.tsx +95 -0
- package/template/theme/sections/image-with-text.tsx +71 -0
- package/template/theme/sections/page-content.tsx +45 -0
- package/template/theme/sections/platform-home.tsx +37 -0
- package/template/theme/sections/product-shelf.tsx +96 -0
- package/template/theme/sections/rich-text.tsx +43 -0
- package/template/theme/sections/testimonials.tsx +84 -0
- package/template/theme/settings.ts +19 -0
- package/template/theme/templates/index.json +9 -0
- package/template/theme/templates/page.json +6 -0
- package/template/theme/text.ts +21 -0
- 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`.
|
package/template/.env.example
CHANGED
|
@@ -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).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/template/AGENTS.md
CHANGED
|
@@ -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 `/
|
|
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:
|
|
65
|
-
| `
|
|
66
|
-
| `components/`
|
|
67
|
-
| `components
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `app/
|
|
71
|
-
| `app/api/
|
|
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
|
|
21
|
-
|
|
|
22
|
-
| `magicstore:shop`
|
|
23
|
-
| `magicstore:home`
|
|
24
|
-
| `magicstore:pages`
|
|
25
|
-
| `magicstore:catalog`
|
|
26
|
-
|
|
|
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`, `
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 } })`,
|
package/template/README.md
CHANGED
|
@@ -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.
|
package/template/_gitignore
CHANGED
|
@@ -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
|
+
}
|
package/template/app/globals.css
CHANGED
|
@@ -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
|
+
}
|
package/template/app/layout.tsx
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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">
|