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.
Files changed (44) hide show
  1. package/dist/index.js +3 -2
  2. package/messages/en.json +21 -0
  3. package/messages/he.json +21 -0
  4. package/package.json +3 -2
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +259 -221
  7. package/templates/nextjs/base/TRANSLATIONS.md +6 -0
  8. package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
  9. package/templates/nextjs/base/scripts/connect.mjs +6 -0
  10. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
  11. package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
  12. package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
  13. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
  15. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
  16. package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
  17. package/templates/nextjs/base/src/app/page.tsx +109 -75
  18. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
  19. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
  20. package/templates/nextjs/base/src/app/products/page.tsx +184 -19
  21. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
  22. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
  23. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
  24. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
  25. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
  26. package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
  27. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
  28. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
  29. package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
  30. package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
  31. package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
  32. package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
  33. package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
  34. package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
  35. package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
  36. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  37. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
  38. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
  39. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
  40. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
  42. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
  43. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
  44. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +0 -18
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, type ReactNode } from 'react';
4
+ import type { Product, PublicMetafieldDefinition } from 'brainerce';
5
+
6
+ /**
7
+ * First-page product data fetched on the SERVER, handed to the client hooks so
8
+ * the home grid and /products render real products in the initial HTML.
9
+ *
10
+ * Without it both pages were client-only: the server sent a spinner, the browser
11
+ * fetched, and a crawler that does not run JavaScript saw an empty page for the
12
+ * two most important URLs on the site.
13
+ *
14
+ * Delivered through context rather than props on `HomeClient` / `ProductListing`
15
+ * on purpose: those components are overridden by every design pack and mirrored
16
+ * in the design canvas, so a new prop has to be forwarded through each copy or
17
+ * the seed is silently dropped. The hooks they all call are shared, so the seed
18
+ * lives there.
19
+ */
20
+ export interface HomeSeed {
21
+ products: Product[];
22
+ /** Region the server priced these in. A seed for another region is not used. */
23
+ regionId: string | undefined;
24
+ }
25
+
26
+ export interface ListingSeed {
27
+ products: Product[];
28
+ page: number;
29
+ totalPages: number;
30
+ total: number;
31
+ /** `listingSignature(...)` of the query the server ran. The hook uses the seed only when its own signature matches. */
32
+ signature: string;
33
+ /**
34
+ * The filterable custom-field definitions the server resolved, only when the
35
+ * URL selected a facet. Needed to compute the same signature in the browser
36
+ * before the definitions load there.
37
+ */
38
+ metafieldDefs: PublicMetafieldDefinition[];
39
+ }
40
+
41
+ interface SeedValue {
42
+ home: HomeSeed | null;
43
+ listing: ListingSeed | null;
44
+ }
45
+
46
+ const SeedContext = createContext<SeedValue>({ home: null, listing: null });
47
+
48
+ export function ListingSeedProvider({
49
+ home = null,
50
+ listing = null,
51
+ children,
52
+ }: {
53
+ home?: HomeSeed | null;
54
+ listing?: ListingSeed | null;
55
+ children: ReactNode;
56
+ }) {
57
+ return <SeedContext.Provider value={{ home, listing }}>{children}</SeedContext.Provider>;
58
+ }
59
+
60
+ export function useHomeSeed(): HomeSeed | null {
61
+ return useContext(SeedContext).home;
62
+ }
63
+
64
+ export function useListingSeed(): ListingSeed | null {
65
+ return useContext(SeedContext).listing;
66
+ }
@@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge';
16
16
  import { Button } from '@/components/ui/button';
17
17
  import { Card } from '@/components/ui/card';
18
18
  import { useCart } from '@/core/providers/store-provider';
19
+ import { useReviewsEnabled } from '@/core/hooks/use-reviews-enabled';
19
20
  import { useCurrency } from '@/core/lib/use-currency';
20
21
  import { trackAddToCart } from '@/core/lib/tracking';
21
22
  import {
@@ -56,6 +57,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
56
57
  const router = useRouter();
57
58
  const { refreshCart } = useCart();
58
59
  const fallbackCurrency = useCurrency();
60
+ const showRating = useReviewsEnabled();
59
61
  // FX overlay (PRD §23): prefer the region-converted display values when the
60
62
  // storefront passed regionId to getProducts. Otherwise fall back to the
61
63
  // canonical store-currency basePrice / salePrice.
@@ -205,8 +207,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
205
207
  {/* Star rating. `avgRating` / `reviewCount` are denormalized rollups
206
208
  the API returns on every product fetch. Auto-hides on a store with
207
209
  no reviews yet: `reviewCount` is 0 (or absent) and the whole block
208
- is skipped, so nothing renders and the card keeps its layout. */}
209
- {product.reviewCount ? (
210
+ is skipped, so nothing renders and the card keeps its layout.
211
+ Also hidden while the merchant has reviews switched off: the rollups
212
+ keep their stored values then, and a rating with no reviews to open
213
+ is a claim the page cannot back. */}
214
+ {showRating && product.reviewCount ? (
210
215
  <div className="flex items-center gap-1" aria-label={tr('starRating')}>
211
216
  <Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" aria-hidden="true" />
212
217
  <span className="text-foreground text-xs font-medium">
@@ -1,6 +1,7 @@
1
1
  import { Star } from 'lucide-react';
2
2
  import type { ProductReview } from 'brainerce';
3
3
  import { getServerClient } from '@/core/lib/brainerce.server';
4
+ import { isReviewsDisabledError } from '@/core/lib/reviews-disabled';
4
5
  import { CdnImage } from '@/ui/shared/cdn-image';
5
6
  import { Badge } from '@/components/ui/badge';
6
7
  <% if (i18nEnabled) { %>import { getMessages, defaultLocale } from '@/i18n';<% } else { %>import { defaultLocale, messages } from '@/i18n';<% } %>
@@ -44,8 +45,11 @@ export async function ReviewsSection({
44
45
  try {
45
46
  const result = await (await getServerClient()).listProductReviews(productId, { page: 1, limit: 20, sort: 'photos_first' });
46
47
  reviews = result.data;
47
- } catch {
48
- // Review listing disabled at store level or network error — render the form anyway.
48
+ } catch (err) {
49
+ // The merchant switched reviews off: this store collects none, so render
50
+ // nothing (no heading, no "no reviews yet", no form). Any other failure
51
+ // (network, 5xx) is not "off": render the form with an empty list.
52
+ if (isReviewsDisabledError(err)) return null;
49
53
  reviews = [];
50
54
  }
51
55
  }
@@ -14,6 +14,7 @@ import { resolveRegion } from '@/core/lib/region.server';
14
14
  import { getDirection, getMessages, supportedLocales } from '@/i18n';
15
15
  import { getNonce } from '@/core/lib/nonce';
16
16
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
17
+ import { getServerNamespace } from '@/core/lib/server-messages';
17
18
  import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
18
19
  import { buildMetaDescription } from '@/core/lib/seo';
19
20
  import '../globals.css';
@@ -56,9 +57,6 @@ export async function generateMetadata({
56
57
  template: buildTitleTemplate(storeName),
57
58
  },
58
59
  description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
59
- alternates: {
60
- canonical: '/',
61
- },
62
60
  openGraph: {
63
61
  siteName: storeName,
64
62
  type: 'website',
@@ -70,15 +68,6 @@ export async function generateMetadata({
70
68
  };
71
69
  }
72
70
 
73
- function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
74
- return {
75
- '@context': 'https://schema.org',
76
- '@type': 'Organization',
77
- name: storeName,
78
- url: baseUrl,
79
- };
80
- }
81
-
82
71
  export function generateStaticParams() {
83
72
  return supportedLocales.map((locale) => ({ locale }));
84
73
  }
@@ -93,7 +82,6 @@ export default async function RootLayout({
93
82
  const { locale } = await params;
94
83
  const dir = getDirection(locale);
95
84
  const nonce = await getNonce();
96
- const baseUrl = await getCanonicalSiteUrl();
97
85
 
98
86
  // Merchant-driven layout chrome — fetched server-side. Each call falls back
99
87
  // to null/[] on 404 so the layout never crashes when the merchant hasn't
@@ -129,21 +117,17 @@ export default async function RootLayout({
129
117
  // One resolved name for the JSON-LD, the header and the footer. Live data
130
118
  // first; the scaffold-time literal is only the last fallback.
131
119
  const storeName = resolveStoreName(storeInfo);
120
+ // Feed title in the request locale (a literal "Blog" was every language's).
121
+ const seoStrings = await getServerNamespace('seo', locale);
132
122
 
133
123
  return (
134
124
  <html lang={locale} dir={dir} data-scroll-behavior="smooth">
135
125
  <head>
136
- <script
137
- type="application/ld+json"
138
- nonce={nonce}
139
- suppressHydrationWarning
140
- dangerouslySetInnerHTML={{
141
- __html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
142
- .replace(/</g, '\\u003c')
143
- .replace(/>/g, '\\u003e')
144
- .replace(/&/g, '\\u0026'),
145
- }}
146
- />
126
+ {/* No Organization JSON-LD here. This layout wraps EVERY page, and the
127
+ homepage already emits the full Organization + WebSite block
128
+ (components/seo/organization-json-ld.tsx); a second, thinner
129
+ Organization on the same page is a duplicate entity, not a reinforcement.
130
+ Organization data belongs on the homepage only. */}
147
131
  {/* Google Search Console verification — token set by the merchant in
148
132
  the dashboard (channel settings → Google site verification). Also
149
133
  what the Merchant Center website claim checks for. Renders nothing
@@ -153,7 +137,7 @@ export default async function RootLayout({
153
137
  ) : null}
154
138
  {/* RSS autodiscovery — feed readers and aggregators find the blog feed
155
139
  from any page. The route itself lives at /blog/rss.xml. */}
156
- <link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
140
+ <link rel="alternate" type="application/rss+xml" title={seoStrings.blog ?? ''} href="/blog/rss.xml" />
157
141
  {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
158
142
  SPA route changes. Cookieless (no consent banner needed); the
159
143
  merchant can toggle it per sales channel in the dashboard. */}
@@ -209,6 +193,7 @@ import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
209
193
  import { resolveRegion } from '@/core/lib/region.server';
210
194
  import { getNonce } from '@/core/lib/nonce';
211
195
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
196
+ import { getServerNamespace } from '@/core/lib/server-messages';
212
197
  import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
213
198
  import { buildMetaDescription } from '@/core/lib/seo';
214
199
  import './globals.css';
@@ -246,9 +231,6 @@ export async function generateMetadata(): Promise<Metadata> {
246
231
  template: buildTitleTemplate(storeName),
247
232
  },
248
233
  description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
249
- alternates: {
250
- canonical: '/',
251
- },
252
234
  openGraph: {
253
235
  siteName: storeName,
254
236
  locale: '<%= ogLocale %>',
@@ -261,22 +243,12 @@ export async function generateMetadata(): Promise<Metadata> {
261
243
  };
262
244
  }
263
245
 
264
- function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
265
- return {
266
- '@context': 'https://schema.org',
267
- '@type': 'Organization',
268
- name: storeName,
269
- url: baseUrl,
270
- };
271
- }
272
-
273
246
  export default async function RootLayout({
274
247
  children,
275
248
  }: {
276
249
  children: React.ReactNode;
277
250
  }) {
278
251
  const nonce = await getNonce();
279
- const baseUrl = await getCanonicalSiteUrl();
280
252
 
281
253
  // Merchant-driven layout chrome — fetched server-side. Each call falls back
282
254
  // to null/[] on 404 so the layout never crashes when the merchant hasn't
@@ -308,21 +280,17 @@ export default async function RootLayout({
308
280
  // One resolved name for the JSON-LD, the header and the footer. Live data
309
281
  // first; the scaffold-time literal is only the last fallback.
310
282
  const storeName = resolveStoreName(storeInfo);
283
+ // Feed title in the request locale (a literal "Blog" was every language's).
284
+ const seoStrings = await getServerNamespace('seo');
311
285
 
312
286
  return (
313
287
  <html lang="<%= language %>" dir="<%= direction %>" data-scroll-behavior="smooth">
314
288
  <head>
315
- <script
316
- type="application/ld+json"
317
- nonce={nonce}
318
- suppressHydrationWarning
319
- dangerouslySetInnerHTML={{
320
- __html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
321
- .replace(/</g, '\\u003c')
322
- .replace(/>/g, '\\u003e')
323
- .replace(/&/g, '\\u0026'),
324
- }}
325
- />
289
+ {/* No Organization JSON-LD here. This layout wraps EVERY page, and the
290
+ homepage already emits the full Organization + WebSite block
291
+ (components/seo/organization-json-ld.tsx); a second, thinner
292
+ Organization on the same page is a duplicate entity, not a reinforcement.
293
+ Organization data belongs on the homepage only. */}
326
294
  {/* Google Search Console verification — token set by the merchant in
327
295
  the dashboard (channel settings → Google site verification). Also
328
296
  what the Merchant Center website claim checks for. Renders nothing
@@ -332,7 +300,7 @@ export default async function RootLayout({
332
300
  ) : null}
333
301
  {/* RSS autodiscovery — feed readers and aggregators find the blog feed
334
302
  from any page. The route itself lives at /blog/rss.xml. */}
335
- <link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
303
+ <link rel="alternate" type="application/rss+xml" title={seoStrings.blog ?? ''} href="/blog/rss.xml" />
336
304
  {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
337
305
  SPA route changes. Cookieless (no consent banner needed); the
338
306
  merchant can toggle it per sales channel in the dashboard. */}
@@ -6,7 +6,8 @@
6
6
  * Never invent testimonials. A storefront that ships hardcoded quotes and star
7
7
  * ratings is publishing fake reviews under the merchant's name, which is illegal
8
8
  * in most markets. This section renders only what shoppers actually left, and
9
- * auto-hides entirely until someone has reviewed something.
9
+ * auto-hides entirely until someone has reviewed something, and while the
10
+ * merchant has reviews switched off.
10
11
  *
11
12
  * `avgRating` / `reviewCount` already ride on the products the page loaded, so
12
13
  * this costs no extra request.
@@ -14,12 +15,14 @@
14
15
  import type { Product } from 'brainerce';
15
16
  import { Link } from '@/core/lib/navigation';
16
17
  import { useTranslations } from '@/core/lib/translations';
18
+ import { useReviewsEnabled } from '@/core/hooks/use-reviews-enabled';
17
19
  import { IconStar } from '@/ui/shared/icons';
18
20
 
19
21
  const MAX_SHOWN = 3;
20
22
 
21
23
  export function TestimonialsBand({ products }: { products: Product[] }) {
22
24
  const t = useTranslations('home');
25
+ const reviewsOn = useReviewsEnabled();
23
26
 
24
27
  const rated = products
25
28
  .filter((p) => (p.reviewCount ?? 0) > 0 && (p.avgRating ?? 0) > 0)
@@ -27,7 +30,10 @@ export function TestimonialsBand({ products }: { products: Product[] }) {
27
30
  .slice(0, MAX_SHOWN);
28
31
 
29
32
  // Nothing reviewed yet — show nothing rather than filling the gap with fiction.
30
- if (rated.length === 0) return null;
33
+ // Also nothing while the merchant has reviews switched off: the ratings are
34
+ // still stored, but the store is not showing reviews, so quoting them here
35
+ // would advertise something a shopper cannot open.
36
+ if (!reviewsOn || rated.length === 0) return null;
31
37
 
32
38
  return (
33
39
  <section className="section-pad wash-sand">
@@ -12,6 +12,7 @@ import { StockBadge } from '@/ui/product/stock-badge';
12
12
  import { canPurchaseProduct } from '@/core/lib/kit';
13
13
  import { DiscountBadge } from '@/ui/product/discount-badge';
14
14
  import { useCart } from '@/core/providers/store-provider';
15
+ import { useReviewsEnabled } from '@/core/hooks/use-reviews-enabled';
15
16
  import { flyToCart } from '@/ui/shared/fly-to-cart';
16
17
  import { openCartDrawer } from '@/ui/cart/cart-drawer';
17
18
  import { useCurrency } from '@/core/lib/use-currency';
@@ -56,6 +57,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
56
57
  const router = useRouter();
57
58
  const { refreshCart } = useCart();
58
59
  const fallbackCurrency = useCurrency();
60
+ const showRating = useReviewsEnabled();
59
61
  // FX overlay (PRD §23): prefer the region-converted display values when the
60
62
  // storefront passed regionId to getProducts. Otherwise fall back to the
61
63
  // canonical store-currency basePrice / salePrice.
@@ -199,8 +201,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
199
201
  {/* Star rating. `avgRating` / `reviewCount` are denormalized rollups
200
202
  the API returns on every product fetch. Auto-hides on a store with
201
203
  no reviews yet: `reviewCount` is 0 (or absent) and the whole block
202
- is skipped, so nothing renders and the card keeps its layout. */}
203
- {product.reviewCount ? (
204
+ is skipped, so nothing renders and the card keeps its layout.
205
+ Also hidden while the merchant has reviews switched off: the rollups
206
+ keep their stored values then, and a rating with no reviews to open
207
+ is a claim the page cannot back. */}
208
+ {showRating && product.reviewCount ? (
204
209
  <div className="flex items-center gap-1" aria-label={tr('starRating')}>
205
210
  <span aria-hidden="true" className="text-primary">
206
211
  <IconStar size={16} />
@@ -1,5 +1,6 @@
1
1
  import type { ProductReview } from 'brainerce';
2
2
  import { getServerClient } from '@/core/lib/brainerce.server';
3
+ import { isReviewsDisabledError } from '@/core/lib/reviews-disabled';
3
4
  import { CdnImage } from '@/ui/shared/cdn-image';
4
5
  <% if (i18nEnabled) { %>
5
6
  import { getMessages, defaultLocale } from '@/i18n';
@@ -49,8 +50,11 @@ export async function ReviewsSection({
49
50
  try {
50
51
  const result = await (await getServerClient()).listProductReviews(productId, { page: 1, limit: 20, sort: 'photos_first' });
51
52
  reviews = result.data;
52
- } catch {
53
- // Review listing disabled at store level or network error — render the form anyway.
53
+ } catch (err) {
54
+ // The merchant switched reviews off: this store collects none, so render
55
+ // nothing (no heading, no "no reviews yet", no form). Any other failure
56
+ // (network, 5xx) is not "off": render the form with an empty list.
57
+ if (isReviewsDisabledError(err)) return null;
54
58
  reviews = [];
55
59
  }
56
60
  }
@@ -1,5 +1,6 @@
1
1
  import type { ProductReview } from 'brainerce';
2
2
  import { getServerClient } from '@/core/lib/brainerce.server';
3
+ import { isReviewsDisabledError } from '@/core/lib/reviews-disabled';
3
4
  import { CdnImage } from '@/ui/shared/cdn-image';
4
5
  <% if (i18nEnabled) { %>import { getMessages, defaultLocale } from '@/i18n';<% } else { %>import { defaultLocale, messages } from '@/i18n';<% } %>
5
6
  import { ReviewForm } from './review-form';
@@ -42,8 +43,11 @@ export async function ReviewsSection({
42
43
  try {
43
44
  const result = await (await getServerClient()).listProductReviews(productId, { page: 1, limit: 20, sort: 'photos_first' });
44
45
  reviews = result.data;
45
- } catch {
46
- // Review listing disabled at store level or network error — render the form anyway.
46
+ } catch (err) {
47
+ // The merchant switched reviews off: this store collects none, so render
48
+ // nothing (no heading, no "no reviews yet", no form). Any other failure
49
+ // (network, 5xx) is not "off": render the form with an empty list.
50
+ if (isReviewsDisabledError(err)) return null;
47
51
  reviews = [];
48
52
  }
49
53
  }
@@ -1,277 +0,0 @@
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
- import * as React from 'react';
11
- import type { Metadata } from 'next';
12
- import Link from 'next/link';
13
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
14
-
15
- import { getServerClient } from '@/core/lib/brainerce.server';
16
-
17
- <% if (i18nEnabled) { %>
18
- type PageProps = {
19
- params: Promise<{ locale: string }>;
20
- searchParams: Promise<{ category?: string; tag?: string; page?: string }>;
21
- };
22
-
23
- export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
24
- const { locale } = await params;
25
- return {
26
- title: locale === 'he' ? 'בלוג' : 'Blog',
27
- description: locale === 'he' ? 'מאמרים וסיפורים מהחנות' : 'Articles and stories from our store',
28
- };
29
- }
30
-
31
- export default async function BlogListPage({ params, searchParams }: PageProps) {
32
- const { locale } = await params;
33
- const { category, tag, page: pageParam } = await searchParams;
34
- const page = Number(pageParam ?? '1') || 1;
35
-
36
- const client = await getServerClient(locale);
37
- const { data: posts, meta } = await client.blog
38
- .getPosts({ category, tag, page, limit: 12 })
39
- .catch(() => ({ data: [], meta: { page: 1, limit: 12, total: 0, totalPages: 1 } }));
40
-
41
- const isHe = locale === 'he';
42
- const dir = isHe ? 'rtl' : 'ltr';
43
- const emptyLabel = isHe ? 'עדיין אין פוסטים.' : 'No posts yet.';
44
- const blogLabel = isHe ? 'בלוג' : 'Blog';
45
- const readMoreLabel = isHe ? 'קרא עוד' : 'Read more';
46
- const prevLabel = isHe ? 'הקודם' : 'Previous';
47
- const nextLabel = isHe ? 'הבא' : 'Next';
48
-
49
- return (
50
- <main dir={dir} className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
51
- <h1 className="mb-8 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
52
- {blogLabel}
53
- </h1>
54
-
55
- {posts.length === 0 ? (
56
- <p className="text-muted-foreground">{emptyLabel}</p>
57
- ) : (
58
- <>
59
- <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
60
- {posts.map((post) => (
61
- <article
62
- key={post.id}
63
- className="group flex flex-col overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm transition-shadow hover:shadow-md"
64
- >
65
- {post.coverImageUrl && (
66
- <Link href={`/blog/${encodeURIComponent(post.slug)}`} tabIndex={-1}>
67
- <div className="relative aspect-[16/9] overflow-hidden">
68
- <Image
69
- src={post.coverImageUrl}
70
- alt={post.coverImageAlt ?? post.title}
71
- fill
72
- className="object-cover transition-transform duration-300 group-hover:scale-105"
73
- sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
74
- />
75
- </div>
76
- </Link>
77
- )}
78
-
79
- <div className="flex flex-1 flex-col gap-3 p-5">
80
- {/* Category badge */}
81
- {post.category && (
82
- <span className="w-fit rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
83
- {post.category}
84
- </span>
85
- )}
86
-
87
- {/* Title */}
88
- <Link href={`/blog/${encodeURIComponent(post.slug)}`}>
89
- <h2 className="line-clamp-2 text-lg font-semibold leading-snug text-foreground hover:text-primary transition-colors">
90
- {post.title}
91
- </h2>
92
- </Link>
93
-
94
- {/* Excerpt */}
95
- {post.excerpt && (
96
- <p className="line-clamp-3 text-sm text-muted-foreground leading-relaxed">
97
- {post.excerpt}
98
- </p>
99
- )}
100
-
101
- {/* Meta: author + date */}
102
- <div className="mt-auto flex items-center gap-2 pt-2 text-xs text-muted-foreground">
103
- {post.author && <span>{post.author}</span>}
104
- {post.author && post.publishedAt && <span>·</span>}
105
- {post.publishedAt && (
106
- <time dateTime={post.publishedAt}>
107
- {new Date(post.publishedAt).toLocaleDateString(isHe ? 'he-IL' : 'en-US', {
108
- year: 'numeric',
109
- month: 'short',
110
- day: 'numeric',
111
- })}
112
- </time>
113
- )}
114
- </div>
115
-
116
- <Link
117
- href={`/blog/${encodeURIComponent(post.slug)}`}
118
- className="mt-1 text-sm font-medium text-primary hover:underline"
119
- >
120
- {readMoreLabel} →
121
- </Link>
122
- </div>
123
- </article>
124
- ))}
125
- </div>
126
-
127
- {/* Pagination */}
128
- {meta.totalPages > 1 && (
129
- <nav className="mt-10 flex items-center justify-center gap-4">
130
- {page > 1 && (
131
- <Link
132
- href={`/blog?page=${page - 1}${category ? `&category=${category}` : ''}${tag ? `&tag=${tag}` : ''}`}
133
- className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
134
- >
135
- {prevLabel}
136
- </Link>
137
- )}
138
- <span className="text-sm text-muted-foreground">
139
- {page} / {meta.totalPages}
140
- </span>
141
- {page < meta.totalPages && (
142
- <Link
143
- href={`/blog?page=${page + 1}${category ? `&category=${category}` : ''}${tag ? `&tag=${tag}` : ''}`}
144
- className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
145
- >
146
- {nextLabel}
147
- </Link>
148
- )}
149
- </nav>
150
- )}
151
- </>
152
- )}
153
- </main>
154
- );
155
- }
156
- <% } else { %>
157
- type PageProps = {
158
- searchParams: Promise<{ category?: string; tag?: string; page?: string }>;
159
- };
160
-
161
- export const metadata: Metadata = {
162
- title: 'Blog',
163
- description: 'Articles and stories from our store',
164
- };
165
-
166
- export default async function BlogListPage({ searchParams }: PageProps) {
167
- const { category, tag, page: pageParam } = await searchParams;
168
- const page = Number(pageParam ?? '1') || 1;
169
-
170
- const client = await getServerClient();
171
- const { data: posts, meta } = await client.blog
172
- .getPosts({ category, tag, page, limit: 12 })
173
- .catch(() => ({ data: [], meta: { page: 1, limit: 12, total: 0, totalPages: 1 } }));
174
-
175
- return (
176
- <main className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
177
- <h1 className="mb-8 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
178
- Blog
179
- </h1>
180
-
181
- {posts.length === 0 ? (
182
- <p className="text-muted-foreground">No posts yet.</p>
183
- ) : (
184
- <>
185
- <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
186
- {posts.map((post) => (
187
- <article
188
- key={post.id}
189
- className="group flex flex-col overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm transition-shadow hover:shadow-md"
190
- >
191
- {post.coverImageUrl && (
192
- <Link href={`/blog/${encodeURIComponent(post.slug)}`} tabIndex={-1}>
193
- <div className="relative aspect-[16/9] overflow-hidden">
194
- <Image
195
- src={post.coverImageUrl}
196
- alt={post.coverImageAlt ?? post.title}
197
- fill
198
- className="object-cover transition-transform duration-300 group-hover:scale-105"
199
- sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
200
- />
201
- </div>
202
- </Link>
203
- )}
204
-
205
- <div className="flex flex-1 flex-col gap-3 p-5">
206
- {post.category && (
207
- <span className="w-fit rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
208
- {post.category}
209
- </span>
210
- )}
211
-
212
- <Link href={`/blog/${encodeURIComponent(post.slug)}`}>
213
- <h2 className="line-clamp-2 text-lg font-semibold leading-snug text-foreground hover:text-primary transition-colors">
214
- {post.title}
215
- </h2>
216
- </Link>
217
-
218
- {post.excerpt && (
219
- <p className="line-clamp-3 text-sm text-muted-foreground leading-relaxed">
220
- {post.excerpt}
221
- </p>
222
- )}
223
-
224
- <div className="mt-auto flex items-center gap-2 pt-2 text-xs text-muted-foreground">
225
- {post.author && <span>{post.author}</span>}
226
- {post.author && post.publishedAt && <span>·</span>}
227
- {post.publishedAt && (
228
- <time dateTime={post.publishedAt}>
229
- {new Date(post.publishedAt).toLocaleDateString('en-US', {
230
- year: 'numeric',
231
- month: 'short',
232
- day: 'numeric',
233
- })}
234
- </time>
235
- )}
236
- </div>
237
-
238
- <Link
239
- href={`/blog/${encodeURIComponent(post.slug)}`}
240
- className="mt-1 text-sm font-medium text-primary hover:underline"
241
- >
242
- Read more →
243
- </Link>
244
- </div>
245
- </article>
246
- ))}
247
- </div>
248
-
249
- {meta.totalPages > 1 && (
250
- <nav className="mt-10 flex items-center justify-center gap-4">
251
- {page > 1 && (
252
- <Link
253
- href={`/blog?page=${page - 1}${category ? `&category=${category}` : ''}${tag ? `&tag=${tag}` : ''}`}
254
- className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
255
- >
256
- Previous
257
- </Link>
258
- )}
259
- <span className="text-sm text-muted-foreground">
260
- {page} / {meta.totalPages}
261
- </span>
262
- {page < meta.totalPages && (
263
- <Link
264
- href={`/blog?page=${page + 1}${category ? `&category=${category}` : ''}${tag ? `&tag=${tag}` : ''}`}
265
- className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
266
- >
267
- Next
268
- </Link>
269
- )}
270
- </nav>
271
- )}
272
- </>
273
- )}
274
- </main>
275
- );
276
- }
277
- <% } %>