create-brainerce-store 1.40.0 → 1.41.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 +7 -5
- package/messages/en.json +6 -0
- package/messages/he.json +6 -0
- package/package.json +1 -1
- 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 +87 -87
- 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/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.41.0",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
|
@@ -153,10 +153,12 @@ var ALLOWED_PACKAGE_MANAGERS = [
|
|
|
153
153
|
"bun"
|
|
154
154
|
];
|
|
155
155
|
var BRAINERCE_RUNTIME_DEPS = Object.freeze({
|
|
156
|
-
// 1.
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
156
|
+
// 1.27 = first published cut with client.content namespace (FAQ / Footer /
|
|
157
|
+
// Header / Announcement / RichText / Page) + getDirectionForLocale helper.
|
|
158
|
+
// Scaffolded stores need this minimum to use the content components shipped
|
|
159
|
+
// under src/components/content/ and the homepage RichTextBlock mount-point.
|
|
160
|
+
// 1.26 had the namespace in source but was published before the cut.
|
|
161
|
+
brainerce: "^1.27.0",
|
|
160
162
|
"isomorphic-dompurify": "^3.8.0"
|
|
161
163
|
});
|
|
162
164
|
|
package/messages/en.json
CHANGED
|
@@ -431,5 +431,11 @@
|
|
|
431
431
|
"thanksBody": "Your message has been received. We'll reply by email as soon as possible.",
|
|
432
432
|
"genericError": "Something went wrong. Please try again.",
|
|
433
433
|
"fieldRequired": "{label} is required"
|
|
434
|
+
},
|
|
435
|
+
"content": {
|
|
436
|
+
"faqTitle": "Frequently Asked Questions",
|
|
437
|
+
"faqEmptyTitle": "FAQ coming soon",
|
|
438
|
+
"faqEmptyBody": "We're still putting together answers to common questions. In the meantime, feel free to reach out.",
|
|
439
|
+
"faqContactCta": "Contact us"
|
|
434
440
|
}
|
|
435
441
|
}
|
package/messages/he.json
CHANGED
|
@@ -431,5 +431,11 @@
|
|
|
431
431
|
"thanksBody": "ההודעה התקבלה. נחזור אליך באימייל בהקדם האפשרי.",
|
|
432
432
|
"genericError": "משהו השתבש. אנא נסה שוב.",
|
|
433
433
|
"fieldRequired": "השדה {label} הוא חובה"
|
|
434
|
+
},
|
|
435
|
+
"content": {
|
|
436
|
+
"faqTitle": "שאלות נפוצות",
|
|
437
|
+
"faqEmptyTitle": "השאלות הנפוצות בדרך",
|
|
438
|
+
"faqEmptyBody": "אנחנו עוד אוספים את התשובות לשאלות הנפוצות. בינתיים, אתם מוזמנים לפנות אלינו.",
|
|
439
|
+
"faqContactCta": "צרו קשר"
|
|
434
440
|
}
|
|
435
441
|
}
|
package/package.json
CHANGED
|
@@ -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,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
|
-
|
|
21
|
-
<% if (i18nEnabled) { %>
|
|
22
|
-
type PageProps = {
|
|
23
|
-
params: Promise<{ locale: string; slug: string }>;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
27
|
-
const { locale, slug } = await params;
|
|
28
|
-
const page = await getServerClient(locale).content.page.getBySlug(slug, locale).catch(() => null);
|
|
29
|
-
if (!page) return { title: slug };
|
|
30
|
-
const seo = page.data.seo ?? {};
|
|
31
|
-
return {
|
|
32
|
-
title: seo.title ?? page.data.title,
|
|
33
|
-
description: seo.description,
|
|
34
|
-
openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export default async function StaticPage({ params }: PageProps) {
|
|
39
|
-
const { locale, slug } = await params;
|
|
40
|
-
const page = await getServerClient(locale).content.page.getBySlug(slug, locale).catch(() => null);
|
|
41
|
-
if (!page) notFound();
|
|
42
|
-
return (
|
|
43
|
-
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
44
|
-
<h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
|
|
45
|
-
{page.data.title}
|
|
46
|
-
</h1>
|
|
47
|
-
<div
|
|
48
|
-
className="prose prose-sm dark:prose-invert max-w-none"
|
|
49
|
-
dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
|
|
50
|
-
/>
|
|
51
|
-
</article>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
<% } else { %>
|
|
55
|
-
type PageProps = {
|
|
56
|
-
params: Promise<{ slug: string }>;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
60
|
-
const { slug } = await params;
|
|
61
|
-
const page = await getServerClient().content.page.getBySlug(slug).catch(() => null);
|
|
62
|
-
if (!page) return { title: slug };
|
|
63
|
-
const seo = page.data.seo ?? {};
|
|
64
|
-
return {
|
|
65
|
-
title: seo.title ?? page.data.title,
|
|
66
|
-
description: seo.description,
|
|
67
|
-
openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export default async function StaticPage({ params }: PageProps) {
|
|
72
|
-
const { slug } = await params;
|
|
73
|
-
const page = await getServerClient().content.page.getBySlug(slug).catch(() => null);
|
|
74
|
-
if (!page) notFound();
|
|
75
|
-
return (
|
|
76
|
-
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
77
|
-
<h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
|
|
78
|
-
{page.data.title}
|
|
79
|
-
</h1>
|
|
80
|
-
<div
|
|
81
|
-
className="prose prose-sm dark:prose-invert max-w-none"
|
|
82
|
-
dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
|
|
83
|
-
/>
|
|
84
|
-
</article>
|
|
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
|
+
|
|
21
|
+
<% if (i18nEnabled) { %>
|
|
22
|
+
type PageProps = {
|
|
23
|
+
params: Promise<{ locale: string; slug: string }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
27
|
+
const { locale, slug } = await params;
|
|
28
|
+
const page = await getServerClient(locale).content.page.getBySlug(slug, locale).catch(() => null);
|
|
29
|
+
if (!page) return { title: slug };
|
|
30
|
+
const seo = page.data.seo ?? {};
|
|
31
|
+
return {
|
|
32
|
+
title: seo.title ?? page.data.title,
|
|
33
|
+
description: seo.description,
|
|
34
|
+
openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default async function StaticPage({ params }: PageProps) {
|
|
39
|
+
const { locale, slug } = await params;
|
|
40
|
+
const page = await getServerClient(locale).content.page.getBySlug(slug, locale).catch(() => null);
|
|
41
|
+
if (!page) notFound();
|
|
42
|
+
return (
|
|
43
|
+
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
44
|
+
<h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
|
|
45
|
+
{page.data.title}
|
|
46
|
+
</h1>
|
|
47
|
+
<div
|
|
48
|
+
className="prose prose-sm dark:prose-invert max-w-none"
|
|
49
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
|
|
50
|
+
/>
|
|
51
|
+
</article>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
<% } else { %>
|
|
55
|
+
type PageProps = {
|
|
56
|
+
params: Promise<{ slug: string }>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
60
|
+
const { slug } = await params;
|
|
61
|
+
const page = await getServerClient().content.page.getBySlug(slug).catch(() => null);
|
|
62
|
+
if (!page) return { title: slug };
|
|
63
|
+
const seo = page.data.seo ?? {};
|
|
64
|
+
return {
|
|
65
|
+
title: seo.title ?? page.data.title,
|
|
66
|
+
description: seo.description,
|
|
67
|
+
openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default async function StaticPage({ params }: PageProps) {
|
|
72
|
+
const { slug } = await params;
|
|
73
|
+
const page = await getServerClient().content.page.getBySlug(slug).catch(() => null);
|
|
74
|
+
if (!page) notFound();
|
|
75
|
+
return (
|
|
76
|
+
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
77
|
+
<h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
|
|
78
|
+
{page.data.title}
|
|
79
|
+
</h1>
|
|
80
|
+
<div
|
|
81
|
+
className="prose prose-sm dark:prose-invert max-w-none"
|
|
82
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
|
|
83
|
+
/>
|
|
84
|
+
</article>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
<% } %>
|
|
@@ -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
|
+
}
|