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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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=
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
<% } %>
|