create-magic-storefront 0.3.1 → 0.5.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 +12 -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 -18
- package/template/PAGES.md +48 -22
- package/template/README.md +6 -0
- package/template/_gitignore +2 -0
- package/template/app/api/magicstore/preview/route.ts +56 -0
- package/template/app/globals.css +108 -0
- package/template/app/layout.tsx +18 -7
- package/template/app/page.tsx +18 -9
- package/template/app/pages/[handle]/page.tsx +26 -11
- package/template/app/providers.tsx +15 -6
- package/template/app/storefront-api/[...path]/route.ts +11 -66
- package/template/components/sections/collections.tsx +2 -1
- package/template/components/sections/product-shelves.tsx +2 -1
- package/template/lib/api.ts +40 -10
- package/template/lib/i18n.ts +6 -0
- package/template/lib/preview.ts +21 -0
- package/template/lib/upstream.ts +21 -0
- package/template/llms.txt +153 -1
- package/template/package.json +6 -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
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
|
}
|
|
@@ -9,13 +9,22 @@ const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* With a public storefront token the browser calls the shop's API directly (the token lists this
|
|
12
|
-
* origin); without one, it goes through this app's same-origin proxy (app/storefront-api)
|
|
12
|
+
* origin); without one, it goes through this app's same-origin proxy (app/storefront-api), which by
|
|
13
|
+
* default also holds the buyer's credentials. `credentials` comes from the server
|
|
14
|
+
* (`credentialsMode()` in the layout), so the proxy and the provider always agree.
|
|
13
15
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export function Providers({
|
|
17
|
+
shop,
|
|
18
|
+
credentials,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
shop: Shop;
|
|
22
|
+
credentials: 'browser' | 'server';
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
const connection = token
|
|
26
|
+
? { shopDomain: process.env.NEXT_PUBLIC_MAGICSTORE_SHOP_DOMAIN!, storefrontToken: token }
|
|
27
|
+
: { baseUrl: '/storefront-api', credentials };
|
|
19
28
|
return (
|
|
20
29
|
<MagicStoreProvider {...connection} shop={shop} locale={locale}>
|
|
21
30
|
<PageViews />
|
|
@@ -3,81 +3,26 @@
|
|
|
3
3
|
* identifies the shop by the request's host and CORS admits only origins a token lists, so the
|
|
4
4
|
* browser talks to this route and this route talks to the shop — with the shop's host, and the
|
|
5
5
|
* buyer's address in X-Forwarded-For. Only the API's own headers pass, both ways.
|
|
6
|
+
*
|
|
7
|
+
* By default it also holds the buyer's credentials (`credentialsMode()`): session tokens and the
|
|
8
|
+
* cart and checkout ids live in httpOnly cookies, and the browser only ever sees "current".
|
|
6
9
|
*/
|
|
7
|
-
import {
|
|
10
|
+
import { createStorefrontProxy, type StorefrontProxy } from '@magicstoreai/hydrogen/server';
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
import { credentialsMode, upstreamBaseUrl } from '@/lib/upstream';
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
'accept',
|
|
13
|
-
'accept-language',
|
|
14
|
-
'authorization',
|
|
15
|
-
'content-type',
|
|
16
|
-
'idempotency-key',
|
|
17
|
-
'if-none-match',
|
|
18
|
-
'x-anonymous-id',
|
|
19
|
-
'x-checkout-id',
|
|
20
|
-
'x-request-id',
|
|
21
|
-
'x-session-id',
|
|
22
|
-
];
|
|
14
|
+
let proxy: StorefrontProxy | null = null;
|
|
23
15
|
|
|
24
|
-
|
|
25
|
-
'cache-control',
|
|
26
|
-
'content-language',
|
|
27
|
-
'content-type',
|
|
28
|
-
'etag',
|
|
29
|
-
'location',
|
|
30
|
-
'ratelimit-limit',
|
|
31
|
-
'ratelimit-remaining',
|
|
32
|
-
'ratelimit-reset',
|
|
33
|
-
'retry-after',
|
|
34
|
-
'x-request-id',
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
async function proxy(
|
|
16
|
+
async function handle(
|
|
38
17
|
request: Request,
|
|
39
18
|
{ params }: { params: Promise<{ path: string[] }> },
|
|
40
19
|
): Promise<Response> {
|
|
20
|
+
const upstream = upstreamBaseUrl();
|
|
41
21
|
if (upstream === null) {
|
|
42
22
|
return new Response('MAGICSTORE_SHOP_DOMAIN is not set', { status: 500 });
|
|
43
23
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const target = new URL(`${upstream}/${path.map(encodeURIComponent).join('/')}`);
|
|
47
|
-
target.search = incoming.search;
|
|
48
|
-
|
|
49
|
-
const headers = new Headers();
|
|
50
|
-
for (const name of REQUEST_HEADERS) {
|
|
51
|
-
const value = request.headers.get(name);
|
|
52
|
-
if (value !== null) {
|
|
53
|
-
headers.set(name, value);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const client = request.headers.get('x-forwarded-for');
|
|
57
|
-
if (client) {
|
|
58
|
-
headers.set('x-forwarded-for', client);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
|
62
|
-
const answer = await fetch(target, {
|
|
63
|
-
method: request.method,
|
|
64
|
-
headers,
|
|
65
|
-
body: hasBody ? await request.arrayBuffer() : undefined,
|
|
66
|
-
cache: 'no-store',
|
|
67
|
-
redirect: 'manual',
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const out = new Headers();
|
|
71
|
-
for (const name of RESPONSE_HEADERS) {
|
|
72
|
-
const value = answer.headers.get(name);
|
|
73
|
-
if (value !== null) {
|
|
74
|
-
out.set(name, value);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return new Response(answer.status === 204 || answer.status === 304 ? null : answer.body, {
|
|
78
|
-
status: answer.status,
|
|
79
|
-
headers: out,
|
|
80
|
-
});
|
|
24
|
+
proxy ??= createStorefrontProxy({ upstream, credentials: credentialsMode() });
|
|
25
|
+
return proxy(request, (await params).path);
|
|
81
26
|
}
|
|
82
27
|
|
|
83
|
-
export {
|
|
28
|
+
export { handle as DELETE, handle as GET, handle as PATCH, handle as POST, handle as PUT };
|
|
@@ -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
|
}: {
|
package/template/lib/api.ts
CHANGED
|
@@ -1,25 +1,55 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MagicStoreError,
|
|
4
|
+
createStorefrontClient,
|
|
5
|
+
type StorefrontClient,
|
|
6
|
+
} from '@magicstoreai/storefront-client';
|
|
3
7
|
import { nextCacheFetch } from '@magicstoreai/hydrogen/server';
|
|
4
8
|
import { notFound } from 'next/navigation';
|
|
5
9
|
|
|
6
10
|
import { upstreamBaseUrl } from './upstream';
|
|
7
11
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
export const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The storefront access token server-side reads go with (`X-Storefront-Token`), or `null` to be
|
|
16
|
+
* identified by the shop's domain. It picks this storefront's own theme, and a theme preview needs
|
|
17
|
+
* it: the preview is signed with this token's webhook secret.
|
|
18
|
+
*/
|
|
19
|
+
export function storefrontToken(): string | null {
|
|
20
|
+
return (
|
|
21
|
+
process.env.MAGICSTORE_STOREFRONT_TOKEN ||
|
|
22
|
+
process.env.NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN ||
|
|
23
|
+
null
|
|
24
|
+
);
|
|
11
25
|
}
|
|
12
26
|
|
|
13
|
-
|
|
27
|
+
let client: StorefrontClient | null = null;
|
|
28
|
+
|
|
29
|
+
function storefrontClient(): StorefrontClient {
|
|
30
|
+
if (client === null) {
|
|
31
|
+
const baseUrl = upstreamBaseUrl();
|
|
32
|
+
if (baseUrl === null) {
|
|
33
|
+
throw new Error('Set MAGICSTORE_SHOP_DOMAIN or MAGICSTORE_API_URL (see .env.example).');
|
|
34
|
+
}
|
|
35
|
+
const token = storefrontToken();
|
|
36
|
+
client = createStorefrontClient({
|
|
37
|
+
baseUrl,
|
|
38
|
+
locale,
|
|
39
|
+
...(token ? { storefrontToken: token } : {}),
|
|
40
|
+
fetch: nextCacheFetch({ revalidate: 600 }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return client;
|
|
44
|
+
}
|
|
14
45
|
|
|
15
46
|
/**
|
|
16
47
|
* The server-side client. Public reads are cached under the tags the webhook revalidates; nothing
|
|
17
|
-
* personal is ever called from here.
|
|
48
|
+
* personal is ever called from here. It is created on first use, so importing a module that uses
|
|
49
|
+
* it (a theme section, for `create-magic-storefront theme check`) needs no shop configured.
|
|
18
50
|
*/
|
|
19
|
-
export const api =
|
|
20
|
-
|
|
21
|
-
locale,
|
|
22
|
-
fetch: nextCacheFetch({ revalidate: 600 }),
|
|
51
|
+
export const api: StorefrontClient = new Proxy({} as StorefrontClient, {
|
|
52
|
+
get: (_, method) => Reflect.get(storefrontClient(), method),
|
|
23
53
|
});
|
|
24
54
|
|
|
25
55
|
/** Runs a read and turns the API's NOT_FOUND into the page's 404. */
|
package/template/lib/i18n.ts
CHANGED
|
@@ -57,6 +57,8 @@ const en = {
|
|
|
57
57
|
`${count} ${plural('en', count, { one: 'review', other: 'reviews' })}`,
|
|
58
58
|
recommendUs: (percent: number) => `${percent}% recommend us`,
|
|
59
59
|
leaveReview: 'Leave a review',
|
|
60
|
+
shopNow: 'Shop now',
|
|
61
|
+
ratedOutOf: (rating: number) => `Rated ${rating} out of 5`,
|
|
60
62
|
// Blog
|
|
61
63
|
blog: 'Blog',
|
|
62
64
|
allArticles: 'All articles',
|
|
@@ -182,6 +184,8 @@ const ru: Messages = {
|
|
|
182
184
|
`${count} ${plural('ru', count, { one: 'отзыв', few: 'отзыва', other: 'отзывов' })}`,
|
|
183
185
|
recommendUs: (percent) => `${percent}% рекомендуют нас`,
|
|
184
186
|
leaveReview: 'Оставить отзыв',
|
|
187
|
+
shopNow: 'Перейти в каталог',
|
|
188
|
+
ratedOutOf: (rating: number) => `Оценка ${rating} из 5`,
|
|
185
189
|
blog: 'Блог',
|
|
186
190
|
allArticles: 'Все статьи',
|
|
187
191
|
featured: 'Выбор редакции',
|
|
@@ -297,6 +301,8 @@ const uz: Messages = {
|
|
|
297
301
|
reviewsCount: (count) => `${count} ta sharh`,
|
|
298
302
|
recommendUs: (percent) => `${percent}% bizni tavsiya qiladi`,
|
|
299
303
|
leaveReview: 'Sharh qoldirish',
|
|
304
|
+
shopNow: "Katalogga o'tish",
|
|
305
|
+
ratedOutOf: (rating: number) => `Baho: ${rating} / 5`,
|
|
300
306
|
blog: 'Blog',
|
|
301
307
|
allArticles: 'Barcha maqolalar',
|
|
302
308
|
featured: 'Muharrir tanlovi',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { THEME_PREVIEW_COOKIE, verifyThemePreview } from '@magicstoreai/hydrogen/server';
|
|
3
|
+
import { cookies, draftMode } from 'next/headers';
|
|
4
|
+
|
|
5
|
+
import { storefrontToken } from './api';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The admin's theme preview token for this request, or `null` for every ordinary visitor. Only
|
|
9
|
+
* read in draft mode (set by /api/magicstore/preview), so pages stay static and cached for
|
|
10
|
+
* everyone else; re-verified here so an expired token falls back to the live theme instead of a
|
|
11
|
+
* THEME_PREVIEW_INVALID error. Off without a storefront token: the API cannot preview a request it
|
|
12
|
+
* identifies by its domain.
|
|
13
|
+
*/
|
|
14
|
+
export async function themePreview(): Promise<string | null> {
|
|
15
|
+
const secret = process.env.MAGICSTORE_WEBHOOK_SECRET;
|
|
16
|
+
if (!secret || !storefrontToken() || !(await draftMode()).isEnabled) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const token = (await cookies()).get(THEME_PREVIEW_COOKIE)?.value ?? null;
|
|
20
|
+
return (await verifyThemePreview(secret, token)) ? token : null;
|
|
21
|
+
}
|
package/template/lib/upstream.ts
CHANGED
|
@@ -10,3 +10,24 @@ export function upstreamBaseUrl(): string | null {
|
|
|
10
10
|
const domain = process.env.MAGICSTORE_SHOP_DOMAIN;
|
|
11
11
|
return domain ? `https://${domain}/api/v2/storefront` : null;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Who holds the buyer's credentials. `'server'` (the default): `app/storefront-api` keeps the session
|
|
16
|
+
* tokens and the cart and checkout ids in httpOnly cookies, and the browser only sees "current".
|
|
17
|
+
* `MAGICSTORE_CREDENTIALS=browser` opts out. A public storefront token sends the browser straight to
|
|
18
|
+
* the API, past the proxy, so it means `'browser'` — and asking for `'server'` with one is an error.
|
|
19
|
+
*/
|
|
20
|
+
export function credentialsMode(): 'browser' | 'server' {
|
|
21
|
+
const explicit = process.env.MAGICSTORE_CREDENTIALS || null;
|
|
22
|
+
const publicToken = Boolean(process.env.NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN);
|
|
23
|
+
if (explicit !== null && explicit !== 'server' && explicit !== 'browser') {
|
|
24
|
+
throw new Error(`MAGICSTORE_CREDENTIALS must be "server" or "browser", not "${explicit}".`);
|
|
25
|
+
}
|
|
26
|
+
if (explicit === 'server' && publicToken) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'MAGICSTORE_CREDENTIALS=server needs every browser call to go through app/storefront-api: ' +
|
|
29
|
+
'unset NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN, or set MAGICSTORE_CREDENTIALS=browser.',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return explicit === 'browser' || publicToken ? 'browser' : 'server';
|
|
33
|
+
}
|
package/template/llms.txt
CHANGED
|
@@ -12,8 +12,9 @@ Every storefront — hand-written or generated — imports the SDK **only** thro
|
|
|
12
12
|
| `@magicstoreai/storefront-client` | `createStorefrontClient`: one typed method per API `operationId`, `MagicStoreError`, `paginate`, `Schema<>` | anywhere, no React |
|
|
13
13
|
| `@magicstoreai/hydrogen` | React: `MagicStoreProvider`, `useCart`, `useCustomer`, `useWishlist`, `<Money>`, `<Image>`, … | client (`"use client"`) |
|
|
14
14
|
| `@magicstoreai/hydrogen/core` | The same state without React: `CartController`, `CustomerSessionController`, `formatMoney`, variants, blog | anywhere |
|
|
15
|
-
| `@magicstoreai/hydrogen/server` | `nextCacheFetch` (cache tags), `createWebhookHandler`
|
|
15
|
+
| `@magicstoreai/hydrogen/server` | `nextCacheFetch` (cache tags), `createWebhookHandler`, `loadTemplate`, `loadThemeSettings`, `verifyThemePreview` | server, edge |
|
|
16
16
|
| `@magicstoreai/hydrogen/seo` | `pageMeta`, `productJsonLd`, `articleMeta`, `articleJsonLd`, `breadcrumbJsonLd`, `jsonLdScript` | anywhere |
|
|
17
|
+
| `@magicstoreai/hydrogen/theme` | Themes: `defineSection`, `defineBlock`, `defineTheme`, `ThemeSections`, `validateTemplate`, `resolveTemplate` | anywhere |
|
|
17
18
|
|
|
18
19
|
Never import from `src/` or `dist/` of a package, and never call `/api/v2/storefront/…` by URL —
|
|
19
20
|
call the client method. A server component takes only components (`<Image>`, `<Money>`, …) from
|
|
@@ -100,6 +101,18 @@ The cart id and the checkout id are credentials. Never log them and never put th
|
|
|
100
101
|
query string. A guest proves an order with the `X-Checkout-Id` header. The same goes for tokens,
|
|
101
102
|
OTP codes and full phone numbers.
|
|
102
103
|
|
|
104
|
+
Who holds them is a choice (`MagicStoreProvider credentials`):
|
|
105
|
+
|
|
106
|
+
- `'server'` — the starter's default. Every browser call goes through a same-origin
|
|
107
|
+
`createStorefrontProxy({ credentials: 'server' })` (`/server`; the starter's
|
|
108
|
+
`app/storefront-api`). It keeps the access and refresh tokens and the cart and checkout ids in
|
|
109
|
+
httpOnly cookies; JavaScript, `localStorage` and every body it sees hold the literal `"current"`
|
|
110
|
+
instead (`cart.id === 'current'`, `checkoutsStore({ body: { cartId: cart.id } })` just works). A
|
|
111
|
+
401 the proxy flags with `x-session-expired` refreshes once and retries once; the proxy never
|
|
112
|
+
refreshes on its own.
|
|
113
|
+
- `'browser'` — the tokens and the cart id live in `localStorage`. Required when a public
|
|
114
|
+
storefront token sends the browser straight to the API (the proxy is not in the path).
|
|
115
|
+
|
|
103
116
|
## Cart → checkout → payment
|
|
104
117
|
|
|
105
118
|
1. The first `useCart().addLine(…)` creates the cart; its id is stored for you. Every cart answer
|
|
@@ -141,6 +154,7 @@ for buyers), quote `error.requestId` when reporting. GET calls retry on 429 / 5x
|
|
|
141
154
|
| `OTP_INVALID` | Let the buyer re-type the code. |
|
|
142
155
|
| `OTP_EXPIRED` | Ask for a new code. |
|
|
143
156
|
| `NETWORK_ERROR`, `ABORTED` | No answer (client-side): offer to try again. |
|
|
157
|
+
| `INVALID_RESPONSE` | The API answered 2xx with an unreadable body: report it. |
|
|
144
158
|
|
|
145
159
|
The full list, with the `meta` each code carries, is in the backend's `docs/api/v2/standards.md`.
|
|
146
160
|
|
|
@@ -178,6 +192,144 @@ The SDK does not validate `initData` (the server does), does not parse referrals
|
|
|
178
192
|
(the server attributes them), and never sends `responseUnsafe`. Never put `initData` in a URL, a log
|
|
179
193
|
or analytics.
|
|
180
194
|
|
|
195
|
+
## Themes
|
|
196
|
+
|
|
197
|
+
A storefront is a **theme** (Shopify Online Store 2.0 style): its pages are templates
|
|
198
|
+
(`theme/templates/index.json` for the home, `page.json` for content pages) made of sections the
|
|
199
|
+
merchant reorders and edits in the admin's theme editor. `create-magic-storefront theme push` (a
|
|
200
|
+
`mtt_` theme deploy token, server-side only, never in a browser) sends the manifest; pages read the
|
|
201
|
+
merchant's version with `loadTemplate(api, 'index', theme)` and render it with `ThemeSections`. Until
|
|
202
|
+
a theme is pushed, the bundled templates render. Webhooks `THEME_UPDATED` / `TEMPLATE_UPDATED`
|
|
203
|
+
revalidate `magicstore:theme` / `magicstore:template:<name>`. The editor's preview opens
|
|
204
|
+
`/api/magicstore/preview?token=…`, verified with `verifyThemePreview` (the webhook secret). Guide:
|
|
205
|
+
`docs/themes.md` in the SDK repository.
|
|
206
|
+
|
|
207
|
+
## Creating a new section
|
|
208
|
+
|
|
209
|
+
Invent any section the design needs (`size-guide`, `lookbook`, `faq`): the backend has no code per
|
|
210
|
+
section, so a section type it has never seen is fine — its schema arrives with `theme push`, and the
|
|
211
|
+
editor, the validation and the merchant's AI tools work for it at once. A **setting type** it has
|
|
212
|
+
never seen is not: compose the types below and never invent one; a missing type is a contract change
|
|
213
|
+
in magicbot, made there first. `GET /home` is optional — data sections fetch through the catalog
|
|
214
|
+
operations.
|
|
215
|
+
|
|
216
|
+
1. Create `theme/sections/<type>.tsx` with `defineSection` and the component
|
|
217
|
+
(`SectionProps<typeof section>` types its `settings` and `blocks`).
|
|
218
|
+
2. Register it in `theme/index.ts`: `sections` of `defineTheme` and the `components` map.
|
|
219
|
+
3. Put it in a template (`theme/templates/*.json`) or give it `presets`, so the merchant can add it.
|
|
220
|
+
4. Every word the buyer sees comes from a setting with a default in every locale (`en`, `ru`, `uz`).
|
|
221
|
+
No copy written into the component.
|
|
222
|
+
5. Run `create-magic-storefront theme check` (must pass), then `theme push`, bumping the theme's
|
|
223
|
+
`version`; `theme dev` pushes on every change while developing.
|
|
224
|
+
|
|
225
|
+
| Type | Extra members | The component receives |
|
|
226
|
+
| ----------------- | --------------------------------------- | --------------------------------------------------------- |
|
|
227
|
+
| `TEXT` | `maxLength` (≤ 500) | `string` |
|
|
228
|
+
| `TEXTAREA` | `maxLength` (≤ 5 000) | `string` |
|
|
229
|
+
| `RICHTEXT` | — | sanitised HTML `string` (the only HTML a section renders) |
|
|
230
|
+
| `URL` | — | `string \| null` |
|
|
231
|
+
| `COLOR` | — | `#rrggbb` or `null` |
|
|
232
|
+
| `CHECKBOX` | — | `boolean` |
|
|
233
|
+
| `NUMBER` | `min`, `max` (optional) | `number \| null` |
|
|
234
|
+
| `RANGE` | `min`, `max`, `step`, `unit` (optional) | `number` |
|
|
235
|
+
| `SELECT` | `options: [{ value, label }]` (≤ 100) | one option `value` |
|
|
236
|
+
| `IMAGE` | — | `{ url, altText: null, width, height }` or `null` |
|
|
237
|
+
| `COLLECTION` | — | `{ id, handle, title }` or `null` |
|
|
238
|
+
| `PRODUCT` | — | `{ id, handle, title }` or `null` |
|
|
239
|
+
| `COLLECTION_LIST` | `limit` (1–50) | `{ id, handle, title }[]` |
|
|
240
|
+
| `PRODUCT_LIST` | `limit` (1–50) | `{ id, handle, title }[]` |
|
|
241
|
+
| `HEADER` | `content` (no `id`) | — (a heading in the editor form) |
|
|
242
|
+
| `PARAGRAPH` | `content` (no `id`) | — (a note in the editor form) |
|
|
243
|
+
|
|
244
|
+
Ids are camelCase; `SELECT` values are `UPPER_SNAKE_CASE`. Text is stored per locale and served in
|
|
245
|
+
the request's. References (`IMAGE`, `COLLECTION`, `PRODUCT`, lists) default to empty and are not full
|
|
246
|
+
products — fetch what you show by `handle`. Blocks (`defineBlock`) are repeatable children: slides,
|
|
247
|
+
quotes, table rows.
|
|
248
|
+
|
|
249
|
+
Worked example, `theme/sections/size-guide.tsx` (a heading and a table of `row` blocks):
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
import { defineBlock, defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
|
|
253
|
+
|
|
254
|
+
import { label } from '../text';
|
|
255
|
+
|
|
256
|
+
const row = defineBlock({
|
|
257
|
+
type: 'row',
|
|
258
|
+
name: label('Size', 'Размер', "O'lcham"),
|
|
259
|
+
settings: [
|
|
260
|
+
{ id: 'size', type: 'TEXT', label: label('Size', 'Размер', "O'lcham"), default: '' },
|
|
261
|
+
{ id: 'chest', type: 'TEXT', label: label('Chest', 'Грудь', "Ko'krak"), default: '' },
|
|
262
|
+
{ id: 'waist', type: 'TEXT', label: label('Waist', 'Талия', 'Bel'), default: '' },
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
export const sizeGuide = defineSection({
|
|
267
|
+
type: 'size-guide',
|
|
268
|
+
name: label('Size guide', 'Таблица размеров', "O'lchamlar jadvali"),
|
|
269
|
+
maxBlocks: 30,
|
|
270
|
+
settings: [
|
|
271
|
+
{
|
|
272
|
+
id: 'heading',
|
|
273
|
+
type: 'TEXT',
|
|
274
|
+
label: label('Heading', 'Заголовок', 'Sarlavha'),
|
|
275
|
+
default: { en: 'Size guide', ru: 'Таблица размеров', uz: "O'lchamlar jadvali" },
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 'columns',
|
|
279
|
+
type: 'TEXT',
|
|
280
|
+
label: label('Column names', 'Названия колонок', 'Ustun nomlari'),
|
|
281
|
+
info: label('Separated by commas.', 'Через запятую.', 'Vergul bilan.'),
|
|
282
|
+
default: {
|
|
283
|
+
en: 'Size, Chest cm, Waist cm',
|
|
284
|
+
ru: 'Размер, Грудь см, Талия см',
|
|
285
|
+
uz: "O'lcham, Ko'krak sm, Bel sm",
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
blocks: [row],
|
|
290
|
+
presets: [
|
|
291
|
+
{
|
|
292
|
+
name: label('Size guide', 'Таблица размеров', "O'lchamlar jadvali"),
|
|
293
|
+
blocks: [{ type: 'row' }, { type: 'row' }, { type: 'row' }],
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/** A size table the merchant fills row by row; nothing shows until a row has a size. */
|
|
299
|
+
export function SizeGuide({ settings, blocks }: SectionProps<typeof sizeGuide>) {
|
|
300
|
+
const rows = blocks.filter((block) => block.settings.size !== '');
|
|
301
|
+
if (rows.length === 0) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return (
|
|
305
|
+
<section className="stack">
|
|
306
|
+
<h2>{settings.heading}</h2>
|
|
307
|
+
<table>
|
|
308
|
+
<thead>
|
|
309
|
+
<tr>
|
|
310
|
+
{settings.columns.split(',').map((name) => (
|
|
311
|
+
<th key={name}>{name.trim()}</th>
|
|
312
|
+
))}
|
|
313
|
+
</tr>
|
|
314
|
+
</thead>
|
|
315
|
+
<tbody>
|
|
316
|
+
{rows.map(({ id, settings: cells }) => (
|
|
317
|
+
<tr key={id}>
|
|
318
|
+
<td>{cells.size}</td>
|
|
319
|
+
<td>{cells.chest}</td>
|
|
320
|
+
<td>{cells.waist}</td>
|
|
321
|
+
</tr>
|
|
322
|
+
))}
|
|
323
|
+
</tbody>
|
|
324
|
+
</table>
|
|
325
|
+
</section>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Register it (`sizeGuide` in `sections`, `'size-guide': SizeGuide` in `components`), then
|
|
331
|
+
`theme check` and `theme push`.
|
|
332
|
+
|
|
181
333
|
## What the SDK does not do
|
|
182
334
|
|
|
183
335
|
- Compute or round prices, discounts, taxes, delivery fees or stock — the API returns them.
|
package/template/package.json
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "
|
|
8
|
-
"build": "next build",
|
|
7
|
+
"dev": "node scripts/theme.mjs dev",
|
|
8
|
+
"build": "node scripts/theme.mjs prebuild && next build",
|
|
9
9
|
"start": "next start",
|
|
10
|
-
"typecheck": "tsc --noEmit"
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"theme:check": "create-magic-storefront theme check",
|
|
12
|
+
"theme:push": "create-magic-storefront theme push"
|
|
11
13
|
},
|
|
12
14
|
"dependencies": {
|
|
13
15
|
"@magicstoreai/hydrogen": "workspace:*",
|
|
@@ -22,6 +24,7 @@
|
|
|
22
24
|
"@types/node": "^26.6.2",
|
|
23
25
|
"@types/react": "^19.3.0",
|
|
24
26
|
"@types/react-dom": "^19.3.0",
|
|
27
|
+
"create-magic-storefront": "workspace:*",
|
|
25
28
|
"jsdom": "^30.1.1",
|
|
26
29
|
"typescript": "5.9.3"
|
|
27
30
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Keeps the theme the admin edits in step with this code.
|
|
2
|
+
//
|
|
3
|
+
// node scripts/theme.mjs dev next dev, plus `theme dev` (push to DEVELOPMENT on every change
|
|
4
|
+
// under theme/) when MAGICSTORE_THEME_TOKEN is set
|
|
5
|
+
// node scripts/theme.mjs prebuild (before `next build`) `theme check`, plus `theme push --publish` when
|
|
6
|
+
// MAGICSTORE_THEME_PUBLISH=1, so a deployed storefront never
|
|
7
|
+
// renders a section the backend does not know
|
|
8
|
+
//
|
|
9
|
+
// MAGICSTORE_THEME_TOKEN is the theme deploy token (mtt_…): server-side only, never NEXT_PUBLIC_.
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
for (const file of ['.env.local', '.env']) {
|
|
17
|
+
if (existsSync(file)) {
|
|
18
|
+
process.loadEnvFile(file);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const cliPackage = require.resolve('create-magic-storefront/package.json');
|
|
24
|
+
const cli = join(dirname(cliPackage), require(cliPackage).bin['create-magic-storefront']);
|
|
25
|
+
|
|
26
|
+
function run(command, args) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const child = spawn(command, args, { stdio: 'inherit' });
|
|
29
|
+
child.on('exit', (code) => resolve(code ?? 1));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const theme = (...args) => run(process.execPath, [cli, 'theme', ...args]);
|
|
34
|
+
const mode = process.argv[2];
|
|
35
|
+
|
|
36
|
+
if (mode === 'dev') {
|
|
37
|
+
const next = run(process.execPath, [require.resolve('next/dist/bin/next'), 'dev']);
|
|
38
|
+
if (process.env.MAGICSTORE_THEME_TOKEN) {
|
|
39
|
+
theme('dev');
|
|
40
|
+
}
|
|
41
|
+
process.exit(await next);
|
|
42
|
+
} else if (mode === 'prebuild') {
|
|
43
|
+
const checked = await theme('check');
|
|
44
|
+
if (checked !== 0) {
|
|
45
|
+
process.exit(checked);
|
|
46
|
+
}
|
|
47
|
+
if (process.env.MAGICSTORE_THEME_PUBLISH === '1') {
|
|
48
|
+
process.exit(await theme('push', '--publish'));
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
console.error('Usage: node scripts/theme.mjs dev|prebuild');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|