create-magic-storefront 0.1.3 → 0.2.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 +6 -0
- package/dist/index.js +40 -3
- package/package.json +1 -1
- package/template/.claude/skills/storefront-design/SKILL.md +19 -18
- package/template/.claude/skills/storefront-verify/SKILL.md +10 -9
- package/template/AGENTS.md +4 -1
- package/template/PAGES.md +48 -11
- package/template/app/blog/[handle]/page.tsx +136 -0
- package/template/app/blog/author/[handle]/page.tsx +44 -0
- package/template/app/blog/category/[handle]/page.tsx +52 -0
- package/template/app/blog/page.tsx +97 -0
- package/template/app/blog/tag/[handle]/page.tsx +51 -0
- package/template/app/globals.css +68 -0
- package/template/app/layout.tsx +2 -0
- package/template/app/sitemap.ts +16 -6
- package/template/components/analytics-views.tsx +7 -0
- package/template/components/article-body.tsx +58 -0
- package/template/components/article-card.tsx +54 -0
- package/template/components/article-vote.tsx +43 -0
- package/template/components/blog-listing.tsx +93 -0
- package/template/components/pager.tsx +8 -2
- package/template/components/product-grid.tsx +27 -20
- package/template/lib/errors.ts +2 -1
- package/template/lib/i18n.ts +46 -0
- package/template/lib/site.ts +12 -0
- package/template/llms.txt +30 -6
package/README.md
CHANGED
|
@@ -21,6 +21,12 @@ Fails (exit 1, one line per finding) when the storefront uses the SDK outside it
|
|
|
21
21
|
path, an unexported subpath, the SDK's sources in this repository);
|
|
22
22
|
- `RAW_API_CALL` — a string that spells out an endpoint (`/api/v2/storefront/…`) instead of calling
|
|
23
23
|
it through the client. The bare base URL handed to `createStorefrontClient` is fine.
|
|
24
|
+
- `CLIENT_ENTRY_ON_SERVER` — in a Next.js project, a server component (a file without
|
|
25
|
+
`'use client'`) takes anything but a component (`Image`, `Money`, `Pagination`,
|
|
26
|
+
`MagicStoreProvider`, `ProductProvider`) from `@magicstoreai/hydrogen`. That entry is a client
|
|
27
|
+
module: on the server its functions and classes are client references, so `paginationState()`
|
|
28
|
+
throws and `instanceof MagicStoreError` is always false. Take them from `/core`, `/server`, `/seo`
|
|
29
|
+
or `@magicstoreai/storefront-client`.
|
|
24
30
|
|
|
25
31
|
`node_modules`, build output and `.d.ts` files are skipped. In this repository `pnpm guard` runs it on
|
|
26
32
|
`examples/starter` (CI too).
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,15 @@ var SDK_PACKAGE = /@magicstoreai\/(?:hydrogen|storefront-client)(?:\/|$)/;
|
|
|
32
32
|
var SDK_SOURCES = /(?:^|\/)packages\/(?:hydrogen|storefront-client)(?:\/|$)/;
|
|
33
33
|
var RAW_API_PATH = /\/api\/v2\/storefront\/(?:[A-Za-z]|\$\{)/;
|
|
34
34
|
var STRING_LITERAL = /(['"`])(?:\\.|(?!\1)[^\\\n])*\1/g;
|
|
35
|
+
var SERVER_SAFE_CLIENT_EXPORTS = /* @__PURE__ */ new Set([
|
|
36
|
+
"Image",
|
|
37
|
+
"Money",
|
|
38
|
+
"Pagination",
|
|
39
|
+
"MagicStoreProvider",
|
|
40
|
+
"ProductProvider"
|
|
41
|
+
]);
|
|
42
|
+
var CLIENT_ENTRY_IMPORT = /\bimport\s+(type\s+)?(?:\{([^}]*)\}|\*\s+as\s+\w+)\s*from\s*(['"])@magicstoreai\/hydrogen\3/g;
|
|
43
|
+
var USE_CLIENT = /^(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*(['"])use client\1/;
|
|
35
44
|
function sourceFiles(directory) {
|
|
36
45
|
const files = [];
|
|
37
46
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
@@ -48,8 +57,24 @@ function sourceFiles(directory) {
|
|
|
48
57
|
function lineAt(source, index) {
|
|
49
58
|
return source.slice(0, index).split("\n").length;
|
|
50
59
|
}
|
|
51
|
-
function checkSource(file, source) {
|
|
60
|
+
function checkSource(file, source, options = {}) {
|
|
52
61
|
const violations = [];
|
|
62
|
+
if (options.serverComponents && !USE_CLIENT.test(source)) {
|
|
63
|
+
for (const match of source.matchAll(CLIENT_ENTRY_IMPORT)) {
|
|
64
|
+
if (match[1]) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const names = match[2] === void 0 ? ["*"] : match[2].split(",").map((name) => name.trim()).filter((name) => name !== "" && !name.startsWith("type ")).map((name) => name.split(/\s+as\s+/)[0].trim());
|
|
68
|
+
for (const name of names.filter((n) => !SERVER_SAFE_CLIENT_EXPORTS.has(n))) {
|
|
69
|
+
violations.push({
|
|
70
|
+
rule: "CLIENT_ENTRY_ON_SERVER",
|
|
71
|
+
file,
|
|
72
|
+
line: lineAt(source, match.index),
|
|
73
|
+
text: name
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
53
78
|
for (const match of source.matchAll(SPECIFIER)) {
|
|
54
79
|
const specifier = match[2];
|
|
55
80
|
const reachesIn = SDK_PACKAGE.test(specifier) && !PUBLIC_ENTRY_POINTS.includes(specifier) || SDK_SOURCES.test(specifier);
|
|
@@ -76,13 +101,25 @@ function checkSource(file, source) {
|
|
|
76
101
|
}
|
|
77
102
|
function checkPublicApi(directory) {
|
|
78
103
|
const root = resolve(directory);
|
|
104
|
+
const serverComponents = usesNext(root);
|
|
79
105
|
return sourceFiles(root).flatMap(
|
|
80
|
-
(path) => checkSource(relative(root, path).split("\\").join("/"), readFileSync(path, "utf8")
|
|
106
|
+
(path) => checkSource(relative(root, path).split("\\").join("/"), readFileSync(path, "utf8"), {
|
|
107
|
+
serverComponents
|
|
108
|
+
})
|
|
81
109
|
);
|
|
82
110
|
}
|
|
111
|
+
function usesNext(root) {
|
|
112
|
+
try {
|
|
113
|
+
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
114
|
+
return "next" in { ...manifest.dependencies, ...manifest.devDependencies };
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
83
119
|
var EXPLANATIONS = {
|
|
84
120
|
DEEP_IMPORT: `import only ${PUBLIC_ENTRY_POINTS.join(", ")}`,
|
|
85
|
-
RAW_API_CALL: "call the API through the storefront client, not by URL"
|
|
121
|
+
RAW_API_CALL: "call the API through the storefront client, not by URL",
|
|
122
|
+
CLIENT_ENTRY_ON_SERVER: "a server component takes only components from '@magicstoreai/hydrogen' (a client module); import this from /core, /server, /seo or '@magicstoreai/storefront-client', or add 'use client'"
|
|
86
123
|
};
|
|
87
124
|
function formatViolations(violations) {
|
|
88
125
|
return violations.map((v) => `${v.file}:${v.line} ${v.rule} ${v.text} \u2014 ${EXPLANATIONS[v.rule]}`).join("\n");
|
package/package.json
CHANGED
|
@@ -33,11 +33,11 @@ When they disagree, **this skill wins**. The overrides are listed in [Overrides]
|
|
|
33
33
|
|
|
34
34
|
Design from the merchant's data, not from a guess. Read, through `lib/api.ts`:
|
|
35
35
|
|
|
36
|
-
| What
|
|
37
|
-
|
|
|
38
|
-
| `api.shop()`: `name`, `description`, `branding`
|
|
39
|
-
| `api.home()`: the sections in the merchant's order
|
|
40
|
-
| `api.collectionsIndex()`, a few `api.productsIndex()` | Category, price range, how many products, how good the photos are
|
|
36
|
+
| What | Why |
|
|
37
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
38
|
+
| `api.shop()`: `name`, `description`, `branding` | Identity: logo, favicon, colors, theme |
|
|
39
|
+
| `api.home()`: the sections in the merchant's order | On a merchant-managed home, what it must hold. A custom home may skip it (`PAGES.md`, Home) |
|
|
40
|
+
| `api.collectionsIndex()`, a few `api.productsIndex()` | Category, price range, how many products, how good the photos are |
|
|
41
41
|
|
|
42
42
|
`shop.branding`:
|
|
43
43
|
|
|
@@ -109,7 +109,8 @@ back to the catalog.
|
|
|
109
109
|
errors next to the field (`error.detail`), a summary of lines and totals from the checkout. No
|
|
110
110
|
animation, no decorative imagery, no experimental layout. Trust beats taste here.
|
|
111
111
|
|
|
112
|
-
**Account, search, content pages.** Same tokens, quiet layout
|
|
112
|
+
**Account, search, content pages, blog.** Same tokens, quiet layout; an article reads in one
|
|
113
|
+
narrow column with the cover at a fixed ratio. Search results reuse the product
|
|
113
114
|
card.
|
|
114
115
|
|
|
115
116
|
**Home** (`app/page.tsx`). The one place for a strong visual idea, and the page a design may
|
|
@@ -136,18 +137,18 @@ Motion, use it only in `'use client'` leaf components on the home page and conte
|
|
|
136
137
|
|
|
137
138
|
Where `design-taste-frontend` says otherwise, this is what applies here:
|
|
138
139
|
|
|
139
|
-
| Taste skill
|
|
140
|
-
|
|
|
141
|
-
| 4.8: generate images, use Picsum, stock photos
|
|
142
|
-
| 4.8 / 9.F: logo walls, "Trusted by", testimonials
|
|
143
|
-
| 9.D: invent realistic content
|
|
144
|
-
| 4.6 / 13: forms and multi-step flows out of scope
|
|
145
|
-
| 4.9 / 9.F: spec sheets banned
|
|
146
|
-
| 5: sticky-stack, horizontal pan, GSAP
|
|
147
|
-
| 6.C / 8: dark mode mandatory
|
|
148
|
-
| 3.A: Tailwind v4 + Motion by default
|
|
149
|
-
| 12: block library files
|
|
150
|
-
| Urgency: countdowns, "only N left", "X people viewing"
|
|
140
|
+
| Taste skill | In this storefront |
|
|
141
|
+
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| 4.8: generate images, use Picsum, stock photos | **Never** for products, collections, banners or stories: those images come from the API only. A missing image renders `<Image fallback>`. Generated imagery only for decoration the merchant has no data for, and only when asked. |
|
|
143
|
+
| 4.8 / 9.F: logo walls, "Trusted by", testimonials | Only real data: `api.reviewsSite()` / the store-reviews section, the shop's own logo. No invented brands, customers, ratings or review counts. |
|
|
144
|
+
| 9.D: invent realistic content | Never. Real product titles, prices and copy only. Placeholder copy in a shipped page is a bug. |
|
|
145
|
+
| 4.6 / 13: forms and multi-step flows out of scope | Cart, checkout and account follow Section 3 here: conventional, accessible, no creative layout. |
|
|
146
|
+
| 4.9 / 9.F: spec sheets banned | Product attributes are allowed, as a definition list without a border on every row. |
|
|
147
|
+
| 5: sticky-stack, horizontal pan, GSAP | Home and content pages only. Never on collection, product, cart or checkout pages. |
|
|
148
|
+
| 6.C / 8: dark mode mandatory | One theme per store, chosen from `branding` (Theme Lock still holds). Add a dark variant only if the brand colors pass contrast in it. |
|
|
149
|
+
| 3.A: Tailwind v4 + Motion by default | Section 5 here. |
|
|
150
|
+
| 12: block library files | Ignore: blocks are not shipped with this storefront. |
|
|
151
|
+
| Urgency: countdowns, "only N left", "X people viewing" | Only from data: the deal-of-day and flash-sale sections' end dates, `quantityAvailable`. Never invented. |
|
|
151
152
|
|
|
152
153
|
## Pre-flight
|
|
153
154
|
|
|
@@ -29,15 +29,16 @@ server when you are done.
|
|
|
29
29
|
|
|
30
30
|
Real data only, from the running shop:
|
|
31
31
|
|
|
32
|
-
| Page | URL
|
|
33
|
-
| ---------- |
|
|
34
|
-
| Home | `/`
|
|
35
|
-
| Collection | the first `/collections/…` link in the home page's HTML or its menu
|
|
36
|
-
| Product | the first `/products/…` link, plus one sold out or with options if the shop has one
|
|
37
|
-
| Search | `/search?q=<a word from a product title>` and one with no results
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
32
|
+
| Page | URL |
|
|
33
|
+
| ---------- | ----------------------------------------------------------------------------------------- |
|
|
34
|
+
| Home | `/` |
|
|
35
|
+
| Collection | the first `/collections/…` link in the home page's HTML or its menu |
|
|
36
|
+
| Product | the first `/products/…` link, plus one sold out or with options if the shop has one |
|
|
37
|
+
| Search | `/search?q=<a word from a product title>` and one with no results |
|
|
38
|
+
| Blog | `/blog` and the first `/blog/…` article, when the header links the blog (`features.blog`) |
|
|
39
|
+
| Cart | `/cart` (empty), then again after adding a product |
|
|
40
|
+
| Checkout | `/checkout` with that cart |
|
|
41
|
+
| Not found | `/products/this-does-not-exist` |
|
|
41
42
|
|
|
42
43
|
Never place an order while verifying: stop at the checkout form.
|
|
43
44
|
|
package/template/AGENTS.md
CHANGED
|
@@ -20,7 +20,9 @@ each error code means — then `PAGES.md` for how each page type is built, and
|
|
|
20
20
|
|
|
21
21
|
- Import the SDK only from `@magicstoreai/storefront-client`, `@magicstoreai/hydrogen`,
|
|
22
22
|
`@magicstoreai/hydrogen/core`, `/server` and `/seo`. Call the API through client methods, never
|
|
23
|
-
by `/api/v2/storefront/…` URL.
|
|
23
|
+
by `/api/v2/storefront/…` URL. A server component takes only components from
|
|
24
|
+
`@magicstoreai/hydrogen` (a client module); functions and classes come from `/core`, `/server`,
|
|
25
|
+
`/seo` or `@magicstoreai/storefront-client`. `npx create-magic-storefront check` must pass.
|
|
24
26
|
- Never compute money: render `Money` values with `<Money>` / `formatMoney`, totals from the cart
|
|
25
27
|
and the checkout.
|
|
26
28
|
- Cart id, checkout id, tokens, OTP codes, phone numbers: never logged, never in a URL.
|
|
@@ -53,6 +55,7 @@ screenshot every page type at 390 and 1280px, fix what you see. Interactive UI f
|
|
|
53
55
|
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
|
54
56
|
| `app/` | Pages (server components) and route handlers |
|
|
55
57
|
| `app/error.tsx` | Error boundary: the API's `detail`, a retry |
|
|
58
|
+
| `app/blog/` | Blog: index, category, author, tag and article pages (`PAGES.md`, Blog) |
|
|
56
59
|
| `app/sitemap.ts` | Sitemap from `GET /sitemap` |
|
|
57
60
|
| `app/page.tsx` | Home: renders `GET /home` sections in the merchant's order. Optional: a custom home composes its own (`PAGES.md`) |
|
|
58
61
|
| `components/sections/` | One renderer per home section type; an unknown type renders nothing |
|
package/template/PAGES.md
CHANGED
|
@@ -17,13 +17,13 @@ errors).
|
|
|
17
17
|
`nextCacheFetch` tags every public `GET` by its path (`tagForPath`), and
|
|
18
18
|
`app/api/magicstore/webhook` revalidates the tags of each webhook topic:
|
|
19
19
|
|
|
20
|
-
| Tag | Paths
|
|
21
|
-
| -------------------- |
|
|
22
|
-
| `magicstore:shop` | `/shop`, `/contacts`, `/theme/section-schema`
|
|
23
|
-
| `magicstore:home` | `/home`
|
|
24
|
-
| `magicstore:pages` | `/pages*`, `/menus/footer`
|
|
25
|
-
| `magicstore:catalog` | `/products*`, `/collections*`, other `/menus/*`, `/reels`, `/sitemap
|
|
26
|
-
| none (`no-store`) | `/search*`, `/reviews/site`, cart, checkout, customer, orders
|
|
20
|
+
| Tag | Paths | Webhook topic |
|
|
21
|
+
| -------------------- | ----------------------------------------------------------------------------------------------- | ----------------- |
|
|
22
|
+
| `magicstore:shop` | `/shop`, `/contacts`, `/theme/section-schema` | `SHOP_UPDATED` |
|
|
23
|
+
| `magicstore:home` | `/home` | `HOME_UPDATED` |
|
|
24
|
+
| `magicstore:pages` | `/pages*`, `/blog/*`, `/menus/footer`, `/sitemap` | `PAGES_UPDATED` |
|
|
25
|
+
| `magicstore:catalog` | `/products*`, `/collections*`, other `/menus/*`, `/reels`, `/sitemap` (both tags), `/locations` | `CATALOG_UPDATED` |
|
|
26
|
+
| none (`no-store`) | `/search*`, `/reviews/site`, cart, checkout, customer, orders | — |
|
|
27
27
|
|
|
28
28
|
Every page renders per request (`dynamic = 'force-dynamic'` in `app/layout.tsx`): the shop is the
|
|
29
29
|
API's, not the build's. The fetch cache still spares the API.
|
|
@@ -37,6 +37,7 @@ API's, not the build's. The fetch cache still spares the API.
|
|
|
37
37
|
tokens `app/globals.css` falls back from; the header shows `shop.branding.logo` when there is one.
|
|
38
38
|
- Wraps the page in `Providers` (`MagicStoreProvider` with `shop` passed in, so the browser does not
|
|
39
39
|
load it again).
|
|
40
|
+
- Links the blog only when `shop.features.blog` is on (an article is readable in this language).
|
|
40
41
|
|
|
41
42
|
## Home — `app/page.tsx`, `components/sections/`
|
|
42
43
|
|
|
@@ -155,15 +156,51 @@ home: show new arrivals.
|
|
|
155
156
|
rewards — through `useStorefrontClient()` (the provider adds the bearer and refreshes it).
|
|
156
157
|
- States: signed out, `UNAUTHENTICATED` (sign in again), no orders yet.
|
|
157
158
|
|
|
159
|
+
## Blog — `app/blog/`, `components/article-body.tsx`, `components/blog-listing.tsx`
|
|
160
|
+
|
|
161
|
+
All blog reads are `magicstore:pages`. Rules: the backend's `docs/api/v2/blog.md`.
|
|
162
|
+
|
|
163
|
+
- **Index** `app/blog/page.tsx`: heading and intro from `shop.blog` (else `t.blog`), categories
|
|
164
|
+
(`api.blogCategoriesIndex()`, `CategoryNav`), editor's picks (`filter[featured]=true`, first page
|
|
165
|
+
only), a search box (`q`, at most 200 characters) and newest / popular (`sort=-recentViews`),
|
|
166
|
+
then `api.blogArticlesIndex({ query: { page, perPage: 12, … } })` with `Pager`. Only a `sort` the
|
|
167
|
+
API knows is passed on (`sortParam`); a `422` shows the API's message (`ArticleListing`). Search
|
|
168
|
+
results and sorted lists are `noindex`.
|
|
169
|
+
- **Category, author, tag** `app/blog/category/[handle]/page.tsx`,
|
|
170
|
+
`app/blog/author/[handle]/page.tsx`, `app/blog/tag/[handle]/page.tsx`: the same list with
|
|
171
|
+
`filter[category|author|tag]`. The API answers an unknown handle with an empty page, so the page
|
|
172
|
+
finds the category / author in its list (`blogCategoriesIndex` / `blogAuthorsIndex`) and is a 404
|
|
173
|
+
when it is not there; a tag with no articles is a 404 (there is no tag list: its title comes from
|
|
174
|
+
an article).
|
|
175
|
+
- **Article** `app/blog/[handle]/page.tsx`:
|
|
176
|
+
- `api.blogArticlesShow` through `orNotFound`: an article not written in this language is a 404
|
|
177
|
+
(no fallback). `generateMetadata` returns `articleMeta(article, { url, shop })` as is; pass
|
|
178
|
+
`urlForLocale` when the storefront routes several languages, and only `availableLocales` become
|
|
179
|
+
`hreflang` alternates.
|
|
180
|
+
- `<h1>{article.h1 ?? article.title}</h1>`, the cover `loading="eager"` (the LCP image),
|
|
181
|
+
`articleJsonLd` and `breadcrumbJsonLd` in `<script type="application/ld+json">`.
|
|
182
|
+
- The body: `components/article-body.tsx` renders `articleBodyBlocks(bodyHtml)` on the server.
|
|
183
|
+
Embedded products come from `api.productsIndex({ query: { 'filter[handles]', perPage: 100 } })`
|
|
184
|
+
for each `embeddedProductFilters(article.products)` value (usually one call), reordered with
|
|
185
|
+
`orderEmbeddedProducts`; a marker whose product did not come back renders nothing, and so does
|
|
186
|
+
one the editor put inside a table, list or quote (only top-level markers become blocks, so the
|
|
187
|
+
HTML is never cut apart). Callouts are
|
|
188
|
+
`<aside class="callout" data-callout>`, media embeds are links.
|
|
189
|
+
- Tags link to the tag page, the author block to the author page; `api.blogArticlesRelated`
|
|
190
|
+
(`perPage: 3`) below.
|
|
191
|
+
- `components/article-vote.tsx`: «was this helpful?» on `useArticleVote` (a guest votes with
|
|
192
|
+
`X-Anonymous-Id`; the proxy forwards it). `ArticleView` reports `ARTICLE_VIEW`.
|
|
193
|
+
- Dates render in `shop.timezone` (`ArticleDate`).
|
|
194
|
+
|
|
158
195
|
## Content pages, sitemap — `app/pages/[handle]/page.tsx`, `app/sitemap.ts`
|
|
159
196
|
|
|
160
197
|
- `api.pagesShow({ path: { handle } })` (`magicstore:pages`) through `orNotFound`,
|
|
161
198
|
`pageMeta({ seo, title })`; `body` is the merchant's HTML (empty until written). The layout links
|
|
162
199
|
`api.pagesIndex()` in the footer.
|
|
163
200
|
- `app/sitemap.ts` reads `api.sitemap({ query: { page, perPage: 1000 } })` — `{ type, handle,
|
|
164
|
-
updatedAt }` rows — on `SITE_URL` or the shop's `primaryDomain
|
|
165
|
-
50 000 URLs).
|
|
166
|
-
every page.
|
|
201
|
+
updatedAt }` rows — on `SITE_URL` or the shop's `primaryDomain` (`lib/site.ts`), at most 50 pages
|
|
202
|
+
(the protocol's 50 000 URLs). `ARTICLE` rows link to `/blog/{handle}`; a type the storefront does
|
|
203
|
+
not know is skipped. Dynamic, like every page.
|
|
167
204
|
|
|
168
205
|
## Every page
|
|
169
206
|
|
|
@@ -177,4 +214,4 @@ updatedAt }` rows — on `SITE_URL` or the shop's `primaryDomain`, at most 50 pa
|
|
|
177
214
|
translated from the API.
|
|
178
215
|
- Images through `<Image>` (intrinsic size, lazy), prices through `<Money>`.
|
|
179
216
|
- Analytics: `PageViews` in `app/providers.tsx` reports each route; product, collection and search
|
|
180
|
-
views are reported where they render.
|
|
217
|
+
views, and article reads, are reported where they render.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { Image } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { embeddedProductFilters, orderEmbeddedProducts } from '@magicstoreai/hydrogen/core';
|
|
5
|
+
import {
|
|
6
|
+
articleJsonLd,
|
|
7
|
+
articleMeta,
|
|
8
|
+
breadcrumbJsonLd,
|
|
9
|
+
jsonLdScript,
|
|
10
|
+
} from '@magicstoreai/hydrogen/seo';
|
|
11
|
+
import { ArticleView } from '@/components/analytics-views';
|
|
12
|
+
import { ArticleBody } from '@/components/article-body';
|
|
13
|
+
import { ArticleDate, ArticleGrid } from '@/components/article-card';
|
|
14
|
+
import { ArticleVote } from '@/components/article-vote';
|
|
15
|
+
import { api, locale, orNotFound } from '@/lib/api';
|
|
16
|
+
import { messages } from '@/lib/i18n';
|
|
17
|
+
import { siteOrigin } from '@/lib/site';
|
|
18
|
+
|
|
19
|
+
type Props = { params: Promise<{ handle: string }> };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An article is served only in the languages it is written in: in any other one the API answers
|
|
23
|
+
* 404, and so does this page.
|
|
24
|
+
*/
|
|
25
|
+
async function article(handle: string) {
|
|
26
|
+
return (await orNotFound(api.blogArticlesShow({ path: { handle } }))).data;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
30
|
+
const { handle } = await params;
|
|
31
|
+
const [found, { data: shop }] = await Promise.all([article(handle), api.shop()]);
|
|
32
|
+
// A single-language storefront: no `urlForLocale`, so no hreflang alternates.
|
|
33
|
+
return articleMeta(found, { url: `${siteOrigin(shop)}/blog/${found.handle}`, shop });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default async function ArticlePage({ params }: Props) {
|
|
37
|
+
const { handle } = await params;
|
|
38
|
+
const t = messages(locale);
|
|
39
|
+
const [found, { data: shop }] = await Promise.all([article(handle), api.shop()]);
|
|
40
|
+
// Prices and stock of the embedded products come live from /products, in one call (rarely two);
|
|
41
|
+
// that list has its own order, so put it back in the article's.
|
|
42
|
+
const [related, ...productPages] = await Promise.all([
|
|
43
|
+
api.blogArticlesRelated({ path: { handle }, query: { perPage: 3 } }),
|
|
44
|
+
...embeddedProductFilters(found.products).map((handles) =>
|
|
45
|
+
api.productsIndex({ query: { 'filter[handles]': handles, perPage: 100 } }),
|
|
46
|
+
),
|
|
47
|
+
]);
|
|
48
|
+
const products = orderEmbeddedProducts(
|
|
49
|
+
found.products,
|
|
50
|
+
productPages.flatMap((result) => result.data),
|
|
51
|
+
);
|
|
52
|
+
const url = `${siteOrigin(shop)}/blog/${found.handle}`;
|
|
53
|
+
const blogTitle = shop.blog?.title || t.blog;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<article className="stack article">
|
|
57
|
+
<script
|
|
58
|
+
type="application/ld+json"
|
|
59
|
+
dangerouslySetInnerHTML={{
|
|
60
|
+
__html: jsonLdScript(articleJsonLd(found, { url, shop, locale })),
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
<script
|
|
64
|
+
type="application/ld+json"
|
|
65
|
+
dangerouslySetInnerHTML={{
|
|
66
|
+
__html: jsonLdScript(
|
|
67
|
+
breadcrumbJsonLd([
|
|
68
|
+
{ name: t.home, url: '/' },
|
|
69
|
+
{ name: blogTitle, url: '/blog' },
|
|
70
|
+
...(found.category
|
|
71
|
+
? [{ name: found.category.title, url: `/blog/category/${found.category.handle}` }]
|
|
72
|
+
: []),
|
|
73
|
+
{ name: found.title, url: `/blog/${found.handle}` },
|
|
74
|
+
]),
|
|
75
|
+
),
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
<ArticleView articleId={found.id} />
|
|
79
|
+
<nav className="row muted" aria-label={blogTitle}>
|
|
80
|
+
<Link href="/blog">{blogTitle}</Link>
|
|
81
|
+
{found.category && (
|
|
82
|
+
<>
|
|
83
|
+
<span aria-hidden="true">/</span>
|
|
84
|
+
<Link href={`/blog/category/${found.category.handle}`}>{found.category.title}</Link>
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
</nav>
|
|
88
|
+
<h1>{found.h1 ?? found.title}</h1>
|
|
89
|
+
<p className="row muted">
|
|
90
|
+
{found.author ? (
|
|
91
|
+
<Link href={`/blog/author/${found.author.handle}`}>{found.authorName}</Link>
|
|
92
|
+
) : (
|
|
93
|
+
found.authorName && <span>{found.authorName}</span>
|
|
94
|
+
)}
|
|
95
|
+
<ArticleDate iso={found.publishedAt} timeZone={shop.timezone} />
|
|
96
|
+
<span>{t.readingTime(found.readingMinutes)}</span>
|
|
97
|
+
</p>
|
|
98
|
+
{/* The cover is the page's largest image: loaded at once, not lazily. */}
|
|
99
|
+
<Image data={found.image} alt={found.title} loading="eager" className="article-cover" />
|
|
100
|
+
<div className="prose">
|
|
101
|
+
<ArticleBody
|
|
102
|
+
bodyHtml={found.bodyHtml}
|
|
103
|
+
products={new Map(products.map((product) => [product.handle, product]))}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
{found.tags.length > 0 && (
|
|
107
|
+
<nav className="row chips" aria-label={t.tag}>
|
|
108
|
+
{found.tags.map((tag) => (
|
|
109
|
+
<Link key={tag.handle} href={`/blog/tag/${tag.handle}`}>
|
|
110
|
+
#{tag.title}
|
|
111
|
+
</Link>
|
|
112
|
+
))}
|
|
113
|
+
</nav>
|
|
114
|
+
)}
|
|
115
|
+
{found.author && (
|
|
116
|
+
<aside className="row author">
|
|
117
|
+
<Image data={found.author.avatar} alt={found.author.name} width={64} height={64} />
|
|
118
|
+
<div className="stack">
|
|
119
|
+
<Link href={`/blog/author/${found.author.handle}`}>
|
|
120
|
+
<strong>{found.author.name}</strong>
|
|
121
|
+
</Link>
|
|
122
|
+
{found.author.role && <span className="muted">{found.author.role}</span>}
|
|
123
|
+
{found.authorBio && <p>{found.authorBio}</p>}
|
|
124
|
+
</div>
|
|
125
|
+
</aside>
|
|
126
|
+
)}
|
|
127
|
+
<ArticleVote handle={found.handle} helpfulCount={found.helpfulCount} />
|
|
128
|
+
{related.data.length > 0 && (
|
|
129
|
+
<section className="stack">
|
|
130
|
+
<h2>{t.relatedArticles}</h2>
|
|
131
|
+
<ArticleGrid articles={related.data} timeZone={shop.timezone} />
|
|
132
|
+
</section>
|
|
133
|
+
)}
|
|
134
|
+
</article>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { Image } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { pageMeta } from '@magicstoreai/hydrogen/seo';
|
|
5
|
+
import { ArticleListing, pageParam } from '@/components/blog-listing';
|
|
6
|
+
import { api } from '@/lib/api';
|
|
7
|
+
|
|
8
|
+
type Props = { params: Promise<{ handle: string }>; searchParams: Promise<{ page?: string }> };
|
|
9
|
+
|
|
10
|
+
/** The author, or 404: the list holds only authors with an article in this language. */
|
|
11
|
+
async function author(handle: string) {
|
|
12
|
+
const { data } = await api.blogAuthorsIndex();
|
|
13
|
+
return data.find((item) => item.handle === handle) ?? notFound();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
17
|
+
const { handle } = await params;
|
|
18
|
+
const [found, { data: shop }] = await Promise.all([author(handle), api.shop()]);
|
|
19
|
+
const meta = pageMeta({ title: found.name, description: found.role, shop });
|
|
20
|
+
return { title: meta.title, description: meta.description };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function BlogAuthorPage({ params, searchParams }: Props) {
|
|
24
|
+
const { handle } = await params;
|
|
25
|
+
const page = pageParam((await searchParams).page);
|
|
26
|
+
const [found, { data: shop }] = await Promise.all([author(handle), api.shop()]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="stack">
|
|
30
|
+
<header className="row author">
|
|
31
|
+
<Image data={found.avatar} alt={found.name} width={64} height={64} />
|
|
32
|
+
<div>
|
|
33
|
+
<h1>{found.name}</h1>
|
|
34
|
+
{found.role && <p className="muted">{found.role}</p>}
|
|
35
|
+
</div>
|
|
36
|
+
</header>
|
|
37
|
+
<ArticleListing
|
|
38
|
+
query={{ page, 'filter[author]': handle }}
|
|
39
|
+
href={(p) => `/blog/author/${handle}${p > 1 ? `?page=${p}` : ''}`}
|
|
40
|
+
timeZone={shop.timezone}
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { breadcrumbJsonLd, jsonLdScript, pageMeta } from '@magicstoreai/hydrogen/seo';
|
|
4
|
+
import { ArticleListing, CategoryNav, pageParam } from '@/components/blog-listing';
|
|
5
|
+
import { api, locale } from '@/lib/api';
|
|
6
|
+
import { messages } from '@/lib/i18n';
|
|
7
|
+
|
|
8
|
+
type Props = { params: Promise<{ handle: string }>; searchParams: Promise<{ page?: string }> };
|
|
9
|
+
|
|
10
|
+
/** The category, or 404: the list holds only categories with an article in this language. */
|
|
11
|
+
async function category(handle: string) {
|
|
12
|
+
const { data } = await api.blogCategoriesIndex();
|
|
13
|
+
return data.find((item) => item.handle === handle) ?? notFound();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
17
|
+
const { handle } = await params;
|
|
18
|
+
const [found, { data: shop }] = await Promise.all([category(handle), api.shop()]);
|
|
19
|
+
const meta = pageMeta({ title: found.title, shop });
|
|
20
|
+
return { title: meta.title };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function BlogCategoryPage({ params, searchParams }: Props) {
|
|
24
|
+
const { handle } = await params;
|
|
25
|
+
const page = pageParam((await searchParams).page);
|
|
26
|
+
const t = messages(locale);
|
|
27
|
+
const [found, { data: shop }] = await Promise.all([category(handle), api.shop()]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="stack">
|
|
31
|
+
<script
|
|
32
|
+
type="application/ld+json"
|
|
33
|
+
dangerouslySetInnerHTML={{
|
|
34
|
+
__html: jsonLdScript(
|
|
35
|
+
breadcrumbJsonLd([
|
|
36
|
+
{ name: t.home, url: '/' },
|
|
37
|
+
{ name: shop.blog?.title || t.blog, url: '/blog' },
|
|
38
|
+
{ name: found.title, url: `/blog/category/${handle}` },
|
|
39
|
+
]),
|
|
40
|
+
),
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
<h1>{found.title}</h1>
|
|
44
|
+
<CategoryNav current={handle} />
|
|
45
|
+
<ArticleListing
|
|
46
|
+
query={{ page, 'filter[category]': handle }}
|
|
47
|
+
href={(p) => `/blog/category/${handle}${p > 1 ? `?page=${p}` : ''}`}
|
|
48
|
+
timeZone={shop.timezone}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { breadcrumbJsonLd, jsonLdScript, pageMeta } from '@magicstoreai/hydrogen/seo';
|
|
4
|
+
import { ArticleGrid } from '@/components/article-card';
|
|
5
|
+
import { ArticleListing, CategoryNav, pageParam, sortParam } from '@/components/blog-listing';
|
|
6
|
+
import { api, locale } from '@/lib/api';
|
|
7
|
+
import { messages } from '@/lib/i18n';
|
|
8
|
+
|
|
9
|
+
type Props = { searchParams: Promise<{ q?: string; sort?: string; page?: string }> };
|
|
10
|
+
|
|
11
|
+
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
|
|
12
|
+
const { data: shop } = await api.shop();
|
|
13
|
+
const { q, sort } = await searchParams;
|
|
14
|
+
const meta = pageMeta({
|
|
15
|
+
title: shop.blog?.title || messages(locale).blog,
|
|
16
|
+
description: shop.blog?.description ?? null,
|
|
17
|
+
shop,
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
title: meta.title,
|
|
21
|
+
description: meta.description,
|
|
22
|
+
// Search results are endless and a sort repeats the same articles: keep both out of the index.
|
|
23
|
+
...((q || sort) && { robots: { index: false, follow: true } }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The blog home: the merchant's heading, categories, editor's picks, then every article. */
|
|
28
|
+
export default async function BlogPage({ searchParams }: Props) {
|
|
29
|
+
const { q = '', sort: sortValue, page: pageValue } = await searchParams;
|
|
30
|
+
const sort = sortParam(sortValue);
|
|
31
|
+
const page = pageParam(pageValue);
|
|
32
|
+
const t = messages(locale);
|
|
33
|
+
const { data: shop } = await api.shop();
|
|
34
|
+
const showFeatured = page === 1 && !q && !sort;
|
|
35
|
+
const featured = showFeatured
|
|
36
|
+
? (await api.blogArticlesIndex({ query: { 'filter[featured]': 'true', perPage: 3 } })).data
|
|
37
|
+
: [];
|
|
38
|
+
const href = (p: number) => {
|
|
39
|
+
const params = new URLSearchParams({
|
|
40
|
+
...(q && { q }),
|
|
41
|
+
...(sort && { sort }),
|
|
42
|
+
...(p > 1 && { page: String(p) }),
|
|
43
|
+
});
|
|
44
|
+
return params.size > 0 ? `/blog?${params}` : '/blog';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="stack">
|
|
49
|
+
<script
|
|
50
|
+
type="application/ld+json"
|
|
51
|
+
dangerouslySetInnerHTML={{
|
|
52
|
+
__html: jsonLdScript(
|
|
53
|
+
breadcrumbJsonLd([
|
|
54
|
+
{ name: t.home, url: '/' },
|
|
55
|
+
{ name: shop.blog?.title || t.blog, url: '/blog' },
|
|
56
|
+
]),
|
|
57
|
+
),
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
<h1>{shop.blog?.title || t.blog}</h1>
|
|
61
|
+
{shop.blog?.description && <p className="muted">{shop.blog.description}</p>}
|
|
62
|
+
<CategoryNav />
|
|
63
|
+
<form className="row" action="/blog">
|
|
64
|
+
<input name="q" defaultValue={q} placeholder={t.searchArticles} maxLength={200} />
|
|
65
|
+
<button className="primary">{t.search}</button>
|
|
66
|
+
</form>
|
|
67
|
+
<nav className="row chips" aria-label={t.sortNewest}>
|
|
68
|
+
<Link
|
|
69
|
+
href={q ? `/blog?q=${encodeURIComponent(q)}` : '/blog'}
|
|
70
|
+
aria-current={!sort ? 'page' : undefined}
|
|
71
|
+
>
|
|
72
|
+
{t.sortNewest}
|
|
73
|
+
</Link>
|
|
74
|
+
<Link
|
|
75
|
+
href={`/blog?${new URLSearchParams({ ...(q && { q }), sort: '-recentViews' })}`}
|
|
76
|
+
aria-current={sort === '-recentViews' ? 'page' : undefined}
|
|
77
|
+
>
|
|
78
|
+
{t.sortPopular}
|
|
79
|
+
</Link>
|
|
80
|
+
</nav>
|
|
81
|
+
{featured.length > 0 && (
|
|
82
|
+
<section className="stack">
|
|
83
|
+
<h2>{t.featured}</h2>
|
|
84
|
+
<ArticleGrid articles={featured} timeZone={shop.timezone} />
|
|
85
|
+
</section>
|
|
86
|
+
)}
|
|
87
|
+
{/* The picks are not repeated below; a blog of only picks shows no second list. */}
|
|
88
|
+
<ArticleListing
|
|
89
|
+
query={{ page, ...(q && { q }), ...(sort && { sort }) }}
|
|
90
|
+
exclude={featured.map((article) => article.id)}
|
|
91
|
+
{...(featured.length > 0 && { heading: t.allArticles })}
|
|
92
|
+
href={href}
|
|
93
|
+
timeZone={shop.timezone}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|