create-magic-storefront 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-magic-storefront",
3
- "version": "0.2.0",
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",
@@ -34,6 +34,10 @@ each error code means — then `PAGES.md` for how each page type is built, and
34
34
  every language it has (`ru`, `uz`, `en`).
35
35
  - Every page handles its empty, error and not-found states (`orNotFound` turns `NOT_FOUND` into
36
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.
37
41
 
38
42
  ## Design
39
43
 
@@ -51,19 +55,20 @@ screenshot every page type at 390 and 1280px, fix what you see. Interactive UI f
51
55
 
52
56
  ## Where things go
53
57
 
54
- | Path | What |
55
- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
56
- | `app/` | Pages (server components) and route handlers |
57
- | `app/error.tsx` | Error boundary: the API's `detail`, a retry |
58
- | `app/blog/` | Blog: index, category, author, tag and article pages (`PAGES.md`, Blog) |
59
- | `app/sitemap.ts` | Sitemap from `GET /sitemap` |
60
- | `app/page.tsx` | Home: renders `GET /home` sections in the merchant's order. Optional: a custom home composes its own (`PAGES.md`) |
61
- | `components/sections/` | One renderer per home section type; an unknown type renders nothing |
62
- | `components/` | Shared UI; client components start with `'use client'` |
63
- | `lib/api.ts` | The server-side client (`nextCacheFetch`), `orNotFound` |
64
- | `app/providers.tsx` | `MagicStoreProvider`: customer, cart, wishlist, analytics in the browser |
65
- | `app/storefront-api/[...path]` | Same-origin proxy for browser calls without a public storefront token |
66
- | `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` |
67
72
 
68
73
  ## Commands
69
74
 
package/template/PAGES.md CHANGED
@@ -38,6 +38,11 @@ API's, not the build's. The fetch cache still spares the API.
38
38
  - Wraps the page in `Providers` (`MagicStoreProvider` with `shop` passed in, so the browser does not
39
39
  load it again).
40
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").
41
46
 
42
47
  ## Home — `app/page.tsx`, `components/sections/`
43
48
 
@@ -149,13 +154,68 @@ home: show new arrivals.
149
154
 
150
155
  ## Account — `app/account/page.tsx`
151
156
 
152
- - Client only. Signed out: `useCustomer().requestOtp(phone)` → `verifyOtp(phone, code)`
153
- (`OTP_INVALID`: re-type; `OTP_EXPIRED`: ask again), or Telegram / OQ / Click sign-in in those apps.
154
- 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.
155
173
  - Signed in: `customerOrdersIndex`, `customerOrdersShow`, `customerWishlistIndex`, addresses,
156
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`).
157
181
  - States: signed out, `UNAUTHENTICATED` (sign in again), no orders yet.
158
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
+
159
219
  ## Blog — `app/blog/`, `components/article-body.tsx`, `components/blog-listing.tsx`
160
220
 
161
221
  All blog reads are `magicstore:pages`. Rules: the backend's `docs/api/v2/blog.md`.
@@ -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);
@@ -4,6 +4,9 @@
4
4
  --muted: #6b6b76;
5
5
  --line: #e7e7ec;
6
6
  --danger: #c62828;
7
+ /* Controls and image placeholders: white and light grey here, Telegram's colors in a Mini App. */
8
+ --surface: #fff;
9
+ --placeholder: #f3f3f6;
7
10
  /* Brand tokens: app/layout.tsx overrides them with the merchant's colors (lib/brand.ts). */
8
11
  --accent: #1f5eff;
9
12
  --on-accent: #fff;
@@ -20,6 +23,32 @@
20
23
  color: var(--fg);
21
24
  background: var(--bg);
22
25
  }
26
+ /*
27
+ * Inside a Telegram Mini App (components/telegram-shell.tsx marks <html>): Telegram's theme, which
28
+ * its script writes as --tg-theme-* on <html>, with the defaults above as fallbacks. The merchant's
29
+ * accent from <html style> (lib/brand.ts) still wins over Telegram's button color.
30
+ */
31
+ html[data-telegram] {
32
+ --bg: var(--tg-theme-bg-color, #fff);
33
+ --fg: var(--tg-theme-text-color, #16161a);
34
+ --muted: var(--tg-theme-hint-color, #6b6b76);
35
+ --danger: var(--tg-theme-destructive-text-color, #c62828);
36
+ --surface: var(--tg-theme-secondary-bg-color, #fff);
37
+ --placeholder: var(--tg-theme-secondary-bg-color, #f3f3f6);
38
+ --accent: var(--tg-theme-button-color, #1f5eff);
39
+ --on-accent: var(--tg-theme-button-text-color, #fff);
40
+ }
41
+ html[data-telegram][data-color-scheme='dark'] {
42
+ --line: rgba(255, 255, 255, 0.14);
43
+ color-scheme: dark;
44
+ }
45
+ html[data-telegram] body {
46
+ /* Telegram's stable viewport (the sheet can be dragged), the dynamic viewport outside it. */
47
+ min-height: var(--tg-viewport-stable-height, 100dvh);
48
+ padding-left: max(env(safe-area-inset-left), var(--tg-safe-area-inset-left, 0px));
49
+ padding-right: max(env(safe-area-inset-right), var(--tg-safe-area-inset-right, 0px));
50
+ padding-bottom: max(env(safe-area-inset-bottom), var(--tg-safe-area-inset-bottom, 0px));
51
+ }
23
52
  * {
24
53
  box-sizing: border-box;
25
54
  }
@@ -76,7 +105,7 @@ header.site .brand img {
76
105
  height: auto;
77
106
  aspect-ratio: 1;
78
107
  object-fit: cover;
79
- background: #f3f3f6;
108
+ background: var(--placeholder);
80
109
  border-radius: 8px;
81
110
  }
82
111
  .muted {
@@ -117,7 +146,9 @@ button,
117
146
  padding: 10px 16px;
118
147
  border-radius: 8px;
119
148
  border: 1px solid var(--line);
120
- background: #fff;
149
+ background: var(--surface);
150
+ color: var(--fg);
151
+ min-height: 44px;
121
152
  cursor: pointer;
122
153
  text-decoration: none;
123
154
  }
@@ -137,7 +168,10 @@ button:disabled {
137
168
  input,
138
169
  select {
139
170
  font: inherit;
171
+ min-height: 44px;
140
172
  padding: 9px 12px;
173
+ background: var(--surface);
174
+ color: var(--fg);
141
175
  border: 1px solid var(--line);
142
176
  border-radius: 8px;
143
177
  }
@@ -286,7 +320,7 @@ fieldset {
286
320
  width: 80px;
287
321
  height: 80px;
288
322
  object-fit: cover;
289
- background: #f3f3f6;
323
+ background: var(--placeholder);
290
324
  border-radius: 12px;
291
325
  }
292
326
  .stories-circle .story img,
@@ -364,7 +398,7 @@ footer.site nav {
364
398
  padding: 12px 16px;
365
399
  border-left: 4px solid var(--accent);
366
400
  border-radius: 8px;
367
- background: #f6f7fb;
401
+ background: var(--placeholder);
368
402
  }
369
403
  .callout[data-callout='warning'] {
370
404
  border-left-color: var(--danger);
@@ -1,8 +1,12 @@
1
1
  import type { Metadata } from 'next';
2
2
  import Link from 'next/link';
3
+ import Script from 'next/script';
3
4
  import type { ReactNode } from 'react';
4
5
  import { Image } from '@magicstoreai/hydrogen';
6
+ // From /core, not the "use client" entry: a plain string a server component can read.
7
+ import { TELEGRAM_WEB_APP_SCRIPT } from '@magicstoreai/hydrogen/core';
5
8
  import { CartLink } from '@/components/cart-link';
9
+ import { TelegramShell } from '@/components/telegram-shell';
6
10
  import { api, locale } from '@/lib/api';
7
11
  import { brandStyle } from '@/lib/brand';
8
12
  import { messages } from '@/lib/i18n';
@@ -34,7 +38,12 @@ export default async function RootLayout({ children }: { children: ReactNode })
34
38
  // The merchant's colors, read per request: a change in the admin shows after SHOP_UPDATED.
35
39
  <html lang={locale} style={brandStyle(shop.branding)}>
36
40
  <body>
41
+ {/* Telegram's Mini App script, only for a shop with a bot: nobody else can open it in Telegram. */}
42
+ {shop.features.telegramBot && (
43
+ <Script src={TELEGRAM_WEB_APP_SCRIPT} strategy="beforeInteractive" />
44
+ )}
37
45
  <Providers shop={shop}>
46
+ <TelegramShell />
38
47
  <header className="site">
39
48
  <nav>
40
49
  <Link href="/" className="brand">
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useTelegramBackButton,
5
+ useTelegramSignIn,
6
+ useTelegramWebApp,
7
+ } from '@magicstoreai/hydrogen';
8
+ import { usePathname, useRouter } from 'next/navigation';
9
+ import { useEffect, useRef } from 'react';
10
+
11
+ /**
12
+ * The storefront as a Telegram Mini App. Renders nothing; outside Telegram every hook is a no-op.
13
+ *
14
+ * - marks `<html>` with `data-telegram` / `data-color-scheme`, so `globals.css` takes Telegram's
15
+ * theme colors;
16
+ * - shows the header's back arrow on every page but the home page. It goes back only while there is
17
+ * history of ours behind the page; on the history entry the Mini App opened on (a deep link from
18
+ * the bot, the return from a payment page) it goes home. Where the browser has the Navigation API
19
+ * the entry is told by its history index, so revisiting the first page through a link still goes
20
+ * back; elsewhere by its path;
21
+ * - signs the buyer in with the launch data once per visit — the only automatic Telegram sign-in in
22
+ * the app (`/account` offers a manual one). A failure keeps the buyer browsing as a guest;
23
+ * `/account` explains it. A referral in the `startapp` parameter is read by the server.
24
+ */
25
+ export function TelegramShell() {
26
+ const pathname = usePathname();
27
+ const router = useRouter();
28
+ useTelegramWebApp({ documentAttributes: true });
29
+ // The page the Mini App opened on: history before it is not ours.
30
+ const entry = useRef<{ pathname: string; index: number | null }>({ pathname, index: null });
31
+ useEffect(() => {
32
+ entry.current.index = historyIndex();
33
+ }, []);
34
+ useTelegramBackButton(pathname !== '/', () => {
35
+ const index = historyIndex();
36
+ const atEntry =
37
+ index !== null && entry.current.index !== null
38
+ ? index <= entry.current.index
39
+ : pathname === entry.current.pathname;
40
+ if (atEntry) {
41
+ router.push('/');
42
+ } else {
43
+ router.back();
44
+ }
45
+ });
46
+ useTelegramSignIn();
47
+ return null;
48
+ }
49
+
50
+ /** The current history entry's index (Navigation API), or null where the browser lacks the API. */
51
+ function historyIndex(): number | null {
52
+ const { navigation } = window as { navigation?: { currentEntry: NavigationHistoryEntry | null } };
53
+ return navigation?.currentEntry?.index ?? null;
54
+ }
@@ -5,3 +5,24 @@ import { MagicStoreError } from '@magicstoreai/storefront-client';
5
5
  export function describe(error: unknown, fallback: string): string {
6
6
  return error instanceof MagicStoreError ? (error.detail ?? error.message) : fallback;
7
7
  }
8
+
9
+ /**
10
+ * Why sharing the Telegram phone (`POST /customer/phone-verification`) failed. `CONTACT_EXPIRED`
11
+ * asks to share again; `CONTACT_INVALID`, `CONTACT_NOT_OWN`, `INVALID_PHONE` and `TAKEN` show the
12
+ * API's localized message for `telegramContact`; anything else falls back to `describe()`.
13
+ */
14
+ export function describePhoneShare(
15
+ error: unknown,
16
+ text: { sharePhoneAgain: string; tryAgainLater: string },
17
+ ): string {
18
+ if (error instanceof MagicStoreError) {
19
+ const refusal = error.errors.find((entry) => entry.field === 'telegramContact');
20
+ if (refusal?.code === 'CONTACT_EXPIRED') {
21
+ return text.sharePhoneAgain;
22
+ }
23
+ if (refusal !== undefined) {
24
+ return refusal.message;
25
+ }
26
+ }
27
+ return describe(error, text.tryAgainLater);
28
+ }
@@ -117,6 +117,16 @@ const en = {
117
117
  signIn: 'Sign in',
118
118
  sendCode: 'Send code',
119
119
  smsCode: 'Code from the SMS',
120
+ // Telegram Mini App
121
+ signInWithTelegram: 'Sign in with Telegram',
122
+ signingIn: 'Signing in…',
123
+ telegramRelaunch:
124
+ 'Your Telegram sign-in has expired. Close the shop and open it again from the bot.',
125
+ telegramUnavailable: 'Sign-in with Telegram is not available in this shop.',
126
+ orWithPhone: 'Or sign in with your phone number',
127
+ sharePhone: 'Share phone number',
128
+ sharePhoneHint: 'Share your Telegram phone number so the shop can reach you about orders.',
129
+ sharePhoneAgain: 'That contact has expired. Share it again.',
120
130
  signOut: 'Sign out',
121
131
  orders: 'Orders',
122
132
  loadingOrders: 'Loading orders…',
@@ -227,6 +237,14 @@ const ru: Messages = {
227
237
  signIn: 'Войти',
228
238
  sendCode: 'Получить код',
229
239
  smsCode: 'Код из SMS',
240
+ signInWithTelegram: 'Войти через Telegram',
241
+ signingIn: 'Входим…',
242
+ telegramRelaunch: 'Вход через Telegram устарел. Закройте магазин и откройте его снова из бота.',
243
+ telegramUnavailable: 'Вход через Telegram в этом магазине недоступен.',
244
+ orWithPhone: 'Или войдите по номеру телефона',
245
+ sharePhone: 'Поделиться номером',
246
+ sharePhoneHint: 'Поделитесь номером из Telegram, чтобы магазин мог связаться с вами по заказам.',
247
+ sharePhoneAgain: 'Контакт устарел. Поделитесь им ещё раз.',
230
248
  signOut: 'Выйти',
231
249
  orders: 'Заказы',
232
250
  loadingOrders: 'Загружаем заказы…',
@@ -334,6 +352,15 @@ const uz: Messages = {
334
352
  signIn: 'Kirish',
335
353
  sendCode: 'Kod olish',
336
354
  smsCode: 'SMS dagi kod',
355
+ signInWithTelegram: 'Telegram orqali kirish',
356
+ signingIn: 'Kirilmoqda…',
357
+ telegramRelaunch: "Telegram orqali kirish eskirdi. Do'konni yoping va botdan qayta oching.",
358
+ telegramUnavailable: "Bu do'konda Telegram orqali kirish mavjud emas.",
359
+ orWithPhone: 'Yoki telefon raqami bilan kiring',
360
+ sharePhone: 'Raqamni ulashish',
361
+ sharePhoneHint:
362
+ "Do'kon buyurtmalar bo'yicha bog'lana olishi uchun Telegram raqamingizni ulashing.",
363
+ sharePhoneAgain: 'Kontakt eskirdi. Uni qayta ulashing.',
337
364
  signOut: 'Chiqish',
338
365
  orders: 'Buyurtmalar',
339
366
  loadingOrders: 'Buyurtmalar yuklanmoqda…',
package/template/llms.txt CHANGED
@@ -54,7 +54,7 @@ product, search, blog, cart, checkout, sign-in, orders, SEO, webhook revalidatio
54
54
  any other origin the API answers `ORIGIN_NOT_ALLOWED`. Without a token for the browser, proxy
55
55
  browser calls through your own server (the starter's `app/storefront-api/[...path]`).
56
56
  - **Which customer** — a customer session: access token (60 min) + refresh token (30 days,
57
- single-use), only on customer routes. `useCustomer()` signs in (OTP, Telegram, OQ, Click),
57
+ single-use), only on customer routes. `useCustomer()` signs in (OTP, Telegram, phone-only, OQ, Click),
58
58
  refreshes and signs out; `MagicStoreProvider` keeps the session in storage.
59
59
 
60
60
  Public catalog reads need no customer. Render them on the server with a client whose `fetch` is
@@ -144,6 +144,40 @@ for buyers), quote `error.requestId` when reporting. GET calls retry on 429 / 5x
144
144
 
145
145
  The full list, with the `meta` each code carries, is in the backend's `docs/api/v2/standards.md`.
146
146
 
147
+ ## Telegram Mini App
148
+
149
+ The same storefront runs inside Telegram as a Mini App (`shop.features.telegramBot` says the shop has
150
+ a bot). The app loads `TELEGRAM_WEB_APP_SCRIPT` (from `@magicstoreai/hydrogen/core`) in the document
151
+ head; the SDK never injects it.
152
+
153
+ - `useTelegramWebApp({ documentAttributes: true })`: `status` is `unknown` on the server and the first
154
+ client render, then `telegram` or `browser` (a Mini App = `window.Telegram.WebApp` with a non-empty
155
+ `initData`). Marks `<html data-telegram data-color-scheme>`; map Telegram's `--tg-theme-*` CSS
156
+ variables onto your tokens under `html[data-telegram]`.
157
+ - `useTelegramSignIn()`: signs in once per provider with `initData` (`POST /auth/telegram`) when
158
+ opened from the bot, unless the stored session was signed in as that same Telegram user (any other
159
+ session is signed out first). `failed` + `UNAUTHENTICATED` → "reopen the shop from the bot";
160
+ `FORBIDDEN` → the shop has no bot. `status` / `error` are shared under one provider, so an
161
+ `{ auto: false }` page shows the automatic attempt's progress and failure.
162
+ - `useTelegramBackButton(visible, onBack)`, `useTelegramMainButton(options | null)`: one button per
163
+ page, registrations stack (the latest owns it); MainButton changes apply in place.
164
+ - `useCustomer().shareTelegramPhone()`: `requestContact` → `POST /customer/phone-verification` with
165
+ the signed `response`; `null` when declined; rejects `UNAUTHENTICATED` at once when nobody is
166
+ signed in (show the button to signed-in customers only, and only while
167
+ `useTelegramWebApp().canRequestContact` — older clients resolve `null` without asking). Refusals: `VALIDATION_FAILED` with `CONTACT_EXPIRED`
168
+ (share again), `CONTACT_INVALID`, `CONTACT_NOT_OWN`, `INVALID_PHONE`, `TAKEN` on `telegramContact`.
169
+ - `useCustomer().signInWithPhone(phone)`: only when `shop.features.otpLogin === false`.
170
+
171
+ - `useCustomer().verifyPhoneWithTelegram(telegramContact)`: the same proof with a contact the app
172
+ got itself. `/core`: `TelegramWebAppController.mainButton(options)` → `{ update, release }`.
173
+ - Call `useTelegramSignIn()` with `auto` in exactly one app-wide component; anywhere else
174
+ `{ auto: false }` + `signIn()`. Load the script only when `shop.features.telegramBot` is on. Starter
175
+ reference: `examples/starter/PAGES.md` → "Running as a Telegram Mini App".
176
+
177
+ The SDK does not validate `initData` (the server does), does not parse referrals out of `startapp`
178
+ (the server attributes them), and never sends `responseUnsafe`. Never put `initData` in a URL, a log
179
+ or analytics.
180
+
147
181
  ## What the SDK does not do
148
182
 
149
183
  - Compute or round prices, discounts, taxes, delivery fees or stock — the API returns them.
@@ -18,9 +18,11 @@
18
18
  "server-only": "^0.0.1"
19
19
  },
20
20
  "devDependencies": {
21
+ "@testing-library/react": "^16.3.3",
21
22
  "@types/node": "^26.6.2",
22
23
  "@types/react": "^19.3.0",
23
24
  "@types/react-dom": "^19.3.0",
25
+ "jsdom": "^30.1.1",
24
26
  "typescript": "5.9.3"
25
27
  }
26
28
  }