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
|
@@ -1,75 +1,103 @@
|
|
|
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
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
* Two render branches:
|
|
12
|
+
* 1. `faq` has items → accordion of question/answer pairs
|
|
13
|
+
* 2. `faq` is null OR has no items → friendly empty state with a contact
|
|
14
|
+
* CTA, so the /faq page never looks broken before the merchant seeds.
|
|
15
|
+
*
|
|
16
|
+
* Security: FAQ answers may contain merchant-authored HTML — we sanitize
|
|
17
|
+
* via `sanitizeHtml` before injecting via dangerouslySetInnerHTML.
|
|
18
|
+
*/
|
|
19
|
+
'use client';
|
|
20
|
+
|
|
21
|
+
import * as React from 'react';
|
|
22
|
+
import type { Content } from 'brainerce';
|
|
23
|
+
import { useTranslations } from '@/lib/translations';
|
|
24
|
+
import { sanitizeHtml } from '@/lib/sanitize';
|
|
25
|
+
|
|
26
|
+
interface FaqSectionProps {
|
|
27
|
+
/** Pre-fetched FAQ row. `null` renders the empty-state fallback. */
|
|
28
|
+
faq: Content<'FAQ'> | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function FaqSection({ faq }: FaqSectionProps) {
|
|
32
|
+
const t = useTranslations('content');
|
|
33
|
+
const [openIdx, setOpenIdx] = React.useState<number | null>(0);
|
|
34
|
+
|
|
35
|
+
const items = faq?.data?.items ?? [];
|
|
36
|
+
const heading = faq?.name || t('faqTitle');
|
|
37
|
+
|
|
38
|
+
// Empty state — runs when the merchant hasn't created a FAQ row yet,
|
|
39
|
+
// or the row exists but contains no items. Keep the page non-empty and
|
|
40
|
+
// give the visitor a path to contact instead of a blank screen.
|
|
41
|
+
if (!faq || items.length === 0) {
|
|
42
|
+
return (
|
|
43
|
+
<section className="mx-auto max-w-3xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
44
|
+
<h1 className="text-foreground mb-3 text-2xl font-semibold sm:text-3xl">{heading}</h1>
|
|
45
|
+
<p className="text-muted-foreground mb-6 text-sm sm:text-base">
|
|
46
|
+
<strong className="text-foreground">{t('faqEmptyTitle')}</strong>
|
|
47
|
+
<br />
|
|
48
|
+
{t('faqEmptyBody')}
|
|
49
|
+
</p>
|
|
50
|
+
<a
|
|
51
|
+
href="/contact"
|
|
52
|
+
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"
|
|
53
|
+
>
|
|
54
|
+
{t('faqContactCta')}
|
|
55
|
+
</a>
|
|
56
|
+
</section>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<section className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
62
|
+
<h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">{heading}</h1>
|
|
63
|
+
<ul className="border-border divide-border divide-y rounded-lg border">
|
|
64
|
+
{items.map((item, idx) => {
|
|
65
|
+
const open = openIdx === idx;
|
|
66
|
+
return (
|
|
67
|
+
<li key={idx}>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => setOpenIdx(open ? null : idx)}
|
|
71
|
+
aria-expanded={open}
|
|
72
|
+
className="hover:bg-muted/40 flex w-full items-center justify-between gap-4 p-4 text-start transition-colors"
|
|
73
|
+
>
|
|
74
|
+
<span className="text-foreground text-sm font-medium sm:text-base">
|
|
75
|
+
{item.question}
|
|
76
|
+
</span>
|
|
77
|
+
<svg
|
|
78
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
79
|
+
viewBox="0 0 24 24"
|
|
80
|
+
fill="none"
|
|
81
|
+
stroke="currentColor"
|
|
82
|
+
strokeWidth="2"
|
|
83
|
+
className={`text-muted-foreground h-4 w-4 transition-transform ${
|
|
84
|
+
open ? 'rotate-180' : ''
|
|
85
|
+
}`}
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
>
|
|
88
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
89
|
+
</svg>
|
|
90
|
+
</button>
|
|
91
|
+
{open ? (
|
|
92
|
+
<div
|
|
93
|
+
className="text-muted-foreground prose prose-sm dark:prose-invert max-w-none px-4 pb-4 text-sm leading-relaxed"
|
|
94
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(item.answer) }}
|
|
95
|
+
/>
|
|
96
|
+
) : null}
|
|
97
|
+
</li>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</ul>
|
|
101
|
+
</section>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -1,32 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,164 +1,164 @@
|
|
|
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
|
-
* Renders a brand block (store-name + social icons), the merchant's link
|
|
8
|
-
* columns, and a copyright bar. Social icons are inline SVGs so the
|
|
9
|
-
* scaffold stays dependency-free; unknown platforms fall back to a
|
|
10
|
-
* letter avatar.
|
|
11
|
-
*
|
|
12
|
-
* API contract:
|
|
13
|
-
* GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
|
|
14
|
-
* Returns null on 404 — we render a minimal fallback so the layout never
|
|
15
|
-
* crashes when the merchant hasn't seeded the footer yet.
|
|
16
|
-
*/
|
|
17
|
-
import * as React from 'react';
|
|
18
|
-
import type { Content } from 'brainerce';
|
|
19
|
-
|
|
20
|
-
interface SiteFooterProps {
|
|
21
|
-
/** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
|
|
22
|
-
footer: Content<'FOOTER'> | null;
|
|
23
|
-
/** Store name shown in the brand block + fallback copyright. */
|
|
24
|
-
storeName?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
type SvgComponent = React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
28
|
-
|
|
29
|
-
const InstagramIcon: SvgComponent = (props) => (
|
|
30
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
|
|
31
|
-
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
|
|
32
|
-
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
|
|
33
|
-
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
|
|
34
|
-
</svg>
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
const FacebookIcon: SvgComponent = (props) => (
|
|
38
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
|
|
39
|
-
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
|
|
40
|
-
</svg>
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
const XIcon: SvgComponent = (props) => (
|
|
44
|
-
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
45
|
-
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
46
|
-
</svg>
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
const LinkedInIcon: SvgComponent = (props) => (
|
|
50
|
-
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
51
|
-
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.852 3.37-1.852 3.601 0 4.267 2.37 4.267 5.455v6.288zM5.337 7.433a2.062 2.062 0 1 1 0-4.125 2.063 2.063 0 0 1 0 4.125zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z" />
|
|
52
|
-
</svg>
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const YouTubeIcon: SvgComponent = (props) => (
|
|
56
|
-
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
57
|
-
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
|
58
|
-
</svg>
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
const TikTokIcon: SvgComponent = (props) => (
|
|
62
|
-
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
63
|
-
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5.8 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1.84-.1z" />
|
|
64
|
-
</svg>
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const SOCIAL_ICONS: Record<string, SvgComponent> = {
|
|
68
|
-
instagram: InstagramIcon,
|
|
69
|
-
facebook: FacebookIcon,
|
|
70
|
-
x: XIcon,
|
|
71
|
-
twitter: XIcon,
|
|
72
|
-
linkedin: LinkedInIcon,
|
|
73
|
-
youtube: YouTubeIcon,
|
|
74
|
-
tiktok: TikTokIcon,
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
78
|
-
const data = footer?.data;
|
|
79
|
-
const columns = data?.columns ?? [];
|
|
80
|
-
const social = data?.social ?? [];
|
|
81
|
-
const year = new Date().getFullYear();
|
|
82
|
-
const brandLabel = storeName ?? 'Store';
|
|
83
|
-
|
|
84
|
-
if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
|
|
85
|
-
return (
|
|
86
|
-
<footer className="border-border bg-muted/30 text-muted-foreground mt-16 border-t py-8 text-sm">
|
|
87
|
-
<div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
|
|
88
|
-
© {year} {brandLabel}. All rights reserved.
|
|
89
|
-
</div>
|
|
90
|
-
</footer>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return (
|
|
95
|
-
<footer className="border-border bg-muted/30 mt-16 border-t">
|
|
96
|
-
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
|
97
|
-
<div className="grid grid-cols-1 gap-10 md:grid-cols-12">
|
|
98
|
-
<div className="md:col-span-4">
|
|
99
|
-
<a
|
|
100
|
-
href="/"
|
|
101
|
-
className="text-foreground text-lg font-semibold tracking-tight"
|
|
102
|
-
>
|
|
103
|
-
{brandLabel}
|
|
104
|
-
</a>
|
|
105
|
-
{social.length > 0 ? (
|
|
106
|
-
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
107
|
-
{social.map((entry, idx) => {
|
|
108
|
-
const key = entry.platform.toLowerCase();
|
|
109
|
-
const Icon = SOCIAL_ICONS[key];
|
|
110
|
-
return (
|
|
111
|
-
<a
|
|
112
|
-
key={`${entry.platform}-${idx}`}
|
|
113
|
-
href={entry.url}
|
|
114
|
-
target="_blank"
|
|
115
|
-
rel="noopener noreferrer"
|
|
116
|
-
aria-label={entry.platform}
|
|
117
|
-
className="border-border hover:border-foreground/40 hover:text-foreground text-muted-foreground inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors"
|
|
118
|
-
>
|
|
119
|
-
{Icon ? (
|
|
120
|
-
<Icon className="h-4 w-4" />
|
|
121
|
-
) : (
|
|
122
|
-
<span className="text-xs font-medium uppercase">
|
|
123
|
-
{entry.platform.charAt(0)}
|
|
124
|
-
</span>
|
|
125
|
-
)}
|
|
126
|
-
</a>
|
|
127
|
-
);
|
|
128
|
-
})}
|
|
129
|
-
</div>
|
|
130
|
-
) : null}
|
|
131
|
-
</div>
|
|
132
|
-
|
|
133
|
-
{columns.length > 0 ? (
|
|
134
|
-
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-8">
|
|
135
|
-
{columns.map((col, idx) => (
|
|
136
|
-
<div key={`${col.title}-${idx}`}>
|
|
137
|
-
<h3 className="text-foreground mb-3 text-sm font-semibold tracking-wide">
|
|
138
|
-
{col.title}
|
|
139
|
-
</h3>
|
|
140
|
-
<ul className="space-y-2">
|
|
141
|
-
{col.links.map((link, linkIdx) => (
|
|
142
|
-
<li key={`${link.url}-${linkIdx}`}>
|
|
143
|
-
<a
|
|
144
|
-
href={link.url}
|
|
145
|
-
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
146
|
-
>
|
|
147
|
-
{link.label}
|
|
148
|
-
</a>
|
|
149
|
-
</li>
|
|
150
|
-
))}
|
|
151
|
-
</ul>
|
|
152
|
-
</div>
|
|
153
|
-
))}
|
|
154
|
-
</div>
|
|
155
|
-
) : null}
|
|
156
|
-
</div>
|
|
157
|
-
|
|
158
|
-
<div className="border-border text-muted-foreground mt-10 flex flex-col items-center justify-between gap-2 border-t pt-6 text-xs sm:flex-row">
|
|
159
|
-
<span>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</span>
|
|
160
|
-
</div>
|
|
161
|
-
</div>
|
|
162
|
-
</footer>
|
|
163
|
-
);
|
|
164
|
-
}
|
|
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
|
+
* Renders a brand block (store-name + social icons), the merchant's link
|
|
8
|
+
* columns, and a copyright bar. Social icons are inline SVGs so the
|
|
9
|
+
* scaffold stays dependency-free; unknown platforms fall back to a
|
|
10
|
+
* letter avatar.
|
|
11
|
+
*
|
|
12
|
+
* API contract:
|
|
13
|
+
* GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
|
|
14
|
+
* Returns null on 404 — we render a minimal fallback so the layout never
|
|
15
|
+
* crashes when the merchant hasn't seeded the footer yet.
|
|
16
|
+
*/
|
|
17
|
+
import * as React from 'react';
|
|
18
|
+
import type { Content } from 'brainerce';
|
|
19
|
+
|
|
20
|
+
interface SiteFooterProps {
|
|
21
|
+
/** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
|
|
22
|
+
footer: Content<'FOOTER'> | null;
|
|
23
|
+
/** Store name shown in the brand block + fallback copyright. */
|
|
24
|
+
storeName?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SvgComponent = React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
28
|
+
|
|
29
|
+
const InstagramIcon: SvgComponent = (props) => (
|
|
30
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
|
|
31
|
+
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
|
|
32
|
+
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
|
|
33
|
+
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
|
|
34
|
+
</svg>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const FacebookIcon: SvgComponent = (props) => (
|
|
38
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
|
|
39
|
+
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
|
|
40
|
+
</svg>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const XIcon: SvgComponent = (props) => (
|
|
44
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
45
|
+
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
46
|
+
</svg>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const LinkedInIcon: SvgComponent = (props) => (
|
|
50
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
51
|
+
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.852 3.37-1.852 3.601 0 4.267 2.37 4.267 5.455v6.288zM5.337 7.433a2.062 2.062 0 1 1 0-4.125 2.063 2.063 0 0 1 0 4.125zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z" />
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const YouTubeIcon: SvgComponent = (props) => (
|
|
56
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
57
|
+
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
|
58
|
+
</svg>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const TikTokIcon: SvgComponent = (props) => (
|
|
62
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
63
|
+
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5.8 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1.84-.1z" />
|
|
64
|
+
</svg>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const SOCIAL_ICONS: Record<string, SvgComponent> = {
|
|
68
|
+
instagram: InstagramIcon,
|
|
69
|
+
facebook: FacebookIcon,
|
|
70
|
+
x: XIcon,
|
|
71
|
+
twitter: XIcon,
|
|
72
|
+
linkedin: LinkedInIcon,
|
|
73
|
+
youtube: YouTubeIcon,
|
|
74
|
+
tiktok: TikTokIcon,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
78
|
+
const data = footer?.data;
|
|
79
|
+
const columns = data?.columns ?? [];
|
|
80
|
+
const social = data?.social ?? [];
|
|
81
|
+
const year = new Date().getFullYear();
|
|
82
|
+
const brandLabel = storeName ?? 'Store';
|
|
83
|
+
|
|
84
|
+
if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
|
|
85
|
+
return (
|
|
86
|
+
<footer className="border-border bg-muted/30 text-muted-foreground mt-16 border-t py-8 text-sm">
|
|
87
|
+
<div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
|
|
88
|
+
© {year} {brandLabel}. All rights reserved.
|
|
89
|
+
</div>
|
|
90
|
+
</footer>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<footer className="border-border bg-muted/30 mt-16 border-t">
|
|
96
|
+
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
|
97
|
+
<div className="grid grid-cols-1 gap-10 md:grid-cols-12">
|
|
98
|
+
<div className="md:col-span-4">
|
|
99
|
+
<a
|
|
100
|
+
href="/"
|
|
101
|
+
className="text-foreground text-lg font-semibold tracking-tight"
|
|
102
|
+
>
|
|
103
|
+
{brandLabel}
|
|
104
|
+
</a>
|
|
105
|
+
{social.length > 0 ? (
|
|
106
|
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
107
|
+
{social.map((entry, idx) => {
|
|
108
|
+
const key = entry.platform.toLowerCase();
|
|
109
|
+
const Icon = SOCIAL_ICONS[key];
|
|
110
|
+
return (
|
|
111
|
+
<a
|
|
112
|
+
key={`${entry.platform}-${idx}`}
|
|
113
|
+
href={entry.url}
|
|
114
|
+
target="_blank"
|
|
115
|
+
rel="noopener noreferrer"
|
|
116
|
+
aria-label={entry.platform}
|
|
117
|
+
className="border-border hover:border-foreground/40 hover:text-foreground text-muted-foreground inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors"
|
|
118
|
+
>
|
|
119
|
+
{Icon ? (
|
|
120
|
+
<Icon className="h-4 w-4" />
|
|
121
|
+
) : (
|
|
122
|
+
<span className="text-xs font-medium uppercase">
|
|
123
|
+
{entry.platform.charAt(0)}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
</a>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
</div>
|
|
130
|
+
) : null}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{columns.length > 0 ? (
|
|
134
|
+
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-8">
|
|
135
|
+
{columns.map((col, idx) => (
|
|
136
|
+
<div key={`${col.title}-${idx}`}>
|
|
137
|
+
<h3 className="text-foreground mb-3 text-sm font-semibold tracking-wide">
|
|
138
|
+
{col.title}
|
|
139
|
+
</h3>
|
|
140
|
+
<ul className="space-y-2">
|
|
141
|
+
{col.links.map((link, linkIdx) => (
|
|
142
|
+
<li key={`${link.url}-${linkIdx}`}>
|
|
143
|
+
<a
|
|
144
|
+
href={link.url}
|
|
145
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
146
|
+
>
|
|
147
|
+
{link.label}
|
|
148
|
+
</a>
|
|
149
|
+
</li>
|
|
150
|
+
))}
|
|
151
|
+
</ul>
|
|
152
|
+
</div>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
) : null}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="border-border text-muted-foreground mt-10 flex flex-col items-center justify-between gap-2 border-t pt-6 text-xs sm:flex-row">
|
|
159
|
+
<span>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</span>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</footer>
|
|
163
|
+
);
|
|
164
|
+
}
|