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.
@@ -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 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
- }
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;