create-brainerce-store 1.37.0 → 1.39.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 +8 -3
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +46 -0
- package/templates/nextjs/base/src/app/home-client.tsx +98 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +37 -8
- package/templates/nextjs/base/src/app/page.tsx +53 -98
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +87 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +5 -3
- package/templates/nextjs/base/src/components/content/announcement-bar.tsx.ejs +125 -0
- package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +75 -0
- package/templates/nextjs/base/src/components/content/rich-text-block.tsx.ejs +32 -0
- package/templates/nextjs/base/src/components/content/site-footer.tsx.ejs +96 -0
- package/templates/nextjs/base/src/components/content/site-header.tsx.ejs +65 -0
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +94 -0
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +6 -1
- package/templates/nextjs/base/src/i18n.ts.ejs +8 -3
- package/templates/nextjs/base/src/lib/sanitize.ts.ejs +26 -0
- package/templates/nextjs/base/src/lib/seo.ts +49 -0
- package/templates/nextjs/base/src/components/layout/footer.tsx +0 -47
- package/templates/nextjs/base/src/components/layout/header.tsx +0 -335
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FAQ accordion — 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
|
+
* Each FAQ row has a `key` (e.g. 'main', 'shipping', 'returns'). Pass the
|
|
8
|
+
* pre-fetched payload as `faq`; this component knows nothing about which
|
|
9
|
+
* key it came from.
|
|
10
|
+
*
|
|
11
|
+
* Security: FAQ answers may contain merchant-authored HTML — we sanitize
|
|
12
|
+
* via `sanitizeHtml` before injecting via dangerouslySetInnerHTML.
|
|
13
|
+
*/
|
|
14
|
+
'use client';
|
|
15
|
+
|
|
16
|
+
import * as React from 'react';
|
|
17
|
+
import type { Content } from 'brainerce';
|
|
18
|
+
import { sanitizeHtml } from '@/lib/sanitize';
|
|
19
|
+
|
|
20
|
+
interface FaqSectionProps {
|
|
21
|
+
/** Pre-fetched FAQ row. `null` triggers a minimal empty-state message. */
|
|
22
|
+
faq: Content<'FAQ'> | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function FaqSection({ faq }: FaqSectionProps) {
|
|
26
|
+
const [openIdx, setOpenIdx] = React.useState<number | null>(0);
|
|
27
|
+
|
|
28
|
+
if (!faq || !faq.data.items?.length) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
34
|
+
<h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">{faq.name}</h1>
|
|
35
|
+
<ul className="border-border divide-border divide-y rounded-lg border">
|
|
36
|
+
{faq.data.items.map((item, idx) => {
|
|
37
|
+
const open = openIdx === idx;
|
|
38
|
+
return (
|
|
39
|
+
<li key={idx}>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={() => setOpenIdx(open ? null : idx)}
|
|
43
|
+
aria-expanded={open}
|
|
44
|
+
className="hover:bg-muted/40 flex w-full items-center justify-between gap-4 p-4 text-start transition-colors"
|
|
45
|
+
>
|
|
46
|
+
<span className="text-foreground text-sm font-medium sm:text-base">
|
|
47
|
+
{item.question}
|
|
48
|
+
</span>
|
|
49
|
+
<svg
|
|
50
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
51
|
+
viewBox="0 0 24 24"
|
|
52
|
+
fill="none"
|
|
53
|
+
stroke="currentColor"
|
|
54
|
+
strokeWidth="2"
|
|
55
|
+
className={`text-muted-foreground h-4 w-4 transition-transform ${
|
|
56
|
+
open ? 'rotate-180' : ''
|
|
57
|
+
}`}
|
|
58
|
+
aria-hidden="true"
|
|
59
|
+
>
|
|
60
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
61
|
+
</svg>
|
|
62
|
+
</button>
|
|
63
|
+
{open ? (
|
|
64
|
+
<div
|
|
65
|
+
className="text-muted-foreground prose prose-sm dark:prose-invert max-w-none px-4 pb-4 text-sm leading-relaxed"
|
|
66
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(item.answer) }}
|
|
67
|
+
/>
|
|
68
|
+
) : null}
|
|
69
|
+
</li>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</ul>
|
|
73
|
+
</section>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline rich-text block — fully driven by the merchant's dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The merchant configures rich-text blocks in the Brainerce dashboard
|
|
5
|
+
* under Sell → Content → Rich Text. Use this component anywhere on a page
|
|
6
|
+
* to drop a merchant-editable HTML block (intro copy, banner descriptions,
|
|
7
|
+
* inline announcements that aren't tied to the top-of-page Announcement
|
|
8
|
+
* bar, etc.).
|
|
9
|
+
*
|
|
10
|
+
* Security: HTML is sanitized via `sanitizeHtml` (isomorphic-dompurify)
|
|
11
|
+
* before injecting via dangerouslySetInnerHTML.
|
|
12
|
+
*/
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
import type { Content } from 'brainerce';
|
|
15
|
+
import { sanitizeHtml } from '@/lib/sanitize';
|
|
16
|
+
|
|
17
|
+
interface RichTextBlockProps {
|
|
18
|
+
/** Pre-fetched RICH_TEXT row, e.g. via `client.content.richText.get(key, locale)`. */
|
|
19
|
+
block: Content<'RICH_TEXT'> | null;
|
|
20
|
+
/** Optional wrapper className for layout positioning. */
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function RichTextBlock({ block, className }: RichTextBlockProps) {
|
|
25
|
+
if (!block?.data?.html) return null;
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className={className ?? 'prose prose-sm dark:prose-invert max-w-none'}
|
|
29
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(block.data.html) }}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storefront footer — fully driven by the merchant's dashboard configuration.
|
|
3
|
+
*
|
|
4
|
+
* The merchant configures the footer in the Brainerce dashboard under
|
|
5
|
+
* Sell → Content → Footer
|
|
6
|
+
*
|
|
7
|
+
* Everything below is generic rendering logic — it adapts automatically to
|
|
8
|
+
* any footer shape returned by the API, so **you should not hardcode column
|
|
9
|
+
* titles or link labels here**.
|
|
10
|
+
*
|
|
11
|
+
* API contract:
|
|
12
|
+
* GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
|
|
13
|
+
* Returns null on 404 — we render a minimal fallback so the layout never
|
|
14
|
+
* crashes when the merchant hasn't seeded the footer yet.
|
|
15
|
+
*
|
|
16
|
+
* Security: this component renders text only. For RichText/Page HTML, use
|
|
17
|
+
* <RichTextBlock> which sanitizes via isomorphic-dompurify.
|
|
18
|
+
*/
|
|
19
|
+
import * as React from 'react';
|
|
20
|
+
import type { Content } from 'brainerce';
|
|
21
|
+
|
|
22
|
+
interface SiteFooterProps {
|
|
23
|
+
/** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
|
|
24
|
+
footer: Content<'FOOTER'> | null;
|
|
25
|
+
/** Store name shown in the fallback copyright when no footer is configured. */
|
|
26
|
+
storeName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
30
|
+
const data = footer?.data;
|
|
31
|
+
const columns = data?.columns ?? [];
|
|
32
|
+
const social = data?.social ?? [];
|
|
33
|
+
|
|
34
|
+
// Fallback when the merchant hasn't seeded a footer yet — keeps the layout
|
|
35
|
+
// visually complete without exposing "missing content" to shoppers.
|
|
36
|
+
if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
|
|
37
|
+
const year = new Date().getFullYear();
|
|
38
|
+
return (
|
|
39
|
+
<footer className="border-border bg-muted/30 text-muted-foreground mt-12 border-t py-8 text-sm">
|
|
40
|
+
<div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
|
|
41
|
+
© {year} {storeName ?? 'Store'}
|
|
42
|
+
</div>
|
|
43
|
+
</footer>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<footer className="border-border bg-muted/30 text-foreground mt-12 border-t">
|
|
49
|
+
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
|
|
50
|
+
{columns.length > 0 ? (
|
|
51
|
+
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
52
|
+
{columns.map((col, idx) => (
|
|
53
|
+
<div key={`${col.title}-${idx}`}>
|
|
54
|
+
<h3 className="text-foreground mb-3 text-sm font-semibold">{col.title}</h3>
|
|
55
|
+
<ul className="space-y-2">
|
|
56
|
+
{col.links.map((link, linkIdx) => (
|
|
57
|
+
<li key={`${link.url}-${linkIdx}`}>
|
|
58
|
+
<a
|
|
59
|
+
href={link.url}
|
|
60
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
61
|
+
>
|
|
62
|
+
{link.label}
|
|
63
|
+
</a>
|
|
64
|
+
</li>
|
|
65
|
+
))}
|
|
66
|
+
</ul>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
) : null}
|
|
71
|
+
|
|
72
|
+
{social.length > 0 ? (
|
|
73
|
+
<div className="border-border mt-8 flex flex-wrap items-center gap-4 border-t pt-6">
|
|
74
|
+
{social.map((entry, idx) => (
|
|
75
|
+
<a
|
|
76
|
+
key={`${entry.platform}-${idx}`}
|
|
77
|
+
href={entry.url}
|
|
78
|
+
target="_blank"
|
|
79
|
+
rel="noopener noreferrer"
|
|
80
|
+
className="text-muted-foreground hover:text-foreground text-sm capitalize transition-colors"
|
|
81
|
+
>
|
|
82
|
+
{entry.platform}
|
|
83
|
+
</a>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
) : null}
|
|
87
|
+
|
|
88
|
+
{data.copyright ? (
|
|
89
|
+
<div className="border-border text-muted-foreground mt-6 border-t pt-4 text-center text-xs">
|
|
90
|
+
{data.copyright}
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
</div>
|
|
94
|
+
</footer>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,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 logo (when set) + nav items + CTA. Everything is generic — you
|
|
8
|
+
* should NOT hardcode nav labels or logo paths here; the merchant edits
|
|
9
|
+
* them in the dashboard and the change propagates within ~5 minutes.
|
|
10
|
+
*
|
|
11
|
+
* Returns null on 404 — new stores are seeded with a default HEADER row
|
|
12
|
+
* by the backend (StoresService.seedDefaultContent) so this should be
|
|
13
|
+
* populated out of the box.
|
|
14
|
+
*/
|
|
15
|
+
import * as React from 'react';
|
|
16
|
+
import type { Content } from 'brainerce';
|
|
17
|
+
|
|
18
|
+
interface SiteHeaderProps {
|
|
19
|
+
/** Pre-fetched header payload (server-side). `null` renders nothing. */
|
|
20
|
+
header: Content<'HEADER'> | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SiteHeader({ header }: SiteHeaderProps) {
|
|
24
|
+
if (!header) return null;
|
|
25
|
+
const data = header.data;
|
|
26
|
+
const logo = data.logo;
|
|
27
|
+
const navItems = data.navItems ?? [];
|
|
28
|
+
const cta = data.cta;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="border-border bg-background border-b">
|
|
32
|
+
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
|
33
|
+
<a href="/" className="flex items-center gap-2">
|
|
34
|
+
{logo ? (
|
|
35
|
+
// eslint-disable-next-line @next/next/no-img-element -- merchant-supplied URL, dynamic optimization handled by CDN
|
|
36
|
+
<img src={logo.src} alt={logo.alt} className="h-8 w-auto" />
|
|
37
|
+
) : null}
|
|
38
|
+
</a>
|
|
39
|
+
|
|
40
|
+
{navItems.length > 0 ? (
|
|
41
|
+
<nav className="hidden items-center gap-6 md:flex">
|
|
42
|
+
{navItems.map((item, idx) => (
|
|
43
|
+
<a
|
|
44
|
+
key={`${item.url}-${idx}`}
|
|
45
|
+
href={item.url}
|
|
46
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
47
|
+
>
|
|
48
|
+
{item.label}
|
|
49
|
+
</a>
|
|
50
|
+
))}
|
|
51
|
+
</nav>
|
|
52
|
+
) : null}
|
|
53
|
+
|
|
54
|
+
{cta ? (
|
|
55
|
+
<a
|
|
56
|
+
href={cta.url}
|
|
57
|
+
className="bg-primary text-primary-foreground inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors hover:opacity-90"
|
|
58
|
+
>
|
|
59
|
+
{cta.label}
|
|
60
|
+
</a>
|
|
61
|
+
) : null}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { StoreInfo } from 'brainerce';
|
|
2
|
+
import { getNonce } from '@/lib/nonce';
|
|
3
|
+
import { stripHtmlForSeo } from '@/lib/seo';
|
|
4
|
+
|
|
5
|
+
interface OrganizationJsonLdProps {
|
|
6
|
+
storeInfo: StoreInfo;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Emits Organization + WebSite JSON-LD on the storefront homepage.
|
|
11
|
+
//
|
|
12
|
+
// Why these two specifically:
|
|
13
|
+
// - Organization → feeds the Google Knowledge Panel (store name, logo,
|
|
14
|
+
// description) and is the primary signal AI Overviews / ChatGPT /
|
|
15
|
+
// Perplexity ingest when citing the store.
|
|
16
|
+
// - WebSite → enables Sitelinks Searchbox (Google) and tells crawlers
|
|
17
|
+
// the canonical site URL. The `potentialAction` block is what makes
|
|
18
|
+
// the search box appear in branded SERP results.
|
|
19
|
+
export async function OrganizationJsonLd({ storeInfo, baseUrl }: OrganizationJsonLdProps) {
|
|
20
|
+
const nonce = await getNonce();
|
|
21
|
+
|
|
22
|
+
const cleanDescription = stripHtmlForSeo(storeInfo.metaDescription ?? undefined);
|
|
23
|
+
const logo =
|
|
24
|
+
typeof storeInfo.logo === 'string' && storeInfo.logo.trim() ? storeInfo.logo : undefined;
|
|
25
|
+
const contactEmail =
|
|
26
|
+
typeof storeInfo.contactEmail === 'string' && storeInfo.contactEmail.trim()
|
|
27
|
+
? storeInfo.contactEmail.trim()
|
|
28
|
+
: undefined;
|
|
29
|
+
const contactPhone =
|
|
30
|
+
typeof storeInfo.contactPhone === 'string' && storeInfo.contactPhone.trim()
|
|
31
|
+
? storeInfo.contactPhone.trim()
|
|
32
|
+
: undefined;
|
|
33
|
+
// sameAs is the JSON-LD property Google uses to associate a brand with its
|
|
34
|
+
// social profiles. Order doesn't matter; we just filter out empties.
|
|
35
|
+
const sameAs = storeInfo.socialLinks
|
|
36
|
+
? Object.values(storeInfo.socialLinks).filter(
|
|
37
|
+
(url): url is string => typeof url === 'string' && url.trim().length > 0,
|
|
38
|
+
)
|
|
39
|
+
: [];
|
|
40
|
+
|
|
41
|
+
const organizationJsonLd: Record<string, unknown> = {
|
|
42
|
+
'@context': 'https://schema.org',
|
|
43
|
+
'@type': 'Organization',
|
|
44
|
+
name: storeInfo.name,
|
|
45
|
+
url: baseUrl,
|
|
46
|
+
...(logo ? { logo } : {}),
|
|
47
|
+
...(cleanDescription ? { description: cleanDescription } : {}),
|
|
48
|
+
...(contactEmail ? { email: contactEmail } : {}),
|
|
49
|
+
...(contactPhone ? { telephone: contactPhone } : {}),
|
|
50
|
+
...(sameAs.length > 0 ? { sameAs } : {}),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const websiteJsonLd: Record<string, unknown> = {
|
|
54
|
+
'@context': 'https://schema.org',
|
|
55
|
+
'@type': 'WebSite',
|
|
56
|
+
name: storeInfo.name,
|
|
57
|
+
url: baseUrl,
|
|
58
|
+
potentialAction: {
|
|
59
|
+
'@type': 'SearchAction',
|
|
60
|
+
target: {
|
|
61
|
+
'@type': 'EntryPoint',
|
|
62
|
+
urlTemplate: `${baseUrl}/products?q={search_term_string}`,
|
|
63
|
+
},
|
|
64
|
+
'query-input': 'required name=search_term_string',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<script
|
|
71
|
+
type="application/ld+json"
|
|
72
|
+
nonce={nonce}
|
|
73
|
+
suppressHydrationWarning
|
|
74
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(organizationJsonLd) }}
|
|
75
|
+
/>
|
|
76
|
+
<script
|
|
77
|
+
type="application/ld+json"
|
|
78
|
+
nonce={nonce}
|
|
79
|
+
suppressHydrationWarning
|
|
80
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(websiteJsonLd) }}
|
|
81
|
+
/>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Escape `<`, `>`, `&` to \uXXXX so seller-controlled fields can't break out
|
|
87
|
+
// of the <script> with `</script>` or inject HTML. Same approach as
|
|
88
|
+
// product-json-ld.tsx — keep the two in sync.
|
|
89
|
+
function serializeJsonLd(value: unknown): string {
|
|
90
|
+
return JSON.stringify(value)
|
|
91
|
+
.replace(/</g, '\\u003c')
|
|
92
|
+
.replace(/>/g, '\\u003e')
|
|
93
|
+
.replace(/&/g, '\\u0026');
|
|
94
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Product } from 'brainerce';
|
|
2
2
|
import { getProductPriceInfo } from 'brainerce';
|
|
3
3
|
import { getNonce } from '@/lib/nonce';
|
|
4
|
+
import { stripHtmlForSeo } from '@/lib/seo';
|
|
4
5
|
|
|
5
6
|
interface ProductJsonLdProps {
|
|
6
7
|
product: Product;
|
|
@@ -39,11 +40,15 @@ export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJ
|
|
|
39
40
|
url,
|
|
40
41
|
};
|
|
41
42
|
|
|
43
|
+
// schema.org/Product.description must be plain text — Google's Rich Results
|
|
44
|
+
// and AI Overviews ingest this field directly and reject markup. Strip HTML
|
|
45
|
+
// first; if the description is empty after stripping, fall back to the name.
|
|
46
|
+
const cleanDescription = stripHtmlForSeo(product.description) || product.name;
|
|
42
47
|
const productJsonLd: Record<string, unknown> = {
|
|
43
48
|
'@context': 'https://schema.org',
|
|
44
49
|
'@type': 'Product',
|
|
45
50
|
name: product.name,
|
|
46
|
-
description:
|
|
51
|
+
description: cleanDescription,
|
|
47
52
|
image: imageUrl,
|
|
48
53
|
url,
|
|
49
54
|
sku: product.sku || product.id,
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
<% if (i18nEnabled) { %>
|
|
2
2
|
// Multi-language store — locales loaded dynamically
|
|
3
|
+
import { getDirectionForLocale } from 'brainerce';
|
|
4
|
+
|
|
3
5
|
export const defaultLocale = '<%= defaultLocale %>';
|
|
4
6
|
export const supportedLocales = <%- supportedLocales %> as const;
|
|
5
7
|
export type Locale = (typeof supportedLocales)[number];
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Resolve script direction. Delegates to the SDK so the storefront stays in
|
|
11
|
+
* sync as Brainerce adds support for new RTL locales — never maintain a
|
|
12
|
+
* local RTL locale set here.
|
|
13
|
+
*/
|
|
9
14
|
export function getDirection(locale: string): 'ltr' | 'rtl' {
|
|
10
|
-
return
|
|
15
|
+
return getDirectionForLocale(locale);
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export async function getMessages(locale: string): Promise<Record<string, Record<string, string>>> {
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SEO helpers for meta tags and JSON-LD. Strips HTML and decodes common
|
|
2
|
+
// entities so seller-authored rich-text descriptions don't leak markup into
|
|
3
|
+
// <meta name="description">, og:description, twitter:description, or
|
|
4
|
+
// schema.org Product.description.
|
|
5
|
+
|
|
6
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
7
|
+
nbsp: ' ',
|
|
8
|
+
amp: '&',
|
|
9
|
+
lt: '<',
|
|
10
|
+
gt: '>',
|
|
11
|
+
quot: '"',
|
|
12
|
+
apos: "'",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function decodeEntities(text: string): string {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/&(nbsp|amp|lt|gt|quot|apos);/g, (_, name: string) => NAMED_ENTITIES[name] ?? '')
|
|
18
|
+
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
|
19
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Strip HTML tags, decode entities, collapse whitespace. Idempotent on plain text.
|
|
23
|
+
export function stripHtmlForSeo(input: string | null | undefined): string {
|
|
24
|
+
if (!input) return '';
|
|
25
|
+
return decodeEntities(input.replace(/<[^>]*>/g, ' '))
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build a meta description from raw HTML/text.
|
|
31
|
+
// Truncates at the nearest word boundary so we never cut mid-word, and
|
|
32
|
+
// appends a Unicode ellipsis. Returns '' for null/empty input so callers
|
|
33
|
+
// can fall back cleanly.
|
|
34
|
+
export function buildMetaDescription(
|
|
35
|
+
input: string | null | undefined,
|
|
36
|
+
maxLength = 160
|
|
37
|
+
): string {
|
|
38
|
+
const stripped = stripHtmlForSeo(input);
|
|
39
|
+
if (!stripped) return '';
|
|
40
|
+
if (stripped.length <= maxLength) return stripped;
|
|
41
|
+
|
|
42
|
+
const room = maxLength - 1; // leave room for ellipsis
|
|
43
|
+
const cut = stripped.slice(0, room);
|
|
44
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
45
|
+
// Only break on word boundary when it's reasonably close to the cap — otherwise
|
|
46
|
+
// a single very long word would shrink the description to almost nothing.
|
|
47
|
+
const safeCut = lastSpace > room * 0.7 ? cut.slice(0, lastSpace) : cut;
|
|
48
|
+
return `${safeCut.trim()}…`;
|
|
49
|
+
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Link } from '@/lib/navigation';
|
|
4
|
-
import { useTranslations } from '@/lib/translations';
|
|
5
|
-
import { useStoreInfo, useAuth } from '@/providers/store-provider';
|
|
6
|
-
|
|
7
|
-
export function Footer() {
|
|
8
|
-
const t = useTranslations('common');
|
|
9
|
-
const tn = useTranslations('nav');
|
|
10
|
-
const { storeInfo } = useStoreInfo();
|
|
11
|
-
const { isLoggedIn } = useAuth();
|
|
12
|
-
const year = new Date().getFullYear();
|
|
13
|
-
|
|
14
|
-
return (
|
|
15
|
-
<footer className="border-border bg-background border-t">
|
|
16
|
-
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
17
|
-
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
|
18
|
-
<p className="text-muted-foreground text-sm">
|
|
19
|
-
{year} {storeInfo?.name || t('store')}. {t('allRightsReserved')}
|
|
20
|
-
</p>
|
|
21
|
-
<nav className="flex items-center gap-4">
|
|
22
|
-
<Link
|
|
23
|
-
href="/products"
|
|
24
|
-
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
25
|
-
>
|
|
26
|
-
{tn('products')}
|
|
27
|
-
</Link>
|
|
28
|
-
<Link
|
|
29
|
-
href="/contact"
|
|
30
|
-
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
31
|
-
>
|
|
32
|
-
{tn('contact')}
|
|
33
|
-
</Link>
|
|
34
|
-
{isLoggedIn && (
|
|
35
|
-
<Link
|
|
36
|
-
href="/account"
|
|
37
|
-
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
38
|
-
>
|
|
39
|
-
{tn('account')}
|
|
40
|
-
</Link>
|
|
41
|
-
)}
|
|
42
|
-
</nav>
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
|
45
|
-
</footer>
|
|
46
|
-
);
|
|
47
|
-
}
|