create-brainerce-store 1.39.0 → 1.40.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 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.39.0",
34
+ version: "1.40.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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.39.0",
3
+ "version": "1.40.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -86,7 +86,7 @@ export default async function RootLayout({
86
86
  <StoreProvider locale={locale}>
87
87
  <div className="min-h-screen flex flex-col">
88
88
  <AnnouncementBar announcements={announcements} />
89
- <SiteHeader header={siteHeader} />
89
+ <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
90
90
  <main className="flex-1">{children}</main>
91
91
  <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
92
92
  </div>
@@ -175,7 +175,7 @@ export default async function RootLayout({
175
175
  <StoreProvider>
176
176
  <div className="min-h-screen flex flex-col">
177
177
  <AnnouncementBar announcements={announcements} />
178
- <SiteHeader header={siteHeader} />
178
+ <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
179
179
  <main className="flex-1">{children}</main>
180
180
  <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
181
181
  </div>
@@ -4,17 +4,15 @@
4
4
  * The merchant configures the footer in the Brainerce dashboard under
5
5
  * Sell → Content → Footer
6
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**.
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.
10
11
  *
11
12
  * API contract:
12
13
  * GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
13
14
  * Returns null on 404 — we render a minimal fallback so the layout never
14
15
  * 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
16
  */
19
17
  import * as React from 'react';
20
18
  import type { Content } from 'brainerce';
@@ -22,74 +20,144 @@ import type { Content } from 'brainerce';
22
20
  interface SiteFooterProps {
23
21
  /** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
24
22
  footer: Content<'FOOTER'> | null;
25
- /** Store name shown in the fallback copyright when no footer is configured. */
23
+ /** Store name shown in the brand block + fallback copyright. */
26
24
  storeName?: string;
27
25
  }
28
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
+
29
77
  export function SiteFooter({ footer, storeName }: SiteFooterProps) {
30
78
  const data = footer?.data;
31
79
  const columns = data?.columns ?? [];
32
80
  const social = data?.social ?? [];
81
+ const year = new Date().getFullYear();
82
+ const brandLabel = storeName ?? 'Store';
33
83
 
34
- // Fallback when the merchant hasn't seeded a footer yet — keeps the layout
35
- // visually complete without exposing "missing content" to shoppers.
36
84
  if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
37
- const year = new Date().getFullYear();
38
85
  return (
39
- <footer className="border-border bg-muted/30 text-muted-foreground mt-12 border-t py-8 text-sm">
86
+ <footer className="border-border bg-muted/30 text-muted-foreground mt-16 border-t py-8 text-sm">
40
87
  <div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
41
- © {year} {storeName ?? 'Store'}
88
+ © {year} {brandLabel}. All rights reserved.
42
89
  </div>
43
90
  </footer>
44
91
  );
45
92
  }
46
93
 
47
94
  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>
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
+ })}
67
129
  </div>
68
- ))}
130
+ ) : null}
69
131
  </div>
70
- ) : null}
71
132
 
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}
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>
87
157
 
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}
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>
93
161
  </div>
94
162
  </footer>
95
163
  );
@@ -4,13 +4,14 @@
4
4
  * The merchant configures the header in the Brainerce dashboard under
5
5
  * Sell → Content → Header
6
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.
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.
10
11
  *
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.
12
+ * Returns null on 404. New stores ship with a seeded HEADER row from the
13
+ * backend (StoresService.seedDefaultContent), so this should be populated
14
+ * out of the box.
14
15
  */
15
16
  import * as React from 'react';
16
17
  import type { Content } from 'brainerce';
@@ -18,32 +19,75 @@ import type { Content } from 'brainerce';
18
19
  interface SiteHeaderProps {
19
20
  /** Pre-fetched header payload (server-side). `null` renders nothing. */
20
21
  header: Content<'HEADER'> | null;
22
+ /** Fallback brand label when the merchant hasn't uploaded a logo yet. */
23
+ storeName?: string;
21
24
  }
22
25
 
23
- export function SiteHeader({ header }: SiteHeaderProps) {
26
+ const MenuIcon = (props: React.SVGProps<SVGSVGElement>) => (
27
+ <svg
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ strokeWidth={2}
32
+ strokeLinecap="round"
33
+ strokeLinejoin="round"
34
+ aria-hidden="true"
35
+ {...props}
36
+ >
37
+ <line x1="4" y1="6" x2="20" y2="6" />
38
+ <line x1="4" y1="12" x2="20" y2="12" />
39
+ <line x1="4" y1="18" x2="20" y2="18" />
40
+ </svg>
41
+ );
42
+
43
+ const CartIcon = (props: React.SVGProps<SVGSVGElement>) => (
44
+ <svg
45
+ viewBox="0 0 24 24"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ strokeWidth={2}
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ aria-hidden="true"
52
+ {...props}
53
+ >
54
+ <circle cx="9" cy="21" r="1" />
55
+ <circle cx="20" cy="21" r="1" />
56
+ <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
57
+ </svg>
58
+ );
59
+
60
+ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
24
61
  if (!header) return null;
25
62
  const data = header.data;
26
63
  const logo = data.logo;
27
64
  const navItems = data.navItems ?? [];
28
65
  const cta = data.cta;
66
+ const brandLabel = logo?.alt || storeName || 'Store';
29
67
 
30
68
  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">
69
+ <header className="border-border bg-background/95 supports-[backdrop-filter]:bg-background/70 sticky top-0 z-40 border-b backdrop-blur">
70
+ <div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
71
+ <a
72
+ href="/"
73
+ className="flex items-center gap-2 font-semibold tracking-tight"
74
+ aria-label={brandLabel}
75
+ >
34
76
  {logo ? (
35
77
  // eslint-disable-next-line @next/next/no-img-element -- merchant-supplied URL, dynamic optimization handled by CDN
36
78
  <img src={logo.src} alt={logo.alt} className="h-8 w-auto" />
37
- ) : null}
79
+ ) : (
80
+ <span className="text-lg">{brandLabel}</span>
81
+ )}
38
82
  </a>
39
83
 
40
84
  {navItems.length > 0 ? (
41
- <nav className="hidden items-center gap-6 md:flex">
85
+ <nav className="hidden items-center gap-8 md:flex">
42
86
  {navItems.map((item, idx) => (
43
87
  <a
44
88
  key={`${item.url}-${idx}`}
45
89
  href={item.url}
46
- className="text-muted-foreground hover:text-foreground text-sm transition-colors"
90
+ className="text-foreground/80 hover:text-foreground text-sm font-medium transition-colors"
47
91
  >
48
92
  {item.label}
49
93
  </a>
@@ -51,15 +95,48 @@ export function SiteHeader({ header }: SiteHeaderProps) {
51
95
  </nav>
52
96
  ) : null}
53
97
 
54
- {cta ? (
98
+ <div className="flex items-center gap-2">
99
+ {cta ? (
100
+ <a
101
+ href={cta.url}
102
+ 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"
103
+ >
104
+ {cta.label}
105
+ </a>
106
+ ) : null}
107
+
55
108
  <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"
109
+ href="/cart"
110
+ aria-label="Cart"
111
+ 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"
58
112
  >
59
- {cta.label}
113
+ <CartIcon className="h-5 w-5" />
60
114
  </a>
61
- ) : null}
115
+
116
+ {navItems.length > 0 ? (
117
+ <details className="group relative md:hidden">
118
+ <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">
119
+ <MenuIcon className="h-5 w-5" />
120
+ <span className="sr-only">Menu</span>
121
+ </summary>
122
+ <nav className="border-border bg-background absolute end-0 top-full z-50 mt-2 w-56 overflow-hidden rounded-lg border shadow-lg">
123
+ <ul className="py-1">
124
+ {navItems.map((item, idx) => (
125
+ <li key={`m-${item.url}-${idx}`}>
126
+ <a
127
+ href={item.url}
128
+ className="text-foreground/80 hover:text-foreground hover:bg-muted block px-4 py-2 text-sm transition-colors"
129
+ >
130
+ {item.label}
131
+ </a>
132
+ </li>
133
+ ))}
134
+ </ul>
135
+ </nav>
136
+ </details>
137
+ ) : null}
138
+ </div>
62
139
  </div>
63
- </div>
140
+ </header>
64
141
  );
65
142
  }