create-brainerce-store 1.88.0 → 1.89.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/dist/index.js +3 -2
- package/messages/en.json +21 -0
- package/messages/he.json +21 -0
- package/package.json +3 -2
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AI-GUIDE.md +259 -221
- package/templates/nextjs/base/TRANSLATIONS.md +6 -0
- package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
- package/templates/nextjs/base/scripts/connect.mjs +6 -0
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
- package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
- package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
- package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
- package/templates/nextjs/base/src/app/page.tsx +109 -75
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
- package/templates/nextjs/base/src/app/products/page.tsx +184 -19
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
- package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
- package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
- package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
- package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
- package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
- package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
- package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
- package/templates/nextjs/base/src/app/products/layout.tsx.ejs +0 -18
|
@@ -35,6 +35,7 @@ import { spawn } from 'node:child_process';
|
|
|
35
35
|
import { dirname, resolve } from 'node:path';
|
|
36
36
|
import { fileURLToPath } from 'node:url';
|
|
37
37
|
import { setVar } from './env-file.mjs';
|
|
38
|
+
import { markAgentDocsConnected } from './agent-docs.mjs';
|
|
38
39
|
|
|
39
40
|
const API_BASE = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(/\/$/, '');
|
|
40
41
|
const ENV_PATH = resolve(process.cwd(), '.env.local');
|
|
@@ -248,6 +249,11 @@ function writeEnv(connectionId) {
|
|
|
248
249
|
let body = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : '';
|
|
249
250
|
for (const key of KEYS) body = setVar(body, key, connectionId);
|
|
250
251
|
writeFileSync(ENV_PATH, body);
|
|
252
|
+
// CLAUDE.md and AGENTS.md open with "Not connected yet" on a deferred scaffold.
|
|
253
|
+
// Left alone they tell the next coding agent this store has no channel, and it
|
|
254
|
+
// asks the user to connect it again.
|
|
255
|
+
const docs = markAgentDocsConnected(dirname(ENV_PATH), connectionId);
|
|
256
|
+
if (docs.length > 0) console.log(`Updated ${docs.join(' and ')}: the store is no longer "not connected".`);
|
|
251
257
|
}
|
|
252
258
|
|
|
253
259
|
/**
|
|
@@ -1,96 +1,102 @@
|
|
|
1
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
response.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { REFERRAL_COOKIE } from '@/core/lib/referral';
|
|
3
|
+
|
|
4
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
5
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
6
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
7
|
+
|
|
8
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
9
|
+
/\/$/,
|
|
10
|
+
''
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
function isSecure(): boolean {
|
|
14
|
+
return process.env.NODE_ENV === 'production';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Forward a failure to the client callback page as a stable snake_case code.
|
|
19
|
+
*
|
|
20
|
+
* The backend uses the RFC 6749 §4.1.2.1 shape (`oauth_error` = code,
|
|
21
|
+
* `error_description` = English developer detail), so codes are what the page
|
|
22
|
+
* switches on to pick localized copy. The description is deliberately NOT
|
|
23
|
+
* forwarded — it is not shopper-facing copy.
|
|
24
|
+
*/
|
|
25
|
+
function errorRedirect(base: URL, code: string): NextResponse {
|
|
26
|
+
base.searchParams.set('oauth_error', code);
|
|
27
|
+
const response = NextResponse.redirect(base);
|
|
28
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
29
|
+
return response;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* OAuth callback handler.
|
|
34
|
+
*
|
|
35
|
+
* The backend redirects here with ?oauth_success=true&auth_code=<one-time-code>.
|
|
36
|
+
* We exchange the auth_code for the real JWT via a server-to-server POST (so the
|
|
37
|
+
* JWT never appears in any URL), then set an httpOnly cookie and redirect to the
|
|
38
|
+
* client-side callback page.
|
|
39
|
+
*/
|
|
40
|
+
export async function GET(request: NextRequest) {
|
|
41
|
+
const { searchParams } = request.nextUrl;
|
|
42
|
+
const authCode = searchParams.get('auth_code');
|
|
43
|
+
const oauthSuccess = searchParams.get('oauth_success');
|
|
44
|
+
const oauthError = searchParams.get('oauth_error');
|
|
45
|
+
|
|
46
|
+
const redirectUrl = new URL('/auth/callback', request.url);
|
|
47
|
+
|
|
48
|
+
if (oauthError) {
|
|
49
|
+
return errorRedirect(redirectUrl, oauthError);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (oauthSuccess !== 'true' || !authCode) {
|
|
53
|
+
return errorRedirect(redirectUrl, 'invalid_request');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Exchange the one-time auth_code for the real JWT.
|
|
57
|
+
// This is a server-to-server call — the JWT never enters the browser URL.
|
|
58
|
+
let token: string;
|
|
59
|
+
try {
|
|
60
|
+
const exchangeRes = await fetch(`${BACKEND_URL}/api/oauth/customer/exchange`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ code: authCode }),
|
|
64
|
+
});
|
|
65
|
+
if (!exchangeRes.ok) {
|
|
66
|
+
throw new Error(`exchange responded ${exchangeRes.status}`);
|
|
67
|
+
}
|
|
68
|
+
const data = await exchangeRes.json();
|
|
69
|
+
if (!data.token) throw new Error('missing token in exchange response');
|
|
70
|
+
token = data.token;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error('[oauth-callback] auth_code exchange failed:', err);
|
|
73
|
+
return errorRedirect(redirectUrl, 'server_error');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
redirectUrl.searchParams.set('oauth_success', 'true');
|
|
77
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
78
|
+
|
|
79
|
+
response.cookies.set(TOKEN_COOKIE, token, {
|
|
80
|
+
httpOnly: true,
|
|
81
|
+
secure: isSecure(),
|
|
82
|
+
sameSite: 'lax',
|
|
83
|
+
path: '/',
|
|
84
|
+
maxAge: COOKIE_MAX_AGE,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
88
|
+
httpOnly: false,
|
|
89
|
+
secure: isSecure(),
|
|
90
|
+
sameSite: 'lax',
|
|
91
|
+
path: '/',
|
|
92
|
+
maxAge: COOKIE_MAX_AGE,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// The referral code rode the OAuth state and is now spent (or ignored, for
|
|
96
|
+
// an existing customer). Drop it so the next account created in this
|
|
97
|
+
// browser is not attributed to the same referrer — same as /register does.
|
|
98
|
+
response.cookies.set(REFERRAL_COOKIE, '', { path: '/', maxAge: 0, sameSite: 'lax' });
|
|
99
|
+
|
|
100
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
101
|
+
return response;
|
|
102
|
+
}
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* - Injects Article JSON-LD for Google rich snippets
|
|
7
7
|
* - Body HTML is sanitized before rendering
|
|
8
8
|
*
|
|
9
|
+
* One implementation for single- and multi-language stores: `locale` exists only
|
|
10
|
+
* under the `[locale]` segment and is `undefined` otherwise. Every string comes
|
|
11
|
+
* from the `blog` message namespace in the request locale; none is a
|
|
12
|
+
* `locale === 'he'` ternary, which gave every third language English and forced
|
|
13
|
+
* `dir="ltr"` onto Arabic.
|
|
14
|
+
*
|
|
9
15
|
* Security: always pass blog content through sanitizeHtml before dangerouslySetInnerHTML.
|
|
10
16
|
*/
|
|
11
17
|
import * as React from 'react';
|
|
@@ -16,14 +22,15 @@ import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
|
16
22
|
import { ArticleJsonLd } from '@/components/seo/article-json-ld';
|
|
17
23
|
import { getCanonicalSiteUrl } from '@/core/lib/site-url';
|
|
18
24
|
|
|
25
|
+
import { defaultLocale } from '@/i18n';
|
|
19
26
|
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
|
|
27
|
+
import { localePath } from '@/core/lib/locale-path';
|
|
28
|
+
import { getServerNamespace } from '@/core/lib/server-messages';
|
|
20
29
|
import { sanitizeHtml } from '@/core/lib/sanitize';
|
|
21
30
|
import { decodeSlug } from '@/core/lib/utils';
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
<% if (i18nEnabled) { %>
|
|
25
32
|
type PageProps = {
|
|
26
|
-
params: Promise<{ locale
|
|
33
|
+
params: Promise<{ locale?: string; slug: string }>;
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
@@ -41,8 +48,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
41
48
|
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
42
49
|
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
43
50
|
const SITE_URL = await getCanonicalSiteUrl();
|
|
44
|
-
const canonicalPath =
|
|
45
|
-
locale && locale !== defaultLoc ? `/${locale}/blog/${post.slug}` : `/blog/${post.slug}`;
|
|
51
|
+
const canonicalPath = localePath(locale, `/blog/${post.slug}`);
|
|
46
52
|
const hreflangLanguages =
|
|
47
53
|
supportedLocales.length > 1
|
|
48
54
|
? Object.fromEntries([
|
|
@@ -63,9 +69,12 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
63
69
|
canonical: canonicalPath,
|
|
64
70
|
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
65
71
|
},
|
|
72
|
+
// `openGraph` replaces the root layout's object wholesale rather than merging
|
|
73
|
+
// into it, so `siteName` is repeated here.
|
|
66
74
|
openGraph: {
|
|
67
75
|
title: post.seoTitle ?? post.title,
|
|
68
76
|
description: post.seoDescription ?? post.excerpt ?? undefined,
|
|
77
|
+
...(storeInfo?.name ? { siteName: storeInfo.name } : {}),
|
|
69
78
|
type: 'article',
|
|
70
79
|
publishedTime: post.publishedAt ?? undefined,
|
|
71
80
|
authors: post.author ? [post.author] : undefined,
|
|
@@ -95,29 +104,43 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|
|
95
104
|
// Default locale stays unprefixed (matches the sitemap/hreflang convention).
|
|
96
105
|
const redirect = await client.resolveSlugRedirect('blog', slug);
|
|
97
106
|
if (redirect) {
|
|
98
|
-
|
|
99
|
-
const prefix = locale && locale !== defaultLoc ? `/${locale}` : '';
|
|
100
|
-
permanentRedirect(`${prefix}/blog/${redirect.currentSlug}`);
|
|
107
|
+
permanentRedirect(localePath(locale, `/blog/${redirect.currentSlug}`));
|
|
101
108
|
}
|
|
102
109
|
notFound();
|
|
103
110
|
}
|
|
104
111
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
const [SITE_URL, strings, seo] = await Promise.all([
|
|
113
|
+
getCanonicalSiteUrl(),
|
|
114
|
+
getServerNamespace('blog', locale),
|
|
115
|
+
getServerNamespace('seo', locale),
|
|
116
|
+
]);
|
|
117
|
+
// The same URL the canonical tag names (locale prefix included), so the
|
|
118
|
+
// Article JSON-LD cannot point at a different page than the page declares.
|
|
119
|
+
const articleUrl = `${SITE_URL}${localePath(locale, `/blog/${post.slug}`)}`;
|
|
120
|
+
const breadcrumbs = [
|
|
121
|
+
{ name: seo.home ?? '', url: `${SITE_URL}${localePath(locale, '/')}` },
|
|
122
|
+
{ name: seo.blog ?? '', url: `${SITE_URL}${localePath(locale, '/blog')}` },
|
|
123
|
+
{ name: post.title, url: articleUrl },
|
|
124
|
+
];
|
|
110
125
|
|
|
111
126
|
return (
|
|
112
|
-
<main
|
|
113
|
-
<ArticleJsonLd
|
|
127
|
+
<main className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
128
|
+
<ArticleJsonLd
|
|
129
|
+
post={post}
|
|
130
|
+
url={articleUrl}
|
|
131
|
+
organizationName={storeInfo?.name}
|
|
132
|
+
breadcrumbs={breadcrumbs}
|
|
133
|
+
/>
|
|
114
134
|
|
|
115
135
|
{/* Back link */}
|
|
116
136
|
<Link
|
|
117
|
-
href=
|
|
137
|
+
href={localePath(locale, '/blog')}
|
|
118
138
|
className="mb-6 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
119
139
|
>
|
|
120
|
-
|
|
140
|
+
<span aria-hidden="true" className="rtl:rotate-180">
|
|
141
|
+
←
|
|
142
|
+
</span>
|
|
143
|
+
{strings.backToBlog}
|
|
121
144
|
</Link>
|
|
122
145
|
|
|
123
146
|
{/* Category */}
|
|
@@ -139,7 +162,7 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|
|
139
162
|
{post.author && <span className="font-medium text-foreground">{post.author}</span>}
|
|
140
163
|
{post.publishedAt && (
|
|
141
164
|
<time dateTime={post.publishedAt}>
|
|
142
|
-
{new Date(post.publishedAt).toLocaleDateString(
|
|
165
|
+
{new Date(post.publishedAt).toLocaleDateString(locale ?? defaultLocale, {
|
|
143
166
|
year: 'numeric',
|
|
144
167
|
month: 'long',
|
|
145
168
|
day: 'numeric',
|
|
@@ -189,129 +212,3 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|
|
189
212
|
</main>
|
|
190
213
|
);
|
|
191
214
|
}
|
|
192
|
-
<% } else { %>
|
|
193
|
-
type PageProps = {
|
|
194
|
-
params: Promise<{ slug: string }>;
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
198
|
-
const { slug: rawSlug } = await params;
|
|
199
|
-
const slug = decodeSlug(rawSlug);
|
|
200
|
-
const post = await (await getServerClient()).blog.getPost(slug).catch(() => null);
|
|
201
|
-
if (!post) return { title: slug };
|
|
202
|
-
return {
|
|
203
|
-
title: post.seoTitle ?? post.title,
|
|
204
|
-
description: post.seoDescription ?? post.excerpt,
|
|
205
|
-
alternates: { canonical: `/blog/${post.slug}` },
|
|
206
|
-
openGraph: {
|
|
207
|
-
title: post.seoTitle ?? post.title,
|
|
208
|
-
description: post.seoDescription ?? post.excerpt ?? undefined,
|
|
209
|
-
type: 'article',
|
|
210
|
-
publishedTime: post.publishedAt ?? undefined,
|
|
211
|
-
authors: post.author ? [post.author] : undefined,
|
|
212
|
-
images: post.ogImageUrl
|
|
213
|
-
? [{ url: post.ogImageUrl }]
|
|
214
|
-
: post.coverImageUrl
|
|
215
|
-
? [{ url: post.coverImageUrl }]
|
|
216
|
-
: undefined,
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export default async function BlogPostPage({ params }: PageProps) {
|
|
222
|
-
const { slug: rawSlug } = await params;
|
|
223
|
-
const slug = decodeSlug(rawSlug);
|
|
224
|
-
const client = await getServerClient();
|
|
225
|
-
const [post, storeInfo] = await Promise.all([
|
|
226
|
-
// getPost returns null only on a genuine 404; any other API failure (a
|
|
227
|
-
// 403 from the channel's origin check, a network error) throws and
|
|
228
|
-
// surfaces as the error it is instead of masquerading as a missing post.
|
|
229
|
-
client.blog.getPost(slug),
|
|
230
|
-
client.getStoreInfo().catch(() => null),
|
|
231
|
-
]);
|
|
232
|
-
if (!post) {
|
|
233
|
-
// The slug may have been renamed — 301 to the current slug so the old
|
|
234
|
-
// URL keeps its ranking; genuine unknowns fall through to the 404 page.
|
|
235
|
-
const redirect = await client.resolveSlugRedirect('blog', slug);
|
|
236
|
-
if (redirect) permanentRedirect(`/blog/${redirect.currentSlug}`);
|
|
237
|
-
notFound();
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const SITE_URL = await getCanonicalSiteUrl();
|
|
241
|
-
const articleUrl = `${SITE_URL}/blog/${post.slug}`;
|
|
242
|
-
|
|
243
|
-
return (
|
|
244
|
-
<main className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
245
|
-
<ArticleJsonLd post={post} url={articleUrl} organizationName={storeInfo?.name} />
|
|
246
|
-
|
|
247
|
-
<Link
|
|
248
|
-
href="/blog"
|
|
249
|
-
className="mb-6 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
250
|
-
>
|
|
251
|
-
← Back to blog
|
|
252
|
-
</Link>
|
|
253
|
-
|
|
254
|
-
{post.category && (
|
|
255
|
-
<div className="mb-3">
|
|
256
|
-
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
|
257
|
-
{post.category}
|
|
258
|
-
</span>
|
|
259
|
-
</div>
|
|
260
|
-
)}
|
|
261
|
-
|
|
262
|
-
<h1 className="mb-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl leading-tight">
|
|
263
|
-
{post.title}
|
|
264
|
-
</h1>
|
|
265
|
-
|
|
266
|
-
<div className="mb-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground border-b border-border pb-6">
|
|
267
|
-
{post.author && <span className="font-medium text-foreground">{post.author}</span>}
|
|
268
|
-
{post.publishedAt && (
|
|
269
|
-
<time dateTime={post.publishedAt}>
|
|
270
|
-
{new Date(post.publishedAt).toLocaleDateString('en-US', {
|
|
271
|
-
year: 'numeric',
|
|
272
|
-
month: 'long',
|
|
273
|
-
day: 'numeric',
|
|
274
|
-
})}
|
|
275
|
-
</time>
|
|
276
|
-
)}
|
|
277
|
-
{post.tags && post.tags.length > 0 && (
|
|
278
|
-
<div className="flex flex-wrap gap-1.5">
|
|
279
|
-
{post.tags.map((tag) => (
|
|
280
|
-
<span
|
|
281
|
-
key={tag}
|
|
282
|
-
className="rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
|
283
|
-
>
|
|
284
|
-
{tag}
|
|
285
|
-
</span>
|
|
286
|
-
))}
|
|
287
|
-
</div>
|
|
288
|
-
)}
|
|
289
|
-
</div>
|
|
290
|
-
|
|
291
|
-
{post.coverImageUrl && (
|
|
292
|
-
<div className="mb-8 overflow-hidden rounded-xl">
|
|
293
|
-
<Image
|
|
294
|
-
src={post.coverImageUrl}
|
|
295
|
-
alt={post.coverImageAlt ?? post.title}
|
|
296
|
-
width={800}
|
|
297
|
-
height={450}
|
|
298
|
-
className="w-full object-cover"
|
|
299
|
-
priority
|
|
300
|
-
/>
|
|
301
|
-
</div>
|
|
302
|
-
)}
|
|
303
|
-
|
|
304
|
-
{post.excerpt && (
|
|
305
|
-
<p className="mb-6 text-lg text-muted-foreground leading-relaxed font-light italic border-s-2 border-primary/40 ps-4">
|
|
306
|
-
{post.excerpt}
|
|
307
|
-
</p>
|
|
308
|
-
)}
|
|
309
|
-
|
|
310
|
-
<div
|
|
311
|
-
className="prose prose-neutral dark:prose-invert max-w-none"
|
|
312
|
-
dangerouslySetInnerHTML={{ __html: sanitizeHtml(post.content) }}
|
|
313
|
-
/>
|
|
314
|
-
</main>
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
<% } %>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog listing page — fetches published posts from the Brainerce blog API.
|
|
3
|
+
*
|
|
4
|
+
* Merchants manage posts in the Brainerce dashboard under Sell → Blog.
|
|
5
|
+
* The storefront renders them here. Each post links to /blog/[slug].
|
|
6
|
+
*
|
|
7
|
+
* Posts are only visible when status=PUBLISHED and publishedAt <= NOW().
|
|
8
|
+
* Category and tag filtering available via query params.
|
|
9
|
+
*
|
|
10
|
+
* One implementation for single- and multi-language stores: `locale` exists only
|
|
11
|
+
* under the `[locale]` segment and is `undefined` otherwise. Every string comes
|
|
12
|
+
* from the `seo` / `blog` message namespaces in the request locale; none is a
|
|
13
|
+
* `locale === 'he'` ternary, which gave every third language English.
|
|
14
|
+
*/
|
|
15
|
+
import * as React from 'react';
|
|
16
|
+
import type { Metadata } from 'next';
|
|
17
|
+
import Link from 'next/link';
|
|
18
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
19
|
+
|
|
20
|
+
import { defaultLocale } from '@/i18n';
|
|
21
|
+
import { getServerClient } from '@/core/lib/brainerce.server';
|
|
22
|
+
import { localePath } from '@/core/lib/locale-path';
|
|
23
|
+
import { getServerNamespace } from '@/core/lib/server-messages';
|
|
24
|
+
|
|
25
|
+
type PageProps = {
|
|
26
|
+
params: Promise<{ locale?: string }>;
|
|
27
|
+
searchParams: Promise<{ category?: string; tag?: string; page?: string }>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** `/blog` plus whichever of category / tag / page apply, in a fixed order. */
|
|
31
|
+
function blogHref(
|
|
32
|
+
locale: string | undefined,
|
|
33
|
+
query: { category?: string; tag?: string; page?: number }
|
|
34
|
+
): string {
|
|
35
|
+
const params = new URLSearchParams();
|
|
36
|
+
if (query.category) params.set('category', query.category);
|
|
37
|
+
if (query.tag) params.set('tag', query.tag);
|
|
38
|
+
if (query.page && query.page > 1) params.set('page', String(query.page));
|
|
39
|
+
const qs = params.toString();
|
|
40
|
+
return `${localePath(locale, '/blog')}${qs ? `?${qs}` : ''}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The canonical is set here because the root layout no longer sets one: a
|
|
44
|
+
// layout-level canonical is inherited by every page without its own, and this
|
|
45
|
+
// page used to tell Google it was a duplicate of the homepage. Each page of the
|
|
46
|
+
// list is its own canonical (`?page=2` is not a copy of page 1).
|
|
47
|
+
export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
|
|
48
|
+
const { locale } = await params;
|
|
49
|
+
const { category, tag, page: pageParam } = await searchParams;
|
|
50
|
+
const seo = await getServerNamespace('seo', locale);
|
|
51
|
+
return {
|
|
52
|
+
title: seo.blog ?? '',
|
|
53
|
+
description: seo.blogDescription ?? '',
|
|
54
|
+
alternates: {
|
|
55
|
+
canonical: blogHref(locale, { category, tag, page: Number(pageParam ?? '1') || 1 }),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default async function BlogListPage({ params, searchParams }: PageProps) {
|
|
61
|
+
const { locale } = await params;
|
|
62
|
+
const { category, tag, page: pageParam } = await searchParams;
|
|
63
|
+
const page = Number(pageParam ?? '1') || 1;
|
|
64
|
+
|
|
65
|
+
const client = await getServerClient(locale);
|
|
66
|
+
const [{ data: posts, meta }, seo, strings] = await Promise.all([
|
|
67
|
+
client.blog
|
|
68
|
+
.getPosts({ category, tag, page, limit: 12 })
|
|
69
|
+
.catch(() => ({ data: [], meta: { page: 1, limit: 12, total: 0, totalPages: 1 } })),
|
|
70
|
+
getServerNamespace('seo', locale),
|
|
71
|
+
getServerNamespace('blog', locale),
|
|
72
|
+
]);
|
|
73
|
+
const dateLocale = locale ?? defaultLocale;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<main className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
|
|
77
|
+
<h1 className="mb-8 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
|
78
|
+
{seo.blog}
|
|
79
|
+
</h1>
|
|
80
|
+
|
|
81
|
+
{posts.length === 0 ? (
|
|
82
|
+
<p className="text-muted-foreground">{strings.empty}</p>
|
|
83
|
+
) : (
|
|
84
|
+
<>
|
|
85
|
+
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
|
86
|
+
{posts.map((post) => {
|
|
87
|
+
const postHref = localePath(locale, `/blog/${encodeURIComponent(post.slug)}`);
|
|
88
|
+
return (
|
|
89
|
+
<article
|
|
90
|
+
key={post.id}
|
|
91
|
+
className="group flex flex-col overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm transition-shadow hover:shadow-md"
|
|
92
|
+
>
|
|
93
|
+
{post.coverImageUrl && (
|
|
94
|
+
<Link href={postHref} tabIndex={-1}>
|
|
95
|
+
<div className="relative aspect-[16/9] overflow-hidden">
|
|
96
|
+
<Image
|
|
97
|
+
src={post.coverImageUrl}
|
|
98
|
+
alt={post.coverImageAlt ?? post.title}
|
|
99
|
+
fill
|
|
100
|
+
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
101
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
</Link>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
<div className="flex flex-1 flex-col gap-3 p-5">
|
|
108
|
+
{/* Category badge */}
|
|
109
|
+
{post.category && (
|
|
110
|
+
<span className="w-fit rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
|
|
111
|
+
{post.category}
|
|
112
|
+
</span>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* Title */}
|
|
116
|
+
<Link href={postHref}>
|
|
117
|
+
<h2 className="line-clamp-2 text-lg font-semibold leading-snug text-foreground hover:text-primary transition-colors">
|
|
118
|
+
{post.title}
|
|
119
|
+
</h2>
|
|
120
|
+
</Link>
|
|
121
|
+
|
|
122
|
+
{/* Excerpt */}
|
|
123
|
+
{post.excerpt && (
|
|
124
|
+
<p className="line-clamp-3 text-sm text-muted-foreground leading-relaxed">
|
|
125
|
+
{post.excerpt}
|
|
126
|
+
</p>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Meta: author + date */}
|
|
130
|
+
<div className="mt-auto flex items-center gap-2 pt-2 text-xs text-muted-foreground">
|
|
131
|
+
{post.author && <span>{post.author}</span>}
|
|
132
|
+
{post.author && post.publishedAt && <span>·</span>}
|
|
133
|
+
{post.publishedAt && (
|
|
134
|
+
<time dateTime={post.publishedAt}>
|
|
135
|
+
{new Date(post.publishedAt).toLocaleDateString(dateLocale, {
|
|
136
|
+
year: 'numeric',
|
|
137
|
+
month: 'short',
|
|
138
|
+
day: 'numeric',
|
|
139
|
+
})}
|
|
140
|
+
</time>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<Link
|
|
145
|
+
href={postHref}
|
|
146
|
+
className="mt-1 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
|
147
|
+
>
|
|
148
|
+
{strings.readMore}
|
|
149
|
+
<span aria-hidden="true" className="rtl:rotate-180">
|
|
150
|
+
→
|
|
151
|
+
</span>
|
|
152
|
+
</Link>
|
|
153
|
+
</div>
|
|
154
|
+
</article>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Pagination */}
|
|
160
|
+
{meta.totalPages > 1 && (
|
|
161
|
+
<nav className="mt-10 flex items-center justify-center gap-4">
|
|
162
|
+
{page > 1 && (
|
|
163
|
+
<Link
|
|
164
|
+
href={blogHref(locale, { category, tag, page: page - 1 })}
|
|
165
|
+
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
|
|
166
|
+
>
|
|
167
|
+
{strings.previous}
|
|
168
|
+
</Link>
|
|
169
|
+
)}
|
|
170
|
+
<span className="text-sm text-muted-foreground">
|
|
171
|
+
{page} / {meta.totalPages}
|
|
172
|
+
</span>
|
|
173
|
+
{page < meta.totalPages && (
|
|
174
|
+
<Link
|
|
175
|
+
href={blogHref(locale, { category, tag, page: page + 1 })}
|
|
176
|
+
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
|
|
177
|
+
>
|
|
178
|
+
{strings.next}
|
|
179
|
+
</Link>
|
|
180
|
+
)}
|
|
181
|
+
</nav>
|
|
182
|
+
)}
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
</main>
|
|
186
|
+
);
|
|
187
|
+
}
|