create-magic-storefront 0.1.3 → 0.3.1

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 CHANGED
@@ -21,6 +21,12 @@ Fails (exit 1, one line per finding) when the storefront uses the SDK outside it
21
21
  path, an unexported subpath, the SDK's sources in this repository);
22
22
  - `RAW_API_CALL` — a string that spells out an endpoint (`/api/v2/storefront/…`) instead of calling
23
23
  it through the client. The bare base URL handed to `createStorefrontClient` is fine.
24
+ - `CLIENT_ENTRY_ON_SERVER` — in a Next.js project, a server component (a file without
25
+ `'use client'`) takes anything but a component (`Image`, `Money`, `Pagination`,
26
+ `MagicStoreProvider`, `ProductProvider`) from `@magicstoreai/hydrogen`. That entry is a client
27
+ module: on the server its functions and classes are client references, so `paginationState()`
28
+ throws and `instanceof MagicStoreError` is always false. Take them from `/core`, `/server`, `/seo`
29
+ or `@magicstoreai/storefront-client`.
24
30
 
25
31
  `node_modules`, build output and `.d.ts` files are skipped. In this repository `pnpm guard` runs it on
26
32
  `examples/starter` (CI too).
package/dist/index.js CHANGED
@@ -32,6 +32,15 @@ var SDK_PACKAGE = /@magicstoreai\/(?:hydrogen|storefront-client)(?:\/|$)/;
32
32
  var SDK_SOURCES = /(?:^|\/)packages\/(?:hydrogen|storefront-client)(?:\/|$)/;
33
33
  var RAW_API_PATH = /\/api\/v2\/storefront\/(?:[A-Za-z]|\$\{)/;
34
34
  var STRING_LITERAL = /(['"`])(?:\\.|(?!\1)[^\\\n])*\1/g;
35
+ var SERVER_SAFE_CLIENT_EXPORTS = /* @__PURE__ */ new Set([
36
+ "Image",
37
+ "Money",
38
+ "Pagination",
39
+ "MagicStoreProvider",
40
+ "ProductProvider"
41
+ ]);
42
+ var CLIENT_ENTRY_IMPORT = /\bimport\s+(type\s+)?(?:\{([^}]*)\}|\*\s+as\s+\w+)\s*from\s*(['"])@magicstoreai\/hydrogen\3/g;
43
+ var USE_CLIENT = /^(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*(['"])use client\1/;
35
44
  function sourceFiles(directory) {
36
45
  const files = [];
37
46
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
@@ -48,8 +57,24 @@ function sourceFiles(directory) {
48
57
  function lineAt(source, index) {
49
58
  return source.slice(0, index).split("\n").length;
50
59
  }
51
- function checkSource(file, source) {
60
+ function checkSource(file, source, options = {}) {
52
61
  const violations = [];
62
+ if (options.serverComponents && !USE_CLIENT.test(source)) {
63
+ for (const match of source.matchAll(CLIENT_ENTRY_IMPORT)) {
64
+ if (match[1]) {
65
+ continue;
66
+ }
67
+ const names = match[2] === void 0 ? ["*"] : match[2].split(",").map((name) => name.trim()).filter((name) => name !== "" && !name.startsWith("type ")).map((name) => name.split(/\s+as\s+/)[0].trim());
68
+ for (const name of names.filter((n) => !SERVER_SAFE_CLIENT_EXPORTS.has(n))) {
69
+ violations.push({
70
+ rule: "CLIENT_ENTRY_ON_SERVER",
71
+ file,
72
+ line: lineAt(source, match.index),
73
+ text: name
74
+ });
75
+ }
76
+ }
77
+ }
53
78
  for (const match of source.matchAll(SPECIFIER)) {
54
79
  const specifier = match[2];
55
80
  const reachesIn = SDK_PACKAGE.test(specifier) && !PUBLIC_ENTRY_POINTS.includes(specifier) || SDK_SOURCES.test(specifier);
@@ -76,13 +101,25 @@ function checkSource(file, source) {
76
101
  }
77
102
  function checkPublicApi(directory) {
78
103
  const root = resolve(directory);
104
+ const serverComponents = usesNext(root);
79
105
  return sourceFiles(root).flatMap(
80
- (path) => checkSource(relative(root, path).split("\\").join("/"), readFileSync(path, "utf8"))
106
+ (path) => checkSource(relative(root, path).split("\\").join("/"), readFileSync(path, "utf8"), {
107
+ serverComponents
108
+ })
81
109
  );
82
110
  }
111
+ function usesNext(root) {
112
+ try {
113
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
114
+ return "next" in { ...manifest.dependencies, ...manifest.devDependencies };
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
83
119
  var EXPLANATIONS = {
84
120
  DEEP_IMPORT: `import only ${PUBLIC_ENTRY_POINTS.join(", ")}`,
85
- RAW_API_CALL: "call the API through the storefront client, not by URL"
121
+ RAW_API_CALL: "call the API through the storefront client, not by URL",
122
+ CLIENT_ENTRY_ON_SERVER: "a server component takes only components from '@magicstoreai/hydrogen' (a client module); import this from /core, /server, /seo or '@magicstoreai/storefront-client', or add 'use client'"
86
123
  };
87
124
  function formatViolations(violations) {
88
125
  return violations.map((v) => `${v.file}:${v.line} ${v.rule} ${v.text} \u2014 ${EXPLANATIONS[v.rule]}`).join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-magic-storefront",
3
- "version": "0.1.3",
3
+ "version": "0.3.1",
4
4
  "description": "Scaffold a Next.js storefront on the MagicStore Storefront API v2",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,11 +33,11 @@ When they disagree, **this skill wins**. The overrides are listed in [Overrides]
33
33
 
34
34
  Design from the merchant's data, not from a guess. Read, through `lib/api.ts`:
35
35
 
36
- | What | Why |
37
- | ------------------------------------------------- | ----------------------------------------------------------------- |
38
- | `api.shop()`: `name`, `description`, `branding` | Identity: logo, favicon, colors, theme |
39
- | `api.home()`: the sections in the merchant's order | On a merchant-managed home, what it must hold. A custom home may skip it (`PAGES.md`, Home) |
40
- | `api.collectionsIndex()`, a few `api.productsIndex()` | Category, price range, how many products, how good the photos are |
36
+ | What | Why |
37
+ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
38
+ | `api.shop()`: `name`, `description`, `branding` | Identity: logo, favicon, colors, theme |
39
+ | `api.home()`: the sections in the merchant's order | On a merchant-managed home, what it must hold. A custom home may skip it (`PAGES.md`, Home) |
40
+ | `api.collectionsIndex()`, a few `api.productsIndex()` | Category, price range, how many products, how good the photos are |
41
41
 
42
42
  `shop.branding`:
43
43
 
@@ -109,7 +109,8 @@ back to the catalog.
109
109
  errors next to the field (`error.detail`), a summary of lines and totals from the checkout. No
110
110
  animation, no decorative imagery, no experimental layout. Trust beats taste here.
111
111
 
112
- **Account, search, content pages.** Same tokens, quiet layout. Search results reuse the product
112
+ **Account, search, content pages, blog.** Same tokens, quiet layout; an article reads in one
113
+ narrow column with the cover at a fixed ratio. Search results reuse the product
113
114
  card.
114
115
 
115
116
  **Home** (`app/page.tsx`). The one place for a strong visual idea, and the page a design may
@@ -136,18 +137,18 @@ Motion, use it only in `'use client'` leaf components on the home page and conte
136
137
 
137
138
  Where `design-taste-frontend` says otherwise, this is what applies here:
138
139
 
139
- | Taste skill | In this storefront |
140
- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
- | 4.8: generate images, use Picsum, stock photos | **Never** for products, collections, banners or stories: those images come from the API only. A missing image renders `<Image fallback>`. Generated imagery only for decoration the merchant has no data for, and only when asked. |
142
- | 4.8 / 9.F: logo walls, "Trusted by", testimonials | Only real data: `api.reviewsSite()` / the store-reviews section, the shop's own logo. No invented brands, customers, ratings or review counts. |
143
- | 9.D: invent realistic content | Never. Real product titles, prices and copy only. Placeholder copy in a shipped page is a bug. |
144
- | 4.6 / 13: forms and multi-step flows out of scope | Cart, checkout and account follow Section 3 here: conventional, accessible, no creative layout. |
145
- | 4.9 / 9.F: spec sheets banned | Product attributes are allowed, as a definition list without a border on every row. |
146
- | 5: sticky-stack, horizontal pan, GSAP | Home and content pages only. Never on collection, product, cart or checkout pages. |
147
- | 6.C / 8: dark mode mandatory | One theme per store, chosen from `branding` (Theme Lock still holds). Add a dark variant only if the brand colors pass contrast in it. |
148
- | 3.A: Tailwind v4 + Motion by default | Section 5 here. |
149
- | 12: block library files | Ignore: blocks are not shipped with this storefront. |
150
- | Urgency: countdowns, "only N left", "X people viewing" | Only from data: the deal-of-day and flash-sale sections' end dates, `quantityAvailable`. Never invented. |
140
+ | Taste skill | In this storefront |
141
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | 4.8: generate images, use Picsum, stock photos | **Never** for products, collections, banners or stories: those images come from the API only. A missing image renders `<Image fallback>`. Generated imagery only for decoration the merchant has no data for, and only when asked. |
143
+ | 4.8 / 9.F: logo walls, "Trusted by", testimonials | Only real data: `api.reviewsSite()` / the store-reviews section, the shop's own logo. No invented brands, customers, ratings or review counts. |
144
+ | 9.D: invent realistic content | Never. Real product titles, prices and copy only. Placeholder copy in a shipped page is a bug. |
145
+ | 4.6 / 13: forms and multi-step flows out of scope | Cart, checkout and account follow Section 3 here: conventional, accessible, no creative layout. |
146
+ | 4.9 / 9.F: spec sheets banned | Product attributes are allowed, as a definition list without a border on every row. |
147
+ | 5: sticky-stack, horizontal pan, GSAP | Home and content pages only. Never on collection, product, cart or checkout pages. |
148
+ | 6.C / 8: dark mode mandatory | One theme per store, chosen from `branding` (Theme Lock still holds). Add a dark variant only if the brand colors pass contrast in it. |
149
+ | 3.A: Tailwind v4 + Motion by default | Section 5 here. |
150
+ | 12: block library files | Ignore: blocks are not shipped with this storefront. |
151
+ | Urgency: countdowns, "only N left", "X people viewing" | Only from data: the deal-of-day and flash-sale sections' end dates, `quantityAvailable`. Never invented. |
151
152
 
152
153
  ## Pre-flight
153
154
 
@@ -29,15 +29,16 @@ server when you are done.
29
29
 
30
30
  Real data only, from the running shop:
31
31
 
32
- | Page | URL |
33
- | ---------- | ----------------------------------------------------------------- |
34
- | Home | `/` |
35
- | Collection | the first `/collections/…` link in the home page's HTML or its menu |
36
- | Product | the first `/products/…` link, plus one sold out or with options if the shop has one |
37
- | Search | `/search?q=<a word from a product title>` and one with no results |
38
- | Cart | `/cart` (empty), then again after adding a product |
39
- | Checkout | `/checkout` with that cart |
40
- | Not found | `/products/this-does-not-exist` |
32
+ | Page | URL |
33
+ | ---------- | ----------------------------------------------------------------------------------------- |
34
+ | Home | `/` |
35
+ | Collection | the first `/collections/…` link in the home page's HTML or its menu |
36
+ | Product | the first `/products/…` link, plus one sold out or with options if the shop has one |
37
+ | Search | `/search?q=<a word from a product title>` and one with no results |
38
+ | Blog | `/blog` and the first `/blog/…` article, when the header links the blog (`features.blog`) |
39
+ | Cart | `/cart` (empty), then again after adding a product |
40
+ | Checkout | `/checkout` with that cart |
41
+ | Not found | `/products/this-does-not-exist` |
41
42
 
42
43
  Never place an order while verifying: stop at the checkout form.
43
44
 
@@ -20,7 +20,9 @@ each error code means — then `PAGES.md` for how each page type is built, and
20
20
 
21
21
  - Import the SDK only from `@magicstoreai/storefront-client`, `@magicstoreai/hydrogen`,
22
22
  `@magicstoreai/hydrogen/core`, `/server` and `/seo`. Call the API through client methods, never
23
- by `/api/v2/storefront/…` URL. `npx create-magic-storefront check` must pass.
23
+ by `/api/v2/storefront/…` URL. A server component takes only components from
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.
24
26
  - Never compute money: render `Money` values with `<Money>` / `formatMoney`, totals from the cart
25
27
  and the checkout.
26
28
  - Cart id, checkout id, tokens, OTP codes, phone numbers: never logged, never in a URL.
@@ -32,6 +34,10 @@ each error code means — then `PAGES.md` for how each page type is built, and
32
34
  every language it has (`ru`, `uz`, `en`).
33
35
  - Every page handles its empty, error and not-found states (`orNotFound` turns `NOT_FOUND` into
34
36
  the 404 page).
37
+ - The storefront also runs as a Telegram Mini App (`PAGES.md`, "Running as a Telegram Mini App").
38
+ App-wide Telegram behaviour lives in `components/telegram-shell.tsx` only: one automatic
39
+ `useTelegramSignIn()` there, `{ auto: false }` anywhere else. Never put `initData` in a URL, a log
40
+ or analytics.
35
41
 
36
42
  ## Design
37
43
 
@@ -49,18 +55,20 @@ screenshot every page type at 390 and 1280px, fix what you see. Interactive UI f
49
55
 
50
56
  ## Where things go
51
57
 
52
- | Path | What |
53
- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
54
- | `app/` | Pages (server components) and route handlers |
55
- | `app/error.tsx` | Error boundary: the API's `detail`, a retry |
56
- | `app/sitemap.ts` | Sitemap from `GET /sitemap` |
57
- | `app/page.tsx` | Home: renders `GET /home` sections in the merchant's order. Optional: a custom home composes its own (`PAGES.md`) |
58
- | `components/sections/` | One renderer per home section type; an unknown type renders nothing |
59
- | `components/` | Shared UI; client components start with `'use client'` |
60
- | `lib/api.ts` | The server-side client (`nextCacheFetch`), `orNotFound` |
61
- | `app/providers.tsx` | `MagicStoreProvider`: customer, cart, wishlist, analytics in the browser |
62
- | `app/storefront-api/[...path]` | Same-origin proxy for browser calls without a public storefront token |
63
- | `app/api/magicstore/webhook` | Signed webhook → `revalidateTag` |
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` |
64
72
 
65
73
  ## Commands
66
74
 
package/template/PAGES.md CHANGED
@@ -17,13 +17,13 @@ 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*`, `/menus/footer` | `PAGES_UPDATED` |
25
- | `magicstore:catalog` | `/products*`, `/collections*`, other `/menus/*`, `/reels`, `/sitemap`, `/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
+ | none (`no-store`) | `/search*`, `/reviews/site`, cart, checkout, customer, orders | — |
27
27
 
28
28
  Every page renders per request (`dynamic = 'force-dynamic'` in `app/layout.tsx`): the shop is the
29
29
  API's, not the build's. The fetch cache still spares the API.
@@ -37,6 +37,12 @@ API's, not the build's. The fetch cache still spares the API.
37
37
  tokens `app/globals.css` falls back from; the header shows `shop.branding.logo` when there is one.
38
38
  - Wraps the page in `Providers` (`MagicStoreProvider` with `shop` passed in, so the browser does not
39
39
  load it again).
40
+ - Links the blog only when `shop.features.blog` is on (an article is readable in this language).
41
+ - Telegram: when `shop.features.telegramBot` is on, loads `TELEGRAM_WEB_APP_SCRIPT` (from
42
+ `@magicstoreai/hydrogen/core` — a plain string a server component may import) with
43
+ `<Script strategy="beforeInteractive">`; a shop without a bot pays nothing for it. Renders
44
+ `TelegramShell` (`components/telegram-shell.tsx`) inside `Providers` (see "Running as a Telegram
45
+ Mini App").
40
46
 
41
47
  ## Home — `app/page.tsx`, `components/sections/`
42
48
 
@@ -148,22 +154,113 @@ home: show new arrivals.
148
154
 
149
155
  ## Account — `app/account/page.tsx`
150
156
 
151
- - Client only. Signed out: `useCustomer().requestOtp(phone)` → `verifyOtp(phone, code)`
152
- (`OTP_INVALID`: re-type; `OTP_EXPIRED`: ask again), or Telegram / OQ / Click sign-in in those apps.
153
- Only offer OTP when `shop.features.otpLogin` is on.
157
+ - Client only. Signed out:
158
+ - Phone: `useCustomer().requestOtp(phone)` → `verifyOtp(phone, code)` (`OTP_INVALID`: re-type;
159
+ `OTP_EXPIRED`: ask again). On a shop whose sign-in code is off
160
+ (`shop.features.otpLogin === false`) the same form calls `signInWithPhone(phone)` — no code
161
+ step.
162
+ - Inside a Telegram Mini App (`useTelegramWebApp().isTelegram`), above the phone form: "Sign in
163
+ with Telegram" — `useTelegramSignIn({ auto: false }).signIn()` (the automatic sign-in lives in
164
+ `components/telegram-shell.tsx`, never here). The hook shares the attempt's state, so this
165
+ block also shows the shell's automatic attempt: "Signing in…" while it runs, its failure
166
+ without a tap. `status: 'failed'` with `UNAUTHENTICATED` → the launch data is stale: ask to
167
+ close the shop and reopen it from the bot; `FORBIDDEN` → the shop has no bot: hide the button.
168
+ The phone form stays as the fallback.
169
+ - While `useTelegramWebApp().status` is `unknown` (server render and first paint) on a shop with
170
+ a bot (`shop.features.telegramBot`), an invisible copy of that block holds its place, so the
171
+ phone form does not jump down after mount. A shop without a bot gets no gap.
172
+ - OQ / Click sign-in in those apps.
154
173
  - Signed in: `customerOrdersIndex`, `customerOrdersShow`, `customerWishlistIndex`, addresses,
155
174
  rewards — through `useStorefrontClient()` (the provider adds the bearer and refreshes it).
175
+ - Phone proof: signed in inside a Mini App with no `customer.phone` (a Telegram-only customer) and
176
+ `useTelegramWebApp().canRequestContact` (Bot API 6.9+; older clients cannot share) → a
177
+ "Share phone number" button → `useCustomer().shareTelegramPhone()` (null: the buyer declined —
178
+ nothing to show). Errors through `describePhoneShare` (`lib/errors.ts`): `CONTACT_EXPIRED` → share
179
+ again; `CONTACT_INVALID`, `CONTACT_NOT_OWN`, `INVALID_PHONE`, `TAKEN` → the API's message for
180
+ `telegramContact`. Never offer it to a signed-out visitor (`UNAUTHENTICATED`).
156
181
  - States: signed out, `UNAUTHENTICATED` (sign in again), no orders yet.
157
182
 
183
+ ## Running as a Telegram Mini App
184
+
185
+ The same app opens inside Telegram when the shop's bot points at it: in BotFather, set the bot's
186
+ Mini App (or menu button) URL to this site's `https://` address. Nothing else changes — same pages,
187
+ same API.
188
+
189
+ - **Script.** `app/layout.tsx` loads it only for a shop with a bot (above). It defines
190
+ `window.Telegram.WebApp` and writes `--tg-theme-*` / `--tg-viewport-*` / `--tg-safe-area-*` CSS
191
+ variables on `<html>`.
192
+ - **Shell.** `components/telegram-shell.tsx` (`'use client'`, renders nothing) holds every
193
+ app-wide Telegram behaviour:
194
+ - `useTelegramWebApp({ documentAttributes: true })` marks `<html>` with `data-telegram` and
195
+ `data-color-scheme`;
196
+ - `useTelegramBackButton(pathname !== '/', onBack)` shows the header's back arrow off the home
197
+ page. `onBack` calls `router.back()` while there is app history behind the page, and
198
+ `router.push('/')` on the history entry the Mini App opened on (a deep link from the bot, the
199
+ return from a payment page), where the history before it is not the app's. The entry is told by
200
+ its Navigation API history index, so revisiting the first page through a link still goes back;
201
+ without that API, by its path;
202
+ - `useTelegramSignIn()` signs the buyer in with the launch data — the **only** automatic sign-in
203
+ in the app. A failure keeps them browsing as a guest; `/account` shows it (the hook's state is
204
+ shared). A referral in the `startapp` parameter is read by the server: pass no `referralCode`
205
+ and never parse `start_param`.
206
+ - **Theme.** `app/globals.css` maps Telegram's colors onto the starter's tokens under
207
+ `html[data-telegram]` (`--bg`, `--fg`, `--muted`, `--surface`, `--placeholder`, `--accent`,
208
+ `--on-accent`, `--danger`), with the web values as fallbacks, and darkens `--line` for
209
+ `data-color-scheme="dark"`. The merchant's own accent (`brandStyle`, inline on `<html>`) still
210
+ wins over Telegram's button color. The merchant's `--nav-*` and `--label-*` brand tokens are not
211
+ overridden in Telegram's dark theme either: brand colors are the merchant's choice.
212
+ - **Viewport.** `body` takes `--tg-viewport-stable-height` (else `100dvh`, never `100vh`) and the
213
+ safe-area insets; tap targets are at least 44px.
214
+ - **MainButton.** `useTelegramMainButton` exists in the SDK; the starter does not use it.
215
+ - **Credentials.** `initData`, tokens and the cart id never go into a URL, a log or analytics.
216
+ - **Check by hand** with the shop's bot: opened from the bot → signed in, back arrow off the home
217
+ page, Telegram's colors in light and dark.
218
+
219
+ ## Blog — `app/blog/`, `components/article-body.tsx`, `components/blog-listing.tsx`
220
+
221
+ All blog reads are `magicstore:pages`. Rules: the backend's `docs/api/v2/blog.md`.
222
+
223
+ - **Index** `app/blog/page.tsx`: heading and intro from `shop.blog` (else `t.blog`), categories
224
+ (`api.blogCategoriesIndex()`, `CategoryNav`), editor's picks (`filter[featured]=true`, first page
225
+ only), a search box (`q`, at most 200 characters) and newest / popular (`sort=-recentViews`),
226
+ then `api.blogArticlesIndex({ query: { page, perPage: 12, … } })` with `Pager`. Only a `sort` the
227
+ API knows is passed on (`sortParam`); a `422` shows the API's message (`ArticleListing`). Search
228
+ results and sorted lists are `noindex`.
229
+ - **Category, author, tag** `app/blog/category/[handle]/page.tsx`,
230
+ `app/blog/author/[handle]/page.tsx`, `app/blog/tag/[handle]/page.tsx`: the same list with
231
+ `filter[category|author|tag]`. The API answers an unknown handle with an empty page, so the page
232
+ finds the category / author in its list (`blogCategoriesIndex` / `blogAuthorsIndex`) and is a 404
233
+ when it is not there; a tag with no articles is a 404 (there is no tag list: its title comes from
234
+ an article).
235
+ - **Article** `app/blog/[handle]/page.tsx`:
236
+ - `api.blogArticlesShow` through `orNotFound`: an article not written in this language is a 404
237
+ (no fallback). `generateMetadata` returns `articleMeta(article, { url, shop })` as is; pass
238
+ `urlForLocale` when the storefront routes several languages, and only `availableLocales` become
239
+ `hreflang` alternates.
240
+ - `<h1>{article.h1 ?? article.title}</h1>`, the cover `loading="eager"` (the LCP image),
241
+ `articleJsonLd` and `breadcrumbJsonLd` in `<script type="application/ld+json">`.
242
+ - The body: `components/article-body.tsx` renders `articleBodyBlocks(bodyHtml)` on the server.
243
+ Embedded products come from `api.productsIndex({ query: { 'filter[handles]', perPage: 100 } })`
244
+ for each `embeddedProductFilters(article.products)` value (usually one call), reordered with
245
+ `orderEmbeddedProducts`; a marker whose product did not come back renders nothing, and so does
246
+ one the editor put inside a table, list or quote (only top-level markers become blocks, so the
247
+ HTML is never cut apart). Callouts are
248
+ `<aside class="callout" data-callout>`, media embeds are links.
249
+ - Tags link to the tag page, the author block to the author page; `api.blogArticlesRelated`
250
+ (`perPage: 3`) below.
251
+ - `components/article-vote.tsx`: «was this helpful?» on `useArticleVote` (a guest votes with
252
+ `X-Anonymous-Id`; the proxy forwards it). `ArticleView` reports `ARTICLE_VIEW`.
253
+ - Dates render in `shop.timezone` (`ArticleDate`).
254
+
158
255
  ## Content pages, sitemap — `app/pages/[handle]/page.tsx`, `app/sitemap.ts`
159
256
 
160
257
  - `api.pagesShow({ path: { handle } })` (`magicstore:pages`) through `orNotFound`,
161
258
  `pageMeta({ seo, title })`; `body` is the merchant's HTML (empty until written). The layout links
162
259
  `api.pagesIndex()` in the footer.
163
260
  - `app/sitemap.ts` reads `api.sitemap({ query: { page, perPage: 1000 } })` — `{ type, handle,
164
- updatedAt }` rows — on `SITE_URL` or the shop's `primaryDomain`, at most 50 pages (the protocol's
165
- 50 000 URLs). Dynamic, like
166
- every page.
261
+ updatedAt }` rows — on `SITE_URL` or the shop's `primaryDomain` (`lib/site.ts`), at most 50 pages
262
+ (the protocol's 50 000 URLs). `ARTICLE` rows link to `/blog/{handle}`; a type the storefront does
263
+ not know is skipped. Dynamic, like every page.
167
264
 
168
265
  ## Every page
169
266
 
@@ -177,4 +274,4 @@ updatedAt }` rows — on `SITE_URL` or the shop's `primaryDomain`, at most 50 pa
177
274
  translated from the API.
178
275
  - Images through `<Image>` (intrinsic size, lazy), prices through `<Money>`.
179
276
  - Analytics: `PageViews` in `app/providers.tsx` reports each route; product, collection and search
180
- views are reported where they render.
277
+ views, and article reads, are reported where they render.
@@ -3,7 +3,8 @@
3
3
  A Next.js (App Router) storefront on the MagicStore Storefront API v2, built only on the public SDK
4
4
  (`@magicstoreai/storefront-client`, `@magicstoreai/hydrogen`). The home page from the merchant's
5
5
  sections (or your own composition: `GET /home` is optional), catalog, collections, search, product with variants, cart, checkout (pickup or delivery,
6
- payment), OTP sign-in and orders, SEO and JSON-LD, and a webhook that revalidates cached pages.
6
+ payment), OTP sign-in and orders, SEO and JSON-LD, a webhook that revalidates cached pages — and it
7
+ runs as a Telegram Mini App (sign-in from the bot, back button, Telegram's theme, phone sharing).
7
8
 
8
9
  ```bash
9
10
  cp .env.example .env.local # set MAGICSTORE_SHOP_DOMAIN
@@ -24,6 +25,11 @@ npm run dev
24
25
  origin) the browser calls the shop's API directly. Without it, calls go through the proxy route,
25
26
  which forwards the buyer's address in `X-Forwarded-For`.
26
27
 
28
+ **Telegram Mini App.** For a shop with a connected bot, set the bot's Mini App URL in BotFather to
29
+ this site's `https://` address. The layout loads Telegram's script only for such a shop;
30
+ `components/telegram-shell.tsx` signs the buyer in, shows the back arrow and applies Telegram's
31
+ theme. Details: `PAGES.md`, "Running as a Telegram Mini App".
32
+
27
33
  **Offline.** `MAGICSTORE_API_URL=http://127.0.0.1:4010` with `pnpm mock` running in the SDK repository
28
34
  serves every page from the spec's example data.
29
35
 
@@ -5,19 +5,29 @@ import {
5
5
  useCustomer,
6
6
  useMagicStore,
7
7
  useStorefrontClient,
8
+ useTelegramSignIn,
9
+ useTelegramWebApp,
8
10
  useWishlist,
9
11
  } from '@magicstoreai/hydrogen';
10
12
  import type { Schema } from '@magicstoreai/storefront-client';
11
13
  import { useEffect, useState } from 'react';
12
- import { describe } from '@/lib/errors';
14
+ import { describe, describePhoneShare } from '@/lib/errors';
13
15
  import { messages } from '@/lib/i18n';
14
16
 
15
17
  export default function AccountPage() {
16
18
  const { customer, isSignedIn, signOut } = useCustomer();
17
- const t = messages(useMagicStore().locale);
19
+ const { status, isTelegram, canRequestContact } = useTelegramWebApp();
20
+ const { locale, shop } = useMagicStore();
21
+ const t = messages(locale);
22
+ // Until the app has looked (`unknown`, server render included), a shop with a bot may be open in
23
+ // Telegram: hold the Telegram block's place so the phone form does not jump down after mount.
24
+ const maybeTelegram = status === 'unknown' && shop?.features.telegramBot === true;
18
25
  return isSignedIn && customer ? (
19
26
  <div className="stack">
20
27
  <h1>{customer.name || customer.phone}</h1>
28
+ {/* A customer who only ever signed in through Telegram has no phone yet; older Telegram
29
+ clients cannot share it. */}
30
+ {canRequestContact && !customer.phone && <SharePhone />}
21
31
  <Orders />
22
32
  <SavedCount />
23
33
  <div>
@@ -25,13 +35,76 @@ export default function AccountPage() {
25
35
  </div>
26
36
  </div>
27
37
  ) : (
28
- <SignIn />
38
+ <div className="stack" style={{ maxWidth: 360 }}>
39
+ <h1>{t.signIn}</h1>
40
+ {isTelegram && <TelegramBlock />}
41
+ {maybeTelegram && <TelegramBlock placeholder />}
42
+ <PhoneSignIn />
43
+ </div>
44
+ );
45
+ }
46
+
47
+ /**
48
+ * The Telegram sign-in above the phone form. As a `placeholder` it renders the same markup, hidden,
49
+ * so the reserved space always matches the real block.
50
+ */
51
+ function TelegramBlock({ placeholder = false }: { placeholder?: boolean }) {
52
+ const t = messages(useMagicStore().locale);
53
+ return (
54
+ <div
55
+ className="stack"
56
+ aria-hidden={placeholder || undefined}
57
+ style={placeholder ? { visibility: 'hidden' } : undefined}
58
+ >
59
+ <TelegramSignIn />
60
+ <p className="muted">{t.orWithPhone}</p>
61
+ </div>
29
62
  );
30
63
  }
31
64
 
32
- function SignIn() {
33
- const { requestOtp, verifyOtp } = useCustomer();
65
+ /**
66
+ * Inside a Mini App: sign in with the launch data. The automatic sign-in runs in
67
+ * components/telegram-shell.tsx; this is the manual retry. The hook shares the attempt's state, so
68
+ * this shows the automatic attempt too: "Signing in…" while it runs, the relaunch hint when it failed.
69
+ */
70
+ function TelegramSignIn() {
71
+ const { status, error, signIn } = useTelegramSignIn({ auto: false });
34
72
  const t = messages(useMagicStore().locale);
73
+ // FORBIDDEN: the shop has no bot, so retrying is pointless.
74
+ const unavailable = error?.code === 'FORBIDDEN';
75
+ return (
76
+ <div className="stack">
77
+ {!unavailable && (
78
+ <button
79
+ className="primary"
80
+ disabled={status === 'signing-in'}
81
+ onClick={() => void signIn()}
82
+ >
83
+ {status === 'signing-in' ? t.signingIn : t.signInWithTelegram}
84
+ </button>
85
+ )}
86
+ {status === 'failed' && (
87
+ <p className="error" role="alert">
88
+ {error?.code === 'UNAUTHENTICATED'
89
+ ? t.telegramRelaunch
90
+ : unavailable
91
+ ? t.telegramUnavailable
92
+ : describe(error, t.tryAgainLater)}
93
+ </p>
94
+ )}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Phone sign-in: a code by SMS, or — on a shop whose sign-in code is off
101
+ * (`shop.features.otpLogin === false`) — the phone alone.
102
+ */
103
+ function PhoneSignIn() {
104
+ const { requestOtp, verifyOtp, signInWithPhone } = useCustomer();
105
+ const { locale, shop } = useMagicStore();
106
+ const t = messages(locale);
107
+ const withoutCode = shop?.features.otpLogin === false;
35
108
  const [phone, setPhone] = useState('');
36
109
  const [sent, setSent] = useState(false);
37
110
  const [code, setCode] = useState('');
@@ -49,11 +122,12 @@ function SignIn() {
49
122
  return (
50
123
  <form
51
124
  className="stack"
52
- style={{ maxWidth: 360 }}
53
125
  onSubmit={(event) => {
54
126
  event.preventDefault();
55
127
  void attempt(async () => {
56
- if (!sent) {
128
+ if (withoutCode) {
129
+ await signInWithPhone(phone);
130
+ } else if (!sent) {
57
131
  await requestOtp(phone);
58
132
  setSent(true);
59
133
  } else {
@@ -62,7 +136,6 @@ function SignIn() {
62
136
  });
63
137
  }}
64
138
  >
65
- <h1>{t.signIn}</h1>
66
139
  <input
67
140
  value={phone}
68
141
  onChange={(e) => setPhone(e.target.value)}
@@ -84,12 +157,55 @@ function SignIn() {
84
157
  required
85
158
  />
86
159
  )}
87
- <button className="primary">{sent ? t.signIn : t.sendCode}</button>
88
- {error && <p className="error">{error}</p>}
160
+ <button className="primary">{sent || withoutCode ? t.signIn : t.sendCode}</button>
161
+ {error && (
162
+ <p className="error" role="alert">
163
+ {error}
164
+ </p>
165
+ )}
89
166
  </form>
90
167
  );
91
168
  }
92
169
 
170
+ /**
171
+ * Signed in inside a Mini App without a phone: share the Telegram contact, which proves the phone.
172
+ * A declined request changes nothing.
173
+ */
174
+ function SharePhone() {
175
+ const { shareTelegramPhone } = useCustomer();
176
+ const t = messages(useMagicStore().locale);
177
+ const [busy, setBusy] = useState(false);
178
+ const [error, setError] = useState<string | null>(null);
179
+
180
+ async function share() {
181
+ setBusy(true);
182
+ setError(null);
183
+ try {
184
+ await shareTelegramPhone();
185
+ } catch (e) {
186
+ setError(describePhoneShare(e, t));
187
+ } finally {
188
+ setBusy(false);
189
+ }
190
+ }
191
+
192
+ return (
193
+ <section className="stack">
194
+ <p className="muted">{t.sharePhoneHint}</p>
195
+ <div>
196
+ <button className="primary" disabled={busy} onClick={() => void share()}>
197
+ {t.sharePhone}
198
+ </button>
199
+ </div>
200
+ {error && (
201
+ <p className="error" role="alert">
202
+ {error}
203
+ </p>
204
+ )}
205
+ </section>
206
+ );
207
+ }
208
+
93
209
  function Orders() {
94
210
  const client = useStorefrontClient();
95
211
  const [orders, setOrders] = useState<Schema<'Order'>[] | null>(null);