create-brainerce-store 1.39.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/layout.tsx.ejs +2 -2
- 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 -96
- package/templates/nextjs/base/src/components/content/site-header.tsx.ejs +166 -65
- 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
|
@@ -1,65 +1,166 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Merchant-controlled site header.
|
|
3
|
-
*
|
|
4
|
-
* The merchant configures the header in the Brainerce dashboard under
|
|
5
|
-
* Sell → Content → Header
|
|
6
|
-
*
|
|
7
|
-
* Renders
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Merchant-controlled site header.
|
|
3
|
+
*
|
|
4
|
+
* The merchant configures the header in the Brainerce dashboard under
|
|
5
|
+
* Sell → Content → Header
|
|
6
|
+
*
|
|
7
|
+
* Renders brand (logo or store-name fallback) + nav items + CTA + cart link,
|
|
8
|
+
* with a `<details>` mobile menu that works without client-side JS. Everything
|
|
9
|
+
* is generic — do NOT hardcode nav labels or logo paths here; the merchant
|
|
10
|
+
* edits them in the dashboard and the change propagates within ~5 minutes.
|
|
11
|
+
*
|
|
12
|
+
* If `header` is null (404 from the API, or the merchant deleted the row),
|
|
13
|
+
* we render a minimal static fallback with just the brand label and cart link
|
|
14
|
+
* so the layout never collapses. New stores ship with a seeded HEADER row
|
|
15
|
+
* from the backend (StoresService.seedDefaultContent), so the populated
|
|
16
|
+
* branch is the common case.
|
|
17
|
+
*/
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
import type { Content } from 'brainerce';
|
|
20
|
+
|
|
21
|
+
interface SiteHeaderProps {
|
|
22
|
+
/** Pre-fetched header payload (server-side). `null` triggers static fallback. */
|
|
23
|
+
header: Content<'HEADER'> | null;
|
|
24
|
+
/** Fallback brand label when the merchant hasn't uploaded a logo yet. */
|
|
25
|
+
storeName?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MenuIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
|
29
|
+
<svg
|
|
30
|
+
viewBox="0 0 24 24"
|
|
31
|
+
fill="none"
|
|
32
|
+
stroke="currentColor"
|
|
33
|
+
strokeWidth={2}
|
|
34
|
+
strokeLinecap="round"
|
|
35
|
+
strokeLinejoin="round"
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
{...props}
|
|
38
|
+
>
|
|
39
|
+
<line x1="4" y1="6" x2="20" y2="6" />
|
|
40
|
+
<line x1="4" y1="12" x2="20" y2="12" />
|
|
41
|
+
<line x1="4" y1="18" x2="20" y2="18" />
|
|
42
|
+
</svg>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const CartIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
|
46
|
+
<svg
|
|
47
|
+
viewBox="0 0 24 24"
|
|
48
|
+
fill="none"
|
|
49
|
+
stroke="currentColor"
|
|
50
|
+
strokeWidth={2}
|
|
51
|
+
strokeLinecap="round"
|
|
52
|
+
strokeLinejoin="round"
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
{...props}
|
|
55
|
+
>
|
|
56
|
+
<circle cx="9" cy="21" r="1" />
|
|
57
|
+
<circle cx="20" cy="21" r="1" />
|
|
58
|
+
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
|
59
|
+
</svg>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
63
|
+
const data = header?.data;
|
|
64
|
+
const logo = data?.logo;
|
|
65
|
+
const navItems = data?.navItems ?? [];
|
|
66
|
+
const cta = data?.cta;
|
|
67
|
+
const brandLabel = logo?.alt || storeName || 'Store';
|
|
68
|
+
|
|
69
|
+
// Static fallback — runs when the Content API returned null (no HEADER row
|
|
70
|
+
// for this store yet, or fetch failed). Keep the brand + cart so the page
|
|
71
|
+
// always has a clickable home link and entry to checkout, but no nav since
|
|
72
|
+
// the merchant hasn't defined any items.
|
|
73
|
+
if (!header) {
|
|
74
|
+
return (
|
|
75
|
+
<header className="border-border bg-background/95 supports-[backdrop-filter]:bg-background/70 sticky top-0 z-40 border-b backdrop-blur">
|
|
76
|
+
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
|
77
|
+
<a href="/" className="text-lg font-semibold tracking-tight" aria-label={brandLabel}>
|
|
78
|
+
{brandLabel}
|
|
79
|
+
</a>
|
|
80
|
+
<a
|
|
81
|
+
href="/cart"
|
|
82
|
+
aria-label="Cart"
|
|
83
|
+
className="text-foreground/80 hover:text-foreground hover:bg-muted/60 inline-flex h-9 w-9 items-center justify-center rounded-md transition-colors"
|
|
84
|
+
>
|
|
85
|
+
<CartIcon className="h-5 w-5" />
|
|
86
|
+
</a>
|
|
87
|
+
</div>
|
|
88
|
+
</header>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<header className="border-border bg-background/95 supports-[backdrop-filter]:bg-background/70 sticky top-0 z-40 border-b backdrop-blur">
|
|
94
|
+
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
|
95
|
+
<a
|
|
96
|
+
href="/"
|
|
97
|
+
className="flex items-center gap-2 font-semibold tracking-tight"
|
|
98
|
+
aria-label={brandLabel}
|
|
99
|
+
>
|
|
100
|
+
{logo ? (
|
|
101
|
+
// eslint-disable-next-line @next/next/no-img-element -- merchant-supplied URL, dynamic optimization handled by CDN
|
|
102
|
+
<img src={logo.src} alt={logo.alt} className="h-8 w-auto" />
|
|
103
|
+
) : (
|
|
104
|
+
<span className="text-lg">{brandLabel}</span>
|
|
105
|
+
)}
|
|
106
|
+
</a>
|
|
107
|
+
|
|
108
|
+
{navItems.length > 0 ? (
|
|
109
|
+
<nav className="hidden items-center gap-8 md:flex">
|
|
110
|
+
{navItems.map((item, idx) => (
|
|
111
|
+
<a
|
|
112
|
+
key={`${item.url}-${idx}`}
|
|
113
|
+
href={item.url}
|
|
114
|
+
className="text-foreground/80 hover:text-foreground text-sm font-medium transition-colors"
|
|
115
|
+
>
|
|
116
|
+
{item.label}
|
|
117
|
+
</a>
|
|
118
|
+
))}
|
|
119
|
+
</nav>
|
|
120
|
+
) : null}
|
|
121
|
+
|
|
122
|
+
<div className="flex items-center gap-2">
|
|
123
|
+
{cta ? (
|
|
124
|
+
<a
|
|
125
|
+
href={cta.url}
|
|
126
|
+
className="bg-primary text-primary-foreground hidden items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors hover:opacity-90 sm:inline-flex"
|
|
127
|
+
>
|
|
128
|
+
{cta.label}
|
|
129
|
+
</a>
|
|
130
|
+
) : null}
|
|
131
|
+
|
|
132
|
+
<a
|
|
133
|
+
href="/cart"
|
|
134
|
+
aria-label="Cart"
|
|
135
|
+
className="text-foreground/80 hover:text-foreground hover:bg-muted/60 inline-flex h-9 w-9 items-center justify-center rounded-md transition-colors"
|
|
136
|
+
>
|
|
137
|
+
<CartIcon className="h-5 w-5" />
|
|
138
|
+
</a>
|
|
139
|
+
|
|
140
|
+
{navItems.length > 0 ? (
|
|
141
|
+
<details className="group relative md:hidden">
|
|
142
|
+
<summary className="text-foreground/80 hover:text-foreground hover:bg-muted/60 inline-flex h-9 w-9 cursor-pointer list-none items-center justify-center rounded-md transition-colors [&::-webkit-details-marker]:hidden">
|
|
143
|
+
<MenuIcon className="h-5 w-5" />
|
|
144
|
+
<span className="sr-only">Menu</span>
|
|
145
|
+
</summary>
|
|
146
|
+
<nav className="border-border bg-background absolute end-0 top-full z-50 mt-2 w-56 overflow-hidden rounded-lg border shadow-lg">
|
|
147
|
+
<ul className="py-1">
|
|
148
|
+
{navItems.map((item, idx) => (
|
|
149
|
+
<li key={`m-${item.url}-${idx}`}>
|
|
150
|
+
<a
|
|
151
|
+
href={item.url}
|
|
152
|
+
className="text-foreground/80 hover:text-foreground hover:bg-muted block px-4 py-2 text-sm transition-colors"
|
|
153
|
+
>
|
|
154
|
+
{item.label}
|
|
155
|
+
</a>
|
|
156
|
+
</li>
|
|
157
|
+
))}
|
|
158
|
+
</ul>
|
|
159
|
+
</nav>
|
|
160
|
+
</details>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</header>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -34,7 +34,7 @@ export async function OrganizationJsonLd({ storeInfo, baseUrl }: OrganizationJso
|
|
|
34
34
|
// social profiles. Order doesn't matter; we just filter out empties.
|
|
35
35
|
const sameAs = storeInfo.socialLinks
|
|
36
36
|
? Object.values(storeInfo.socialLinks).filter(
|
|
37
|
-
(url): url is string => typeof url === 'string' && url.trim().length > 0
|
|
37
|
+
(url): url is string => typeof url === 'string' && url.trim().length > 0
|
|
38
38
|
)
|
|
39
39
|
: [];
|
|
40
40
|
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Single-source HTML sanitizer for merchant-authored content.
|
|
3
|
-
*
|
|
4
|
-
* Brainerce returns RICH_TEXT.html, PAGE.html, and FAQ answer values as raw
|
|
5
|
-
* HTML — the server does NOT pre-sanitize because some merchants embed
|
|
6
|
-
* iframes (e.g. YouTube). Run every merchant-authored HTML string through
|
|
7
|
-
* this wrapper before injecting via `dangerouslySetInnerHTML`.
|
|
8
|
-
*
|
|
9
|
-
* const safe = sanitizeHtml(rawHtml);
|
|
10
|
-
* <div dangerouslySetInnerHTML={{ __html: safe }} />
|
|
11
|
-
*
|
|
12
|
-
* Uses isomorphic-dompurify so the same call works in Server Components and
|
|
13
|
-
* Client Components.
|
|
14
|
-
*/
|
|
15
|
-
import DOMPurify from 'isomorphic-dompurify';
|
|
16
|
-
|
|
17
|
-
const DEFAULT_CONFIG: DOMPurify.Config = {
|
|
18
|
-
// Allow iframes for embedded videos/maps the merchant may include.
|
|
19
|
-
ADD_TAGS: ['iframe'],
|
|
20
|
-
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'target', 'rel'],
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export function sanitizeHtml(html: string | null | undefined): string {
|
|
24
|
-
if (!html) return '';
|
|
25
|
-
return DOMPurify.sanitize(html, DEFAULT_CONFIG) as unknown as string;
|
|
26
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Single-source HTML sanitizer for merchant-authored content.
|
|
3
|
+
*
|
|
4
|
+
* Brainerce returns RICH_TEXT.html, PAGE.html, and FAQ answer values as raw
|
|
5
|
+
* HTML — the server does NOT pre-sanitize because some merchants embed
|
|
6
|
+
* iframes (e.g. YouTube). Run every merchant-authored HTML string through
|
|
7
|
+
* this wrapper before injecting via `dangerouslySetInnerHTML`.
|
|
8
|
+
*
|
|
9
|
+
* const safe = sanitizeHtml(rawHtml);
|
|
10
|
+
* <div dangerouslySetInnerHTML={{ __html: safe }} />
|
|
11
|
+
*
|
|
12
|
+
* Uses isomorphic-dompurify so the same call works in Server Components and
|
|
13
|
+
* Client Components.
|
|
14
|
+
*/
|
|
15
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CONFIG: DOMPurify.Config = {
|
|
18
|
+
// Allow iframes for embedded videos/maps the merchant may include.
|
|
19
|
+
ADD_TAGS: ['iframe'],
|
|
20
|
+
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'target', 'rel'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function sanitizeHtml(html: string | null | undefined): string {
|
|
24
|
+
if (!html) return '';
|
|
25
|
+
return DOMPurify.sanitize(html, DEFAULT_CONFIG) as unknown as string;
|
|
26
|
+
}
|
|
@@ -31,10 +31,7 @@ export function stripHtmlForSeo(input: string | null | undefined): string {
|
|
|
31
31
|
// Truncates at the nearest word boundary so we never cut mid-word, and
|
|
32
32
|
// appends a Unicode ellipsis. Returns '' for null/empty input so callers
|
|
33
33
|
// can fall back cleanly.
|
|
34
|
-
export function buildMetaDescription(
|
|
35
|
-
input: string | null | undefined,
|
|
36
|
-
maxLength = 160
|
|
37
|
-
): string {
|
|
34
|
+
export function buildMetaDescription(input: string | null | undefined, maxLength = 160): string {
|
|
38
35
|
const stripped = stripHtmlForSeo(input);
|
|
39
36
|
if (!stripped) return '';
|
|
40
37
|
if (stripped.length <= maxLength) return stripped;
|