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.
Files changed (45) hide show
  1. package/README.md +18 -1
  2. package/dist/index.js +523 -9
  3. package/package.json +7 -1
  4. package/template/.claude/skills/theme-section/SKILL.md +157 -0
  5. package/template/.env.example +7 -1
  6. package/template/.screenshots/home-1280.png +0 -0
  7. package/template/.screenshots/home-390.png +0 -0
  8. package/template/.screenshots/home-filled-1280.png +0 -0
  9. package/template/.screenshots/home-filled-390.png +0 -0
  10. package/template/.screenshots/page-1280.png +0 -0
  11. package/template/AGENTS.md +58 -16
  12. package/template/PAGES.md +107 -30
  13. package/template/README.md +13 -1
  14. package/template/_gitignore +2 -0
  15. package/template/app/account/page.tsx +126 -10
  16. package/template/app/api/magicstore/preview/route.ts +56 -0
  17. package/template/app/globals.css +146 -4
  18. package/template/app/layout.tsx +25 -6
  19. package/template/app/page.tsx +18 -9
  20. package/template/app/pages/[handle]/page.tsx +26 -11
  21. package/template/components/sections/collections.tsx +2 -1
  22. package/template/components/sections/product-shelves.tsx +2 -1
  23. package/template/components/telegram-shell.tsx +54 -0
  24. package/template/lib/api.ts +40 -10
  25. package/template/lib/errors.ts +21 -0
  26. package/template/lib/i18n.ts +33 -0
  27. package/template/lib/preview.ts +21 -0
  28. package/template/llms.txt +175 -2
  29. package/template/package.json +8 -3
  30. package/template/scripts/theme.mjs +53 -0
  31. package/template/theme/index.ts +53 -0
  32. package/template/theme/sections/collection-list.tsx +64 -0
  33. package/template/theme/sections/featured-collection.tsx +56 -0
  34. package/template/theme/sections/hero.tsx +95 -0
  35. package/template/theme/sections/image-with-text.tsx +71 -0
  36. package/template/theme/sections/page-content.tsx +45 -0
  37. package/template/theme/sections/platform-home.tsx +37 -0
  38. package/template/theme/sections/product-shelf.tsx +96 -0
  39. package/template/theme/sections/rich-text.tsx +43 -0
  40. package/template/theme/sections/testimonials.tsx +84 -0
  41. package/template/theme/settings.ts +19 -0
  42. package/template/theme/templates/index.json +9 -0
  43. package/template/theme/templates/page.json +6 -0
  44. package/template/theme/text.ts +21 -0
  45. 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 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);
@@ -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
+ }
@@ -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);
@@ -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
+ }
@@ -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
- api.shop(),
28
- api.collectionsIndex({ query: { perPage: 6 } }),
29
- api.pagesIndex(),
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 lang={locale} style={brandStyle(shop.branding)}>
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">
@@ -1,16 +1,25 @@
1
- import { HomeSections } from '@/components/sections';
2
- import { api } from '@/lib/api';
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: home } = await api.home();
6
- // A shop that has not laid out its home page yet still greets buyers with its newest products.
7
- const sections =
8
- home.sections.length > 0
9
- ? home.sections
10
- : [{ id: 'default', type: 'NEW_ARRIVALS' as const, settings: { limit: null } }];
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
- <HomeSections sections={sections} />
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 { messages } from '@/lib/i18n';
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
- /** A merchant's content page (about, delivery, offer…). `body` is the merchant's HTML. */
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 } = await orNotFound(api.pagesShow({ path: { handle } }));
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
- <article className="stack">
21
- <h1>{page.title}</h1>
22
- {page.body ? (
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
- function CollectionTiles({
9
+ /** Collection tiles linking to each collection; renders nothing for none. */
10
+ export function CollectionTiles({
10
11
  collections,
11
12
  className = '',
12
13
  }: {
@@ -8,7 +8,8 @@ import { messages } from '@/lib/i18n';
8
8
 
9
9
  import { SHELF_SIZE, type SectionOf } from './types';
10
10
 
11
- function Shelf({
11
+ /** A titled row of product cards; renders nothing for no products. */
12
+ export function Shelf({
12
13
  title,
13
14
  href,
14
15
  products,
@@ -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
+ }