create-brainerce-store 1.40.0 → 1.41.1
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 +32 -6
- package/messages/en.json +441 -435
- package/messages/he.json +441 -435
- package/package.json +2 -2
- package/templates/nextjs/base/package.json.ejs +3 -0
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +46 -46
- package/templates/nextjs/base/src/app/page.tsx +17 -6
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +92 -87
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +5 -2
- package/templates/nextjs/base/src/components/content/announcement-bar.tsx.ejs +125 -125
- package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +103 -75
- package/templates/nextjs/base/src/components/content/rich-text-block.tsx.ejs +32 -32
- package/templates/nextjs/base/src/components/content/site-footer.tsx.ejs +164 -164
- package/templates/nextjs/base/src/components/content/site-header.tsx.ejs +166 -142
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +1 -1
- package/templates/nextjs/base/src/lib/sanitize.ts.ejs +26 -26
- package/templates/nextjs/base/src/lib/seo.ts +1 -4
- package/templates/nextjs/base/src/lib/utils.ts +21 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-brainerce-store",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.1",
|
|
4
4
|
"description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-brainerce-store": "dist/index.js"
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typescript": "^5.4.0"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
|
-
"node": ">=
|
|
36
|
+
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
|
37
37
|
},
|
|
38
38
|
"keywords": [
|
|
39
39
|
"brainerce",
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FAQ page — fully driven by the merchant's dashboard configuration.
|
|
3
|
-
*
|
|
4
|
-
* The merchant configures FAQs in the Brainerce dashboard under
|
|
5
|
-
* Sell → Content → FAQ
|
|
6
|
-
*
|
|
7
|
-
* This page fetches the merchant's *primary* FAQ (key='main'). For topical
|
|
8
|
-
* FAQs (e.g. /faq/shipping), copy this route to `app/faq/[topic]/page.tsx`
|
|
9
|
-
* and pass `params.topic` to `client.content.faq.get(topic, locale)`.
|
|
10
|
-
*/
|
|
11
|
-
import * as React from 'react';
|
|
12
|
-
import type { Metadata } from 'next';
|
|
13
|
-
|
|
14
|
-
import { getServerClient } from '@/lib/brainerce';
|
|
15
|
-
import { FaqSection } from '@/components/content/faq-section';
|
|
16
|
-
<% if (i18nEnabled) { %>
|
|
17
|
-
type PageProps = {
|
|
18
|
-
params: Promise<{ locale: string }>;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
22
|
-
const { locale } = await params;
|
|
23
|
-
const faq = await getServerClient(locale).content.faq.get('main', locale).catch(() => null);
|
|
24
|
-
return {
|
|
25
|
-
title: faq?.name ?? 'FAQ',
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export default async function FaqPage({ params }: PageProps) {
|
|
30
|
-
const { locale } = await params;
|
|
31
|
-
const faq = await getServerClient(locale).content.faq.get('main', locale).catch(() => null);
|
|
32
|
-
return <FaqSection faq={faq} />;
|
|
33
|
-
}
|
|
34
|
-
<% } else { %>
|
|
35
|
-
export async function generateMetadata(): Promise<Metadata> {
|
|
36
|
-
const faq = await getServerClient().content.faq.get('main').catch(() => null);
|
|
37
|
-
return {
|
|
38
|
-
title: faq?.name ?? 'FAQ',
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export default async function FaqPage() {
|
|
43
|
-
const faq = await getServerClient().content.faq.get('main').catch(() => null);
|
|
44
|
-
return <FaqSection faq={faq} />;
|
|
45
|
-
}
|
|
46
|
-
<% } %>
|
|
1
|
+
/**
|
|
2
|
+
* FAQ page — fully driven by the merchant's dashboard configuration.
|
|
3
|
+
*
|
|
4
|
+
* The merchant configures FAQs in the Brainerce dashboard under
|
|
5
|
+
* Sell → Content → FAQ
|
|
6
|
+
*
|
|
7
|
+
* This page fetches the merchant's *primary* FAQ (key='main'). For topical
|
|
8
|
+
* FAQs (e.g. /faq/shipping), copy this route to `app/faq/[topic]/page.tsx`
|
|
9
|
+
* and pass `params.topic` to `client.content.faq.get(topic, locale)`.
|
|
10
|
+
*/
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
import type { Metadata } from 'next';
|
|
13
|
+
|
|
14
|
+
import { getServerClient } from '@/lib/brainerce';
|
|
15
|
+
import { FaqSection } from '@/components/content/faq-section';
|
|
16
|
+
<% if (i18nEnabled) { %>
|
|
17
|
+
type PageProps = {
|
|
18
|
+
params: Promise<{ locale: string }>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
22
|
+
const { locale } = await params;
|
|
23
|
+
const faq = await getServerClient(locale).content.faq.get('main', locale).catch(() => null);
|
|
24
|
+
return {
|
|
25
|
+
title: faq?.name ?? 'FAQ',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default async function FaqPage({ params }: PageProps) {
|
|
30
|
+
const { locale } = await params;
|
|
31
|
+
const faq = await getServerClient(locale).content.faq.get('main', locale).catch(() => null);
|
|
32
|
+
return <FaqSection faq={faq} />;
|
|
33
|
+
}
|
|
34
|
+
<% } else { %>
|
|
35
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
36
|
+
const faq = await getServerClient().content.faq.get('main').catch(() => null);
|
|
37
|
+
return {
|
|
38
|
+
title: faq?.name ?? 'FAQ',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default async function FaqPage() {
|
|
43
|
+
const faq = await getServerClient().content.faq.get('main').catch(() => null);
|
|
44
|
+
return <FaqSection faq={faq} />;
|
|
45
|
+
}
|
|
46
|
+
<% } %>
|
|
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|
|
2
2
|
import { getServerClient } from '@/lib/brainerce';
|
|
3
3
|
import { buildMetaDescription } from '@/lib/seo';
|
|
4
4
|
import { OrganizationJsonLd } from '@/components/seo/organization-json-ld';
|
|
5
|
+
import { RichTextBlock } from '@/components/content/rich-text-block';
|
|
5
6
|
import { HomeClient } from './home-client';
|
|
6
7
|
|
|
7
8
|
// Build homepage metadata server-side so Google, Facebook, WhatsApp etc.
|
|
@@ -35,18 +36,28 @@ export default async function HomePage() {
|
|
|
35
36
|
// crawlers and AI Overviews see it without needing to execute JavaScript.
|
|
36
37
|
// Failure here is non-fatal: the client component re-fetches via the
|
|
37
38
|
// StoreProvider so the page still renders.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
//
|
|
40
|
+
// Also fetch the merchant's homepage intro (RICH_TEXT key='main'). When
|
|
41
|
+
// present, it renders as a sanitized HTML block above the product grid —
|
|
42
|
+
// typical use is brand introduction, value-prop copy, or a seasonal
|
|
43
|
+
// announcement that needs more formatting than the AnnouncementBar.
|
|
44
|
+
// When absent, RichTextBlock returns null cleanly so the layout collapses
|
|
45
|
+
// to just the product grid.
|
|
46
|
+
const client = getServerClient();
|
|
47
|
+
const [storeInfo, intro] = await Promise.all([
|
|
48
|
+
client.getStoreInfo().catch(() => null),
|
|
49
|
+
client.content.richText.get('main').catch(() => null),
|
|
50
|
+
]);
|
|
44
51
|
|
|
45
52
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
46
53
|
|
|
47
54
|
return (
|
|
48
55
|
<>
|
|
49
56
|
{storeInfo && baseUrl ? <OrganizationJsonLd storeInfo={storeInfo} baseUrl={baseUrl} /> : null}
|
|
57
|
+
<RichTextBlock
|
|
58
|
+
block={intro}
|
|
59
|
+
className="prose prose-sm dark:prose-invert mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"
|
|
60
|
+
/>
|
|
50
61
|
<HomeClient />
|
|
51
62
|
</>
|
|
52
63
|
);
|
|
@@ -1,87 +1,92 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Static page route — fully driven by the merchant's dashboard.
|
|
3
|
-
*
|
|
4
|
-
* The merchant creates pages in the Brainerce dashboard under
|
|
5
|
-
* Sell → Content → Page (one row per page, each with its own `slug`)
|
|
6
|
-
*
|
|
7
|
-
* This catch-all renders any PAGE by its `data.slug`. `/pages/about` →
|
|
8
|
-
* fetches PAGE with slug='about'. Mounting under `/pages/` (rather than
|
|
9
|
-
* the root) keeps the route from clashing with existing static routes
|
|
10
|
-
* like /cart, /login, /products.
|
|
11
|
-
*
|
|
12
|
-
* Security: page HTML is sanitized via `sanitizeHtml` before injecting.
|
|
13
|
-
*/
|
|
14
|
-
import * as React from 'react';
|
|
15
|
-
import type { Metadata } from 'next';
|
|
16
|
-
import { notFound } from 'next/navigation';
|
|
17
|
-
|
|
18
|
-
import { getServerClient } from '@/lib/brainerce';
|
|
19
|
-
import { sanitizeHtml } from '@/lib/sanitize';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Static page route — fully driven by the merchant's dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The merchant creates pages in the Brainerce dashboard under
|
|
5
|
+
* Sell → Content → Page (one row per page, each with its own `slug`)
|
|
6
|
+
*
|
|
7
|
+
* This catch-all renders any PAGE by its `data.slug`. `/pages/about` →
|
|
8
|
+
* fetches PAGE with slug='about'. Mounting under `/pages/` (rather than
|
|
9
|
+
* the root) keeps the route from clashing with existing static routes
|
|
10
|
+
* like /cart, /login, /products.
|
|
11
|
+
*
|
|
12
|
+
* Security: page HTML is sanitized via `sanitizeHtml` before injecting.
|
|
13
|
+
*/
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import type { Metadata } from 'next';
|
|
16
|
+
import { notFound } from 'next/navigation';
|
|
17
|
+
|
|
18
|
+
import { getServerClient } from '@/lib/brainerce';
|
|
19
|
+
import { sanitizeHtml } from '@/lib/sanitize';
|
|
20
|
+
import { decodeSlug } from '@/lib/utils';
|
|
21
|
+
|
|
22
|
+
<% if (i18nEnabled) { %>
|
|
23
|
+
type PageProps = {
|
|
24
|
+
params: Promise<{ locale: string; slug: string }>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
28
|
+
const { locale, slug: rawSlug } = await params;
|
|
29
|
+
const slug = decodeSlug(rawSlug);
|
|
30
|
+
const page = await getServerClient(locale).content.page.getBySlug(slug, locale).catch(() => null);
|
|
31
|
+
if (!page) return { title: slug };
|
|
32
|
+
const seo = page.data.seo ?? {};
|
|
33
|
+
return {
|
|
34
|
+
title: seo.title ?? page.data.title,
|
|
35
|
+
description: seo.description,
|
|
36
|
+
openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default async function StaticPage({ params }: PageProps) {
|
|
41
|
+
const { locale, slug: rawSlug } = await params;
|
|
42
|
+
const slug = decodeSlug(rawSlug);
|
|
43
|
+
const page = await getServerClient(locale).content.page.getBySlug(slug, locale).catch(() => null);
|
|
44
|
+
if (!page) notFound();
|
|
45
|
+
return (
|
|
46
|
+
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
47
|
+
<h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
|
|
48
|
+
{page.data.title}
|
|
49
|
+
</h1>
|
|
50
|
+
<div
|
|
51
|
+
className="prose prose-sm dark:prose-invert max-w-none"
|
|
52
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
|
|
53
|
+
/>
|
|
54
|
+
</article>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
<% } else { %>
|
|
58
|
+
type PageProps = {
|
|
59
|
+
params: Promise<{ slug: string }>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
63
|
+
const { slug: rawSlug } = await params;
|
|
64
|
+
const slug = decodeSlug(rawSlug);
|
|
65
|
+
const page = await getServerClient().content.page.getBySlug(slug).catch(() => null);
|
|
66
|
+
if (!page) return { title: slug };
|
|
67
|
+
const seo = page.data.seo ?? {};
|
|
68
|
+
return {
|
|
69
|
+
title: seo.title ?? page.data.title,
|
|
70
|
+
description: seo.description,
|
|
71
|
+
openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default async function StaticPage({ params }: PageProps) {
|
|
76
|
+
const { slug: rawSlug } = await params;
|
|
77
|
+
const slug = decodeSlug(rawSlug);
|
|
78
|
+
const page = await getServerClient().content.page.getBySlug(slug).catch(() => null);
|
|
79
|
+
if (!page) notFound();
|
|
80
|
+
return (
|
|
81
|
+
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
82
|
+
<h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
|
|
83
|
+
{page.data.title}
|
|
84
|
+
</h1>
|
|
85
|
+
<div
|
|
86
|
+
className="prose prose-sm dark:prose-invert max-w-none"
|
|
87
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
|
|
88
|
+
/>
|
|
89
|
+
</article>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
<% } %>
|
|
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import { getServerClient } from '@/lib/brainerce';
|
|
4
4
|
import { buildMetaDescription } from '@/lib/seo';
|
|
5
|
+
import { decodeSlug } from '@/lib/utils';
|
|
5
6
|
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
6
7
|
import { ReviewsSection } from '@/components/reviews/reviews-section';
|
|
7
8
|
import { ProductClientSection } from './product-client-section';
|
|
@@ -11,7 +12,8 @@ type Props = {
|
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
14
|
-
const { slug, locale } = await params;
|
|
15
|
+
const { slug: rawSlug, locale } = await params;
|
|
16
|
+
const slug = decodeSlug(rawSlug);
|
|
15
17
|
|
|
16
18
|
try {
|
|
17
19
|
const client = getServerClient(locale);
|
|
@@ -53,7 +55,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export default async function ProductDetailPage({ params }: Props) {
|
|
56
|
-
const { slug, locale } = await params;
|
|
58
|
+
const { slug: rawSlug, locale } = await params;
|
|
59
|
+
const slug = decodeSlug(rawSlug);
|
|
57
60
|
|
|
58
61
|
let product;
|
|
59
62
|
try {
|
|
@@ -1,125 +1,125 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Storefront announcement bar — fully driven by the merchant's dashboard.
|
|
3
|
-
*
|
|
4
|
-
* The merchant configures announcements in the Brainerce dashboard under
|
|
5
|
-
* Sell → Content → Announcement
|
|
6
|
-
*
|
|
7
|
-
* Everything below is generic rendering logic — it adapts automatically to
|
|
8
|
-
* any announcement shape returned by the API, so **you should not hardcode
|
|
9
|
-
* the message or severity here**.
|
|
10
|
-
*
|
|
11
|
-
* API contract:
|
|
12
|
-
* GET → client.content.announcement.list(locale) → Content<'ANNOUNCEMENT'>[]
|
|
13
|
-
* Empty array → renders nothing (page layout stays intact).
|
|
14
|
-
*
|
|
15
|
-
* Time-bounded: each announcement may have `startsAt` / `endsAt` ISO
|
|
16
|
-
* timestamps. We filter client-side so the cached server response can be
|
|
17
|
-
* shared across visitors regardless of the current time.
|
|
18
|
-
*/
|
|
19
|
-
'use client';
|
|
20
|
-
|
|
21
|
-
import * as React from 'react';
|
|
22
|
-
import type { Content } from 'brainerce';
|
|
23
|
-
|
|
24
|
-
interface AnnouncementBarProps {
|
|
25
|
-
/** Pre-fetched list from the server. Pass `[]` to render nothing. */
|
|
26
|
-
announcements: Content<'ANNOUNCEMENT'>[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const SEVERITY_STYLES: Record<'info' | 'warning' | 'success', string> = {
|
|
30
|
-
info: 'bg-blue-600 text-white',
|
|
31
|
-
warning: 'bg-amber-500 text-amber-950',
|
|
32
|
-
success: 'bg-emerald-600 text-white',
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const STORAGE_KEY = 'brainerce_dismissed_announcements';
|
|
36
|
-
|
|
37
|
-
export function AnnouncementBar({ announcements }: AnnouncementBarProps) {
|
|
38
|
-
const [dismissed, setDismissed] = React.useState<Set<string>>(() => new Set());
|
|
39
|
-
|
|
40
|
-
// Hydrate dismissals from localStorage on mount (avoids SSR/CSR mismatch).
|
|
41
|
-
React.useEffect(() => {
|
|
42
|
-
if (typeof window === 'undefined') return;
|
|
43
|
-
try {
|
|
44
|
-
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
45
|
-
if (raw) setDismissed(new Set(JSON.parse(raw) as string[]));
|
|
46
|
-
} catch {
|
|
47
|
-
// ignore malformed storage
|
|
48
|
-
}
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
|
-
const dismiss = (id: string) => {
|
|
52
|
-
setDismissed((prev) => {
|
|
53
|
-
const next = new Set(prev);
|
|
54
|
-
next.add(id);
|
|
55
|
-
if (typeof window !== 'undefined') {
|
|
56
|
-
try {
|
|
57
|
-
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
|
|
58
|
-
} catch {
|
|
59
|
-
// ignore quota errors
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return next;
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const now = Date.now();
|
|
67
|
-
const visible = announcements.filter((a) => {
|
|
68
|
-
if (dismissed.has(a.id)) return false;
|
|
69
|
-
const startOk = !a.data.startsAt || new Date(a.data.startsAt).getTime() <= now;
|
|
70
|
-
const endOk = !a.data.endsAt || new Date(a.data.endsAt).getTime() >= now;
|
|
71
|
-
return startOk && endOk;
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
if (visible.length === 0) return null;
|
|
75
|
-
|
|
76
|
-
return (
|
|
77
|
-
<div role="region" aria-label="Site announcements">
|
|
78
|
-
{visible.map((announcement) => {
|
|
79
|
-
const severity = announcement.data.severity;
|
|
80
|
-
const tone = SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.info;
|
|
81
|
-
return (
|
|
82
|
-
<div
|
|
83
|
-
key={announcement.id}
|
|
84
|
-
className={`flex items-center justify-center gap-3 px-4 py-2 text-sm ${tone}`}
|
|
85
|
-
>
|
|
86
|
-
<span className="flex-1 text-center">
|
|
87
|
-
{announcement.data.message}
|
|
88
|
-
{announcement.data.ctaHref && announcement.data.ctaLabel ? (
|
|
89
|
-
<>
|
|
90
|
-
{' '}
|
|
91
|
-
<a
|
|
92
|
-
href={announcement.data.ctaHref}
|
|
93
|
-
className="underline underline-offset-2"
|
|
94
|
-
>
|
|
95
|
-
{announcement.data.ctaLabel}
|
|
96
|
-
</a>
|
|
97
|
-
</>
|
|
98
|
-
) : null}
|
|
99
|
-
</span>
|
|
100
|
-
{announcement.data.dismissible ? (
|
|
101
|
-
<button
|
|
102
|
-
type="button"
|
|
103
|
-
onClick={() => dismiss(announcement.id)}
|
|
104
|
-
aria-label="Dismiss announcement"
|
|
105
|
-
className="text-current opacity-80 transition-opacity hover:opacity-100"
|
|
106
|
-
>
|
|
107
|
-
<svg
|
|
108
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
109
|
-
viewBox="0 0 24 24"
|
|
110
|
-
fill="none"
|
|
111
|
-
stroke="currentColor"
|
|
112
|
-
strokeWidth="2"
|
|
113
|
-
className="h-4 w-4"
|
|
114
|
-
aria-hidden="true"
|
|
115
|
-
>
|
|
116
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
117
|
-
</svg>
|
|
118
|
-
</button>
|
|
119
|
-
) : null}
|
|
120
|
-
</div>
|
|
121
|
-
);
|
|
122
|
-
})}
|
|
123
|
-
</div>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Storefront announcement bar — fully driven by the merchant's dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The merchant configures announcements in the Brainerce dashboard under
|
|
5
|
+
* Sell → Content → Announcement
|
|
6
|
+
*
|
|
7
|
+
* Everything below is generic rendering logic — it adapts automatically to
|
|
8
|
+
* any announcement shape returned by the API, so **you should not hardcode
|
|
9
|
+
* the message or severity here**.
|
|
10
|
+
*
|
|
11
|
+
* API contract:
|
|
12
|
+
* GET → client.content.announcement.list(locale) → Content<'ANNOUNCEMENT'>[]
|
|
13
|
+
* Empty array → renders nothing (page layout stays intact).
|
|
14
|
+
*
|
|
15
|
+
* Time-bounded: each announcement may have `startsAt` / `endsAt` ISO
|
|
16
|
+
* timestamps. We filter client-side so the cached server response can be
|
|
17
|
+
* shared across visitors regardless of the current time.
|
|
18
|
+
*/
|
|
19
|
+
'use client';
|
|
20
|
+
|
|
21
|
+
import * as React from 'react';
|
|
22
|
+
import type { Content } from 'brainerce';
|
|
23
|
+
|
|
24
|
+
interface AnnouncementBarProps {
|
|
25
|
+
/** Pre-fetched list from the server. Pass `[]` to render nothing. */
|
|
26
|
+
announcements: Content<'ANNOUNCEMENT'>[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SEVERITY_STYLES: Record<'info' | 'warning' | 'success', string> = {
|
|
30
|
+
info: 'bg-blue-600 text-white',
|
|
31
|
+
warning: 'bg-amber-500 text-amber-950',
|
|
32
|
+
success: 'bg-emerald-600 text-white',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const STORAGE_KEY = 'brainerce_dismissed_announcements';
|
|
36
|
+
|
|
37
|
+
export function AnnouncementBar({ announcements }: AnnouncementBarProps) {
|
|
38
|
+
const [dismissed, setDismissed] = React.useState<Set<string>>(() => new Set());
|
|
39
|
+
|
|
40
|
+
// Hydrate dismissals from localStorage on mount (avoids SSR/CSR mismatch).
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (typeof window === 'undefined') return;
|
|
43
|
+
try {
|
|
44
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
45
|
+
if (raw) setDismissed(new Set(JSON.parse(raw) as string[]));
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore malformed storage
|
|
48
|
+
}
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const dismiss = (id: string) => {
|
|
52
|
+
setDismissed((prev) => {
|
|
53
|
+
const next = new Set(prev);
|
|
54
|
+
next.add(id);
|
|
55
|
+
if (typeof window !== 'undefined') {
|
|
56
|
+
try {
|
|
57
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore quota errors
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return next;
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const visible = announcements.filter((a) => {
|
|
68
|
+
if (dismissed.has(a.id)) return false;
|
|
69
|
+
const startOk = !a.data.startsAt || new Date(a.data.startsAt).getTime() <= now;
|
|
70
|
+
const endOk = !a.data.endsAt || new Date(a.data.endsAt).getTime() >= now;
|
|
71
|
+
return startOk && endOk;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (visible.length === 0) return null;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div role="region" aria-label="Site announcements">
|
|
78
|
+
{visible.map((announcement) => {
|
|
79
|
+
const severity = announcement.data.severity;
|
|
80
|
+
const tone = SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.info;
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
key={announcement.id}
|
|
84
|
+
className={`flex items-center justify-center gap-3 px-4 py-2 text-sm ${tone}`}
|
|
85
|
+
>
|
|
86
|
+
<span className="flex-1 text-center">
|
|
87
|
+
{announcement.data.message}
|
|
88
|
+
{announcement.data.ctaHref && announcement.data.ctaLabel ? (
|
|
89
|
+
<>
|
|
90
|
+
{' '}
|
|
91
|
+
<a
|
|
92
|
+
href={announcement.data.ctaHref}
|
|
93
|
+
className="underline underline-offset-2"
|
|
94
|
+
>
|
|
95
|
+
{announcement.data.ctaLabel}
|
|
96
|
+
</a>
|
|
97
|
+
</>
|
|
98
|
+
) : null}
|
|
99
|
+
</span>
|
|
100
|
+
{announcement.data.dismissible ? (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => dismiss(announcement.id)}
|
|
104
|
+
aria-label="Dismiss announcement"
|
|
105
|
+
className="text-current opacity-80 transition-opacity hover:opacity-100"
|
|
106
|
+
>
|
|
107
|
+
<svg
|
|
108
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
109
|
+
viewBox="0 0 24 24"
|
|
110
|
+
fill="none"
|
|
111
|
+
stroke="currentColor"
|
|
112
|
+
strokeWidth="2"
|
|
113
|
+
className="h-4 w-4"
|
|
114
|
+
aria-hidden="true"
|
|
115
|
+
>
|
|
116
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
117
|
+
</svg>
|
|
118
|
+
</button>
|
|
119
|
+
) : null}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|