create-magic-storefront 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/dist/index.js +523 -9
- package/package.json +7 -1
- package/template/.claude/skills/theme-section/SKILL.md +157 -0
- package/template/.env.example +7 -1
- package/template/.screenshots/home-1280.png +0 -0
- package/template/.screenshots/home-390.png +0 -0
- package/template/.screenshots/home-filled-1280.png +0 -0
- package/template/.screenshots/home-filled-390.png +0 -0
- package/template/.screenshots/page-1280.png +0 -0
- package/template/AGENTS.md +58 -16
- package/template/PAGES.md +107 -30
- package/template/README.md +13 -1
- package/template/_gitignore +2 -0
- package/template/app/account/page.tsx +126 -10
- package/template/app/api/magicstore/preview/route.ts +56 -0
- package/template/app/globals.css +146 -4
- package/template/app/layout.tsx +25 -6
- package/template/app/page.tsx +18 -9
- package/template/app/pages/[handle]/page.tsx +26 -11
- package/template/components/sections/collections.tsx +2 -1
- package/template/components/sections/product-shelves.tsx +2 -1
- package/template/components/telegram-shell.tsx +54 -0
- package/template/lib/api.ts +40 -10
- package/template/lib/errors.ts +21 -0
- package/template/lib/i18n.ts +33 -0
- package/template/lib/preview.ts +21 -0
- package/template/llms.txt +175 -2
- package/template/package.json +8 -3
- package/template/scripts/theme.mjs +53 -0
- package/template/theme/index.ts +53 -0
- package/template/theme/sections/collection-list.tsx +64 -0
- package/template/theme/sections/featured-collection.tsx +56 -0
- package/template/theme/sections/hero.tsx +95 -0
- package/template/theme/sections/image-with-text.tsx +71 -0
- package/template/theme/sections/page-content.tsx +45 -0
- package/template/theme/sections/platform-home.tsx +37 -0
- package/template/theme/sections/product-shelf.tsx +96 -0
- package/template/theme/sections/rich-text.tsx +43 -0
- package/template/theme/sections/testimonials.tsx +84 -0
- package/template/theme/settings.ts +19 -0
- package/template/theme/templates/index.json +9 -0
- package/template/theme/templates/page.json +6 -0
- package/template/theme/text.ts +21 -0
- package/template/tsconfig.json +1 -1
|
@@ -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);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
THEME_PREVIEW_COOKIE,
|
|
3
|
+
previewRedirectPath,
|
|
4
|
+
verifyThemePreview,
|
|
5
|
+
} from '@magicstoreai/hydrogen/server';
|
|
6
|
+
import { cookies, draftMode } from 'next/headers';
|
|
7
|
+
import { redirect } from 'next/navigation';
|
|
8
|
+
|
|
9
|
+
import { storefrontToken } from '@/lib/api';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The admin's theme editor opens `/api/magicstore/preview?token=…&path=/` (in a frame or a tab) to
|
|
13
|
+
* show an unpublished theme or unsaved changes. A valid token turns on draft mode and is kept in an
|
|
14
|
+
* httpOnly cookie for its ten minutes; pages then send it as `X-Theme-Preview`, uncached.
|
|
15
|
+
* `?exit=1` ends the preview. The token is signed with the webhook secret and never logged.
|
|
16
|
+
*/
|
|
17
|
+
export async function GET(request: Request): Promise<Response> {
|
|
18
|
+
const secret = process.env.MAGICSTORE_WEBHOOK_SECRET;
|
|
19
|
+
if (!secret) {
|
|
20
|
+
return new Response('MAGICSTORE_WEBHOOK_SECRET is not set', { status: 501 });
|
|
21
|
+
}
|
|
22
|
+
if (!storefrontToken()) {
|
|
23
|
+
// The API previews only a request that names its storefront token.
|
|
24
|
+
return new Response('MAGICSTORE_STOREFRONT_TOKEN is not set', { status: 501 });
|
|
25
|
+
}
|
|
26
|
+
const params = new URL(request.url).searchParams;
|
|
27
|
+
const path = previewRedirectPath(params.get('path'));
|
|
28
|
+
const jar = await cookies();
|
|
29
|
+
|
|
30
|
+
if (params.has('exit')) {
|
|
31
|
+
(await draftMode()).disable();
|
|
32
|
+
jar.delete(THEME_PREVIEW_COOKIE);
|
|
33
|
+
redirect(path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const token = params.get('token');
|
|
37
|
+
const now = Math.floor(Date.now() / 1000);
|
|
38
|
+
const preview = await verifyThemePreview(secret, token, now);
|
|
39
|
+
if (preview === null || token === null) {
|
|
40
|
+
return new Response('The preview link is invalid or has expired.', {
|
|
41
|
+
status: 403,
|
|
42
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
(await draftMode()).enable();
|
|
47
|
+
jar.set(THEME_PREVIEW_COOKIE, token, {
|
|
48
|
+
httpOnly: true,
|
|
49
|
+
secure: true,
|
|
50
|
+
// The editor shows the storefront in a frame on the admin's origin.
|
|
51
|
+
sameSite: 'none',
|
|
52
|
+
path: '/',
|
|
53
|
+
maxAge: Math.max(1, preview.expiresAt - now),
|
|
54
|
+
});
|
|
55
|
+
redirect(path);
|
|
56
|
+
}
|
package/template/app/globals.css
CHANGED
|
@@ -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);
|
|
@@ -376,3 +410,111 @@ footer.site nav {
|
|
|
376
410
|
.author h1 {
|
|
377
411
|
margin: 0;
|
|
378
412
|
}
|
|
413
|
+
/* Theme sections (theme/sections/): the merchant edits their content in the admin. */
|
|
414
|
+
/* A section with nothing to show yet renders nothing, but ThemeSections still wraps it. */
|
|
415
|
+
[data-section-id]:empty {
|
|
416
|
+
display: none;
|
|
417
|
+
}
|
|
418
|
+
.site-announcement {
|
|
419
|
+
margin: 0;
|
|
420
|
+
padding: 8px 16px;
|
|
421
|
+
background: var(--accent);
|
|
422
|
+
color: var(--on-accent);
|
|
423
|
+
text-align: center;
|
|
424
|
+
}
|
|
425
|
+
.hero {
|
|
426
|
+
display: grid;
|
|
427
|
+
border-radius: 12px;
|
|
428
|
+
overflow: hidden;
|
|
429
|
+
background: var(--placeholder);
|
|
430
|
+
}
|
|
431
|
+
.hero > img,
|
|
432
|
+
.hero-text {
|
|
433
|
+
grid-area: 1 / 1;
|
|
434
|
+
}
|
|
435
|
+
.hero > img {
|
|
436
|
+
width: 100%;
|
|
437
|
+
height: auto;
|
|
438
|
+
aspect-ratio: 21 / 9;
|
|
439
|
+
object-fit: cover;
|
|
440
|
+
}
|
|
441
|
+
.hero-text {
|
|
442
|
+
align-self: center;
|
|
443
|
+
padding: 40px 24px;
|
|
444
|
+
}
|
|
445
|
+
.hero > img + .hero-text {
|
|
446
|
+
background: linear-gradient(to top, rgb(0 0 0 / 0.7), rgb(0 0 0 / 0.35) 55%, transparent);
|
|
447
|
+
color: #fff;
|
|
448
|
+
text-shadow: 0 1px 2px rgb(0 0 0 / 0.4);
|
|
449
|
+
align-self: stretch;
|
|
450
|
+
justify-content: flex-end;
|
|
451
|
+
}
|
|
452
|
+
.hero-center .hero-text {
|
|
453
|
+
align-items: center;
|
|
454
|
+
text-align: center;
|
|
455
|
+
}
|
|
456
|
+
.hero-text h1,
|
|
457
|
+
.hero-text p {
|
|
458
|
+
margin: 0;
|
|
459
|
+
max-width: 40ch;
|
|
460
|
+
}
|
|
461
|
+
.hero-text .button,
|
|
462
|
+
.image-with-text .button {
|
|
463
|
+
align-self: flex-start;
|
|
464
|
+
text-shadow: none;
|
|
465
|
+
}
|
|
466
|
+
.hero-center .hero-text .button {
|
|
467
|
+
align-self: center;
|
|
468
|
+
}
|
|
469
|
+
@media (max-width: 720px) {
|
|
470
|
+
.hero > img {
|
|
471
|
+
aspect-ratio: 4 / 5;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
.rich-text-narrow {
|
|
475
|
+
max-width: 760px;
|
|
476
|
+
}
|
|
477
|
+
.image-with-text {
|
|
478
|
+
display: grid;
|
|
479
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
480
|
+
gap: 32px;
|
|
481
|
+
align-items: center;
|
|
482
|
+
}
|
|
483
|
+
.image-with-text.image-right > img {
|
|
484
|
+
order: 2;
|
|
485
|
+
}
|
|
486
|
+
.image-with-text > img {
|
|
487
|
+
width: 100%;
|
|
488
|
+
height: auto;
|
|
489
|
+
aspect-ratio: 4 / 3;
|
|
490
|
+
object-fit: cover;
|
|
491
|
+
border-radius: 12px;
|
|
492
|
+
}
|
|
493
|
+
@media (max-width: 720px) {
|
|
494
|
+
.image-with-text {
|
|
495
|
+
grid-template-columns: 1fr;
|
|
496
|
+
}
|
|
497
|
+
.image-with-text.image-right > img {
|
|
498
|
+
order: 0;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
.testimonials {
|
|
502
|
+
display: grid;
|
|
503
|
+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
504
|
+
gap: 20px;
|
|
505
|
+
}
|
|
506
|
+
.testimonials figure {
|
|
507
|
+
margin: 0;
|
|
508
|
+
padding: 16px;
|
|
509
|
+
border: 1px solid var(--line);
|
|
510
|
+
border-radius: 12px;
|
|
511
|
+
}
|
|
512
|
+
.testimonials blockquote {
|
|
513
|
+
margin: 0;
|
|
514
|
+
padding: 0;
|
|
515
|
+
border: 0;
|
|
516
|
+
}
|
|
517
|
+
.testimonials .rating {
|
|
518
|
+
color: var(--accent);
|
|
519
|
+
letter-spacing: 2px;
|
|
520
|
+
}
|
package/template/app/layout.tsx
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
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';
|
|
8
|
+
import { loadThemeSettings } from '@magicstoreai/hydrogen/server';
|
|
5
9
|
import { CartLink } from '@/components/cart-link';
|
|
10
|
+
import { TelegramShell } from '@/components/telegram-shell';
|
|
6
11
|
import { api, locale } from '@/lib/api';
|
|
12
|
+
import { themePreview } from '@/lib/preview';
|
|
7
13
|
import { brandStyle } from '@/lib/brand';
|
|
8
14
|
import { messages } from '@/lib/i18n';
|
|
15
|
+
import theme from '@/theme';
|
|
9
16
|
import { Providers } from './providers';
|
|
10
17
|
import './globals.css';
|
|
11
18
|
|
|
@@ -23,18 +30,30 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
26
|
-
const [{ data: shop }, { data: collections }, { data: pages }] = await Promise.all(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
const [{ data: shop }, { data: collections }, { data: pages }, themeSettings] = await Promise.all(
|
|
34
|
+
[
|
|
35
|
+
api.shop(),
|
|
36
|
+
api.collectionsIndex({ query: { perPage: 6 } }),
|
|
37
|
+
api.pagesIndex(),
|
|
38
|
+
themePreview().then((preview) => loadThemeSettings(api, theme, { locale, preview })),
|
|
39
|
+
],
|
|
40
|
+
);
|
|
41
|
+
const announcement = themeSettings.announcement as string;
|
|
31
42
|
const t = messages(locale);
|
|
32
43
|
|
|
33
44
|
return (
|
|
34
45
|
// The merchant's colors, read per request: a change in the admin shows after SHOP_UPDATED.
|
|
35
|
-
<html
|
|
46
|
+
// Telegram's script (beforeInteractive) writes --tg-viewport-* into <html style> before React
|
|
47
|
+
// hydrates; suppressHydrationWarning covers this element's attributes only, not its children.
|
|
48
|
+
<html lang={locale} style={brandStyle(shop.branding)} suppressHydrationWarning>
|
|
36
49
|
<body>
|
|
50
|
+
{/* Telegram's Mini App script, only for a shop with a bot: nobody else can open it in Telegram. */}
|
|
51
|
+
{shop.features.telegramBot && (
|
|
52
|
+
<Script src={TELEGRAM_WEB_APP_SCRIPT} strategy="beforeInteractive" />
|
|
53
|
+
)}
|
|
37
54
|
<Providers shop={shop}>
|
|
55
|
+
<TelegramShell />
|
|
56
|
+
{announcement && <p className="site-announcement">{announcement}</p>}
|
|
38
57
|
<header className="site">
|
|
39
58
|
<nav>
|
|
40
59
|
<Link href="/" className="brand">
|
package/template/app/page.tsx
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { loadTemplate } from '@magicstoreai/hydrogen/server';
|
|
2
|
+
import { ThemeSections } from '@magicstoreai/hydrogen/theme';
|
|
3
3
|
|
|
4
|
+
import { api, locale } from '@/lib/api';
|
|
5
|
+
import { themePreview } from '@/lib/preview';
|
|
6
|
+
import theme, { components } from '@/theme';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Home: the merchant's `index` template (edited in the admin's theme editor), or the theme's bundled
|
|
10
|
+
* one until the theme is pushed; the admin's draft while it previews (lib/preview.ts). It never needs `GET /home`; the `platform-home` section brings
|
|
11
|
+
* that layout in when the merchant adds it.
|
|
12
|
+
*/
|
|
4
13
|
export default async function Home() {
|
|
5
|
-
const { data:
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
const [{ data: shop }, preview] = await Promise.all([api.shop(), themePreview()]);
|
|
15
|
+
const sections = await loadTemplate(api, 'index', theme, {
|
|
16
|
+
locale,
|
|
17
|
+
defaultLocale: shop.defaultLocale,
|
|
18
|
+
preview,
|
|
19
|
+
});
|
|
11
20
|
return (
|
|
12
21
|
<div className="home">
|
|
13
|
-
<
|
|
22
|
+
<ThemeSections sections={sections} components={components} />
|
|
14
23
|
</div>
|
|
15
24
|
);
|
|
16
25
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
|
+
import { loadTemplate } from '@magicstoreai/hydrogen/server';
|
|
2
3
|
import { pageMeta } from '@magicstoreai/hydrogen/seo';
|
|
4
|
+
import { ThemeSections, type SectionComponents } from '@magicstoreai/hydrogen/theme';
|
|
3
5
|
import { api, locale, orNotFound } from '@/lib/api';
|
|
4
|
-
import {
|
|
6
|
+
import { themePreview } from '@/lib/preview';
|
|
7
|
+
import theme, { components } from '@/theme';
|
|
8
|
+
import { PageContent } from '@/theme/sections/page-content';
|
|
5
9
|
|
|
6
10
|
type Props = { params: Promise<{ handle: string }> };
|
|
7
11
|
|
|
@@ -12,18 +16,29 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
12
16
|
return { title: meta.title, description: meta.description };
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* A merchant's content page (about, delivery, offer…), laid out by the theme's `page` template:
|
|
21
|
+
* `page-content` shows the page's own title and body, other sections sit around it.
|
|
22
|
+
*/
|
|
16
23
|
export default async function ContentPage({ params }: Props) {
|
|
17
24
|
const { handle } = await params;
|
|
18
|
-
const { data: page }
|
|
25
|
+
const [{ data: page }, { data: shop }, preview] = await Promise.all([
|
|
26
|
+
orNotFound(api.pagesShow({ path: { handle } })),
|
|
27
|
+
api.shop(),
|
|
28
|
+
themePreview(),
|
|
29
|
+
]);
|
|
30
|
+
const sections = await loadTemplate(api, 'page', theme, {
|
|
31
|
+
locale,
|
|
32
|
+
defaultLocale: shop.defaultLocale,
|
|
33
|
+
preview,
|
|
34
|
+
});
|
|
35
|
+
const pageComponents: SectionComponents<typeof theme.sections> = {
|
|
36
|
+
...components,
|
|
37
|
+
'page-content': (props) => <PageContent {...props} page={page} />,
|
|
38
|
+
};
|
|
19
39
|
return (
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
<div dangerouslySetInnerHTML={{ __html: page.body }} />
|
|
24
|
-
) : (
|
|
25
|
-
<p className="muted">{messages(locale).nothingHere}</p>
|
|
26
|
-
)}
|
|
27
|
-
</article>
|
|
40
|
+
<div className="home">
|
|
41
|
+
<ThemeSections sections={sections} components={pageComponents} />
|
|
42
|
+
</div>
|
|
28
43
|
);
|
|
29
44
|
}
|
|
@@ -6,7 +6,8 @@ import { api } from '@/lib/api';
|
|
|
6
6
|
|
|
7
7
|
import type { SectionOf } from './types';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** Collection tiles linking to each collection; renders nothing for none. */
|
|
10
|
+
export function CollectionTiles({
|
|
10
11
|
collections,
|
|
11
12
|
className = '',
|
|
12
13
|
}: {
|
|
@@ -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
|
+
}
|