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 +1 -1
- package/template/AGENTS.md +18 -13
- package/template/PAGES.md +63 -3
- package/template/README.md +7 -1
- package/template/app/account/page.tsx +126 -10
- package/template/app/globals.css +38 -4
- package/template/app/layout.tsx +9 -0
- package/template/components/telegram-shell.tsx +54 -0
- package/template/lib/errors.ts +21 -0
- package/template/lib/i18n.ts +27 -0
- package/template/llms.txt +35 -1
- package/template/package.json +2 -0
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -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
|
|
55
|
-
|
|
|
56
|
-
| `app/`
|
|
57
|
-
| `app/error.tsx`
|
|
58
|
-
| `app/blog/`
|
|
59
|
-
| `app/sitemap.ts`
|
|
60
|
-
| `app/page.tsx`
|
|
61
|
-
| `components/sections/`
|
|
62
|
-
| `components/`
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `app/
|
|
66
|
-
| `app/api/
|
|
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:
|
|
153
|
-
(`
|
|
154
|
-
|
|
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`.
|
package/template/README.md
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
33
|
-
|
|
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 (
|
|
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 &&
|
|
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);
|
package/template/app/globals.css
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
401
|
+
background: var(--placeholder);
|
|
368
402
|
}
|
|
369
403
|
.callout[data-callout='warning'] {
|
|
370
404
|
border-left-color: var(--danger);
|
package/template/app/layout.tsx
CHANGED
|
@@ -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
|
+
}
|
package/template/lib/errors.ts
CHANGED
|
@@ -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
|
+
}
|
package/template/lib/i18n.ts
CHANGED
|
@@ -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.
|
package/template/package.json
CHANGED