create-brainerce-store 1.36.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.
@@ -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: product.description || product.name,
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
- const RTL_LOCALES = new Set(['he', 'ar']);
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 RTL_LOCALES.has(locale) ? 'rtl' : 'ltr';
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
- }
@@ -1,335 +0,0 @@
1
- 'use client';
2
-
3
- import { useState, useEffect, useRef, useCallback } from 'react';
4
- import { Link, useRouter } from '@/lib/navigation';
5
- import Image from 'next/image';
6
- import type { SearchSuggestions, ProductSuggestion } from 'brainerce';
7
- import { formatPrice } from 'brainerce';
8
- import { getClient } from '@/lib/brainerce';
9
- import { useTranslations } from '@/lib/translations';
10
- import { useStoreInfo, useAuth, useCart } from '@/providers/store-provider';
11
- export function Header() {
12
- const t = useTranslations('nav');
13
- const tc = useTranslations('common');
14
- const { storeInfo } = useStoreInfo();
15
- const { isLoggedIn, logout } = useAuth();
16
- const { itemCount } = useCart();
17
- const router = useRouter();
18
-
19
- const [searchQuery, setSearchQuery] = useState('');
20
- const [suggestions, setSuggestions] = useState<SearchSuggestions | null>(null);
21
- const [showSuggestions, setShowSuggestions] = useState(false);
22
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
23
- const searchRef = useRef<HTMLDivElement>(null);
24
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
25
-
26
- const currency = storeInfo?.currency || 'USD';
27
-
28
- // Debounced search suggestions
29
- const fetchSuggestions = useCallback((query: string) => {
30
- if (debounceRef.current) clearTimeout(debounceRef.current);
31
-
32
- if (query.length < 2) {
33
- setSuggestions(null);
34
- setShowSuggestions(false);
35
- return;
36
- }
37
-
38
- debounceRef.current = setTimeout(async () => {
39
- try {
40
- const client = getClient();
41
- const result = await client.getSearchSuggestions(query, 5);
42
- setSuggestions(result);
43
- setShowSuggestions(true);
44
- } catch {
45
- setSuggestions(null);
46
- }
47
- }, 300);
48
- }, []);
49
-
50
- // Close suggestions on click outside
51
- useEffect(() => {
52
- function handleClickOutside(e: MouseEvent) {
53
- if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
54
- setShowSuggestions(false);
55
- }
56
- }
57
- document.addEventListener('mousedown', handleClickOutside);
58
- return () => document.removeEventListener('mousedown', handleClickOutside);
59
- }, []);
60
-
61
- function handleSearchSubmit(e: React.FormEvent) {
62
- e.preventDefault();
63
- if (searchQuery.trim()) {
64
- router.push(`/products?search=${encodeURIComponent(searchQuery.trim())}`);
65
- setShowSuggestions(false);
66
- setSearchQuery('');
67
- }
68
- }
69
-
70
- function handleSuggestionClick(suggestion: ProductSuggestion) {
71
- const href = suggestion.slug ? `/products/${suggestion.slug}` : `/products/${suggestion.id}`;
72
- router.push(href);
73
- setShowSuggestions(false);
74
- setSearchQuery('');
75
- }
76
-
77
- return (
78
- <header className="bg-background border-border sticky top-0 z-50 border-b">
79
- <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
80
- <div className="flex h-16 items-center justify-between gap-4">
81
- {/* Logo / Store Name */}
82
- <Link href="/" className="text-foreground flex-shrink-0 text-xl font-bold">
83
- {storeInfo?.name || process.env.NEXT_PUBLIC_STORE_NAME || tc('store')}
84
- </Link>
85
-
86
- {/* Desktop Navigation */}
87
- <nav className="hidden items-center gap-6 md:flex">
88
- <Link
89
- href="/products"
90
- className="text-muted-foreground hover:text-foreground text-sm transition-colors"
91
- >
92
- {t('products')}
93
- </Link>
94
- {isLoggedIn && (
95
- <Link
96
- href="/account"
97
- className="text-muted-foreground hover:text-foreground text-sm transition-colors"
98
- >
99
- {t('account')}
100
- </Link>
101
- )}
102
- </nav>
103
-
104
- {/* Search */}
105
- <div ref={searchRef} className="relative hidden max-w-md flex-1 sm:block">
106
- <form onSubmit={handleSearchSubmit}>
107
- <input
108
- type="text"
109
- value={searchQuery}
110
- onChange={(e) => {
111
- setSearchQuery(e.target.value);
112
- fetchSuggestions(e.target.value);
113
- }}
114
- onFocus={() => {
115
- if (suggestions && searchQuery.length >= 2) {
116
- setShowSuggestions(true);
117
- }
118
- }}
119
- placeholder={t('searchPlaceholder')}
120
- className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-9 w-full rounded border px-3 pe-9 text-sm focus:outline-none focus:ring-2"
121
- />
122
- <button
123
- type="submit"
124
- className="text-muted-foreground hover:text-foreground absolute end-0 top-0 flex h-9 w-9 items-center justify-center"
125
- aria-label={t('search')}
126
- >
127
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
128
- <path
129
- strokeLinecap="round"
130
- strokeLinejoin="round"
131
- strokeWidth={2}
132
- d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
133
- />
134
- </svg>
135
- </button>
136
- </form>
137
-
138
- {/* Search Suggestions Dropdown */}
139
- {showSuggestions && suggestions && (
140
- <div className="bg-background border-border absolute top-full z-50 mt-1 w-full overflow-hidden rounded-lg border shadow-lg">
141
- {suggestions.products.length > 0 && (
142
- <div>
143
- <div className="text-muted-foreground bg-muted px-3 py-1.5 text-xs font-medium">
144
- {t('products')}
145
- </div>
146
- {suggestions.products.map((product) => (
147
- <button
148
- key={product.id}
149
- type="button"
150
- onClick={() => handleSuggestionClick(product)}
151
- className="hover:bg-muted flex w-full items-center gap-3 px-3 py-2 text-start transition-colors"
152
- >
153
- {product.image ? (
154
- <Image
155
- src={product.image}
156
- alt={product.name}
157
- width={32}
158
- height={32}
159
- className="flex-shrink-0 rounded object-cover"
160
- />
161
- ) : (
162
- <div className="bg-muted h-8 w-8 flex-shrink-0 rounded" />
163
- )}
164
- <div className="min-w-0 flex-1">
165
- <p className="text-foreground truncate text-sm">{product.name}</p>
166
- <p className="text-muted-foreground text-xs">
167
- {
168
- formatPrice(product.salePrice || product.price, {
169
- currency,
170
- }) as string
171
- }
172
- </p>
173
- </div>
174
- </button>
175
- ))}
176
- </div>
177
- )}
178
-
179
- {suggestions.categories.length > 0 && (
180
- <div>
181
- <div className="text-muted-foreground bg-muted px-3 py-1.5 text-xs font-medium">
182
- {t('categories')}
183
- </div>
184
- {suggestions.categories.map((cat) => (
185
- <button
186
- key={cat.id}
187
- type="button"
188
- onClick={() => {
189
- router.push(`/products?category=${cat.id}`);
190
- setShowSuggestions(false);
191
- setSearchQuery('');
192
- }}
193
- className="hover:bg-muted flex w-full items-center justify-between px-3 py-2 text-start transition-colors"
194
- >
195
- <span className="text-foreground text-sm">{cat.name}</span>
196
- <span className="text-muted-foreground text-xs">
197
- {cat.productCount} {tc('products')}
198
- </span>
199
- </button>
200
- ))}
201
- </div>
202
- )}
203
-
204
- {suggestions.products.length === 0 && suggestions.categories.length === 0 && (
205
- <div className="text-muted-foreground px-3 py-4 text-center text-sm">
206
- {tc('noResults')}
207
- </div>
208
- )}
209
- </div>
210
- )}
211
- </div>
212
-
213
- {/* Right side actions */}
214
- <div className="flex items-center gap-3">
215
- {/* Auth */}
216
- {isLoggedIn ? (
217
- <button
218
- onClick={logout}
219
- className="text-muted-foreground hover:text-foreground hidden text-sm transition-colors sm:inline-flex"
220
- >
221
- {t('logout')}
222
- </button>
223
- ) : (
224
- <Link
225
- href="/login"
226
- className="text-muted-foreground hover:text-foreground hidden text-sm transition-colors sm:inline-flex"
227
- >
228
- {t('login')}
229
- </Link>
230
- )}
231
-
232
- {/* Cart */}
233
- <Link
234
- href="/cart"
235
- className="text-foreground hover:text-primary relative p-2 transition-colors"
236
- aria-label={`${itemCount} ${itemCount === 1 ? tc('item') : tc('items')}`}
237
- >
238
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
239
- <path
240
- strokeLinecap="round"
241
- strokeLinejoin="round"
242
- strokeWidth={2}
243
- d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
244
- />
245
- </svg>
246
- {itemCount > 0 && (
247
- <span className="bg-primary text-primary-foreground absolute -end-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full text-[10px] font-bold">
248
- {itemCount > 99 ? '99+' : itemCount}
249
- </span>
250
- )}
251
- </Link>
252
-
253
- {/* Mobile menu button */}
254
- <button
255
- type="button"
256
- onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
257
- className="text-foreground p-2 md:hidden"
258
- aria-label={t('menu')}
259
- >
260
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
261
- {mobileMenuOpen ? (
262
- <path
263
- strokeLinecap="round"
264
- strokeLinejoin="round"
265
- strokeWidth={2}
266
- d="M6 18L18 6M6 6l12 12"
267
- />
268
- ) : (
269
- <path
270
- strokeLinecap="round"
271
- strokeLinejoin="round"
272
- strokeWidth={2}
273
- d="M4 6h16M4 12h16M4 18h16"
274
- />
275
- )}
276
- </svg>
277
- </button>
278
- </div>
279
- </div>
280
-
281
- {/* Mobile Menu */}
282
- {mobileMenuOpen && (
283
- <div className="border-border space-y-2 border-t py-3 md:hidden">
284
- <Link
285
- href="/products"
286
- onClick={() => setMobileMenuOpen(false)}
287
- className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
288
- >
289
- {t('products')}
290
- </Link>
291
- {isLoggedIn && (
292
- <Link
293
- href="/account"
294
- onClick={() => setMobileMenuOpen(false)}
295
- className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
296
- >
297
- {t('account')}
298
- </Link>
299
- )}
300
- {isLoggedIn ? (
301
- <button
302
- onClick={() => {
303
- logout();
304
- setMobileMenuOpen(false);
305
- }}
306
- className="text-foreground hover:bg-muted block w-full rounded px-2 py-2 text-start text-sm"
307
- >
308
- {t('logout')}
309
- </button>
310
- ) : (
311
- <Link
312
- href="/login"
313
- onClick={() => setMobileMenuOpen(false)}
314
- className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
315
- >
316
- {t('login')}
317
- </Link>
318
- )}
319
-
320
- {/* Mobile search */}
321
- <form onSubmit={handleSearchSubmit} className="px-2 pt-2">
322
- <input
323
- type="text"
324
- value={searchQuery}
325
- onChange={(e) => setSearchQuery(e.target.value)}
326
- placeholder={t('searchPlaceholder')}
327
- className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-9 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
328
- />
329
- </form>
330
- </div>
331
- )}
332
- </div>
333
- </header>
334
- );
335
- }