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.
@@ -1,142 +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 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
- * 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.
15
- */
16
- import * as React from 'react';
17
- import type { Content } from 'brainerce';
18
-
19
- interface SiteHeaderProps {
20
- /** Pre-fetched header payload (server-side). `null` renders nothing. */
21
- header: Content<'HEADER'> | null;
22
- /** Fallback brand label when the merchant hasn't uploaded a logo yet. */
23
- storeName?: string;
24
- }
25
-
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) {
61
- if (!header) return null;
62
- const data = header.data;
63
- const logo = data.logo;
64
- const navItems = data.navItems ?? [];
65
- const cta = data.cta;
66
- const brandLabel = logo?.alt || storeName || 'Store';
67
-
68
- return (
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
- >
76
- {logo ? (
77
- // eslint-disable-next-line @next/next/no-img-element -- merchant-supplied URL, dynamic optimization handled by CDN
78
- <img src={logo.src} alt={logo.alt} className="h-8 w-auto" />
79
- ) : (
80
- <span className="text-lg">{brandLabel}</span>
81
- )}
82
- </a>
83
-
84
- {navItems.length > 0 ? (
85
- <nav className="hidden items-center gap-8 md:flex">
86
- {navItems.map((item, idx) => (
87
- <a
88
- key={`${item.url}-${idx}`}
89
- href={item.url}
90
- className="text-foreground/80 hover:text-foreground text-sm font-medium transition-colors"
91
- >
92
- {item.label}
93
- </a>
94
- ))}
95
- </nav>
96
- ) : null}
97
-
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
-
108
- <a
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"
112
- >
113
- <CartIcon className="h-5 w-5" />
114
- </a>
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>
139
- </div>
140
- </header>
141
- );
142
- }
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;
@@ -1,6 +1,21 @@
1
- import { clsx, type ClassValue } from 'clsx';
2
- import { twMerge } from 'tailwind-merge';
3
-
4
- export function cn(...inputs: ClassValue[]) {
5
- return twMerge(clsx(inputs));
6
- }
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ /**
9
+ * Normalize a Next.js dynamic-route param into its decoded human-readable
10
+ * form. App Router usually decodes params automatically, but some runtime
11
+ * + middleware combinations leak percent-encoded values for non-ASCII
12
+ * slugs (e.g. Hebrew `%D7%A7…`), which then surface in canonical URLs and
13
+ * SEO tags. Idempotent on already-decoded input.
14
+ */
15
+ export function decodeSlug(slug: string): string {
16
+ try {
17
+ return decodeURIComponent(slug);
18
+ } catch {
19
+ return slug;
20
+ }
21
+ }