create-brainerce-store 1.62.0 → 1.64.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/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/index.js +26 -3
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/agents.md/route.ts +87 -0
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +308 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +29 -3
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +30 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +28 -5
- package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
- package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/app/robots.ts +73 -14
- package/templates/nextjs/base/src/app/sitemap.ts +89 -66
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +23 -131
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +17 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +22 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +30 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +29 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +21 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import type { Product } from 'brainerce';
|
|
4
|
-
import { getServerClient } from '@/core/lib/brainerce';
|
|
4
|
+
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
5
5
|
import { buildMetaDescription } from '@/core/lib/seo';
|
|
6
6
|
import { sanitizeHtml } from '@/core/lib/sanitize';
|
|
7
7
|
import { decodeSlug } from '@/core/lib/utils';
|
|
@@ -26,17 +26,43 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
26
26
|
const { slug: rawSlug, locale } = await params;
|
|
27
27
|
const slug = decodeSlug(rawSlug);
|
|
28
28
|
try {
|
|
29
|
-
const category = await
|
|
29
|
+
const [category, storeInfo] = await Promise.all([
|
|
30
|
+
getServerClient(locale).getCategoryBySlug(slug),
|
|
31
|
+
fetchStoreInfo(locale),
|
|
32
|
+
]);
|
|
30
33
|
const description =
|
|
31
34
|
category.metaDescription || buildMetaDescription(category.description) || category.name;
|
|
32
35
|
const canonicalSlug = category.slug || slug;
|
|
33
36
|
const canonicalPath = locale
|
|
34
37
|
? `/${locale}/category/${canonicalSlug}`
|
|
35
38
|
: `/category/${canonicalSlug}`;
|
|
39
|
+
|
|
40
|
+
// hreflang: category URLs use the same slug across locales (only the
|
|
41
|
+
// locale prefix changes) — the same convention getCategorySitemapEntries
|
|
42
|
+
// emits, so the alternates always match real, crawlable URLs.
|
|
43
|
+
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
44
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
45
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
46
|
+
const hreflangLanguages =
|
|
47
|
+
supportedLocales.length > 1
|
|
48
|
+
? Object.fromEntries([
|
|
49
|
+
...supportedLocales.map((loc) => [
|
|
50
|
+
loc,
|
|
51
|
+
loc === defaultLoc
|
|
52
|
+
? `${baseUrl}/category/${canonicalSlug}`
|
|
53
|
+
: `${baseUrl}/${loc}/category/${canonicalSlug}`,
|
|
54
|
+
]),
|
|
55
|
+
['x-default', `${baseUrl}/category/${canonicalSlug}`],
|
|
56
|
+
])
|
|
57
|
+
: undefined;
|
|
58
|
+
|
|
36
59
|
return {
|
|
37
60
|
title: category.name,
|
|
38
61
|
description,
|
|
39
|
-
alternates: {
|
|
62
|
+
alternates: {
|
|
63
|
+
canonical: canonicalPath,
|
|
64
|
+
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
65
|
+
},
|
|
40
66
|
openGraph: {
|
|
41
67
|
title: category.name,
|
|
42
68
|
description,
|
|
@@ -31,6 +31,7 @@ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
|
31
31
|
import { useTranslations } from '@/core/lib/translations';
|
|
32
32
|
import { cn } from '@/core/lib/utils';
|
|
33
33
|
import { isValidCheckoutId } from '@/core/lib/safe-redirect';
|
|
34
|
+
import { trackBeginCheckout } from '@/core/lib/tracking';
|
|
34
35
|
|
|
35
36
|
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
|
|
36
37
|
|
|
@@ -69,6 +70,17 @@ function CheckoutContent() {
|
|
|
69
70
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
|
70
71
|
const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
|
|
71
72
|
|
|
73
|
+
// `begin_checkout` — fired once the cart is loaded, guarded by a ref so a
|
|
74
|
+
// re-render (or the shopper returning from a canceled payment) doesn't
|
|
75
|
+
// report a second checkout start for the same cart.
|
|
76
|
+
const beganCheckoutForCartRef = useRef<string | null>(null);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!cart?.id || !cart.items?.length) return;
|
|
79
|
+
if (beganCheckoutForCartRef.current === cart.id) return;
|
|
80
|
+
beganCheckoutForCartRef.current = cart.id;
|
|
81
|
+
trackBeginCheckout(cart, currency);
|
|
82
|
+
}, [cart, currency]);
|
|
83
|
+
|
|
72
84
|
// Check for returning from canceled payment
|
|
73
85
|
const canceled = searchParams.get('canceled') === 'true';
|
|
74
86
|
const checkoutIdParam = searchParams.get('checkout_id');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
|
|
3
|
+
// The contact page itself is a client component (the form is dashboard-
|
|
4
|
+
// driven), so its metadata lives here. Indexable on purpose — a crawlable
|
|
5
|
+
// contact page is a store-trust signal for both Google and AI assistants.
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: <%- language === 'he' ? "'צור קשר'" : "'Contact'" %>,
|
|
8
|
+
description: <%- language === 'he' ? "'יצירת קשר עם החנות — שאלות, הזמנות ושירות לקוחות.'" : "'Get in touch with the store — questions, orders, and support.'" %>,
|
|
9
|
+
alternates: { canonical: '/contact' },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function ContactLayout({ children }: { children: React.ReactNode }) {
|
|
13
|
+
return children;
|
|
14
|
+
}
|
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
@tailwind base;
|
|
2
|
-
@tailwind components;
|
|
3
|
-
@tailwind utilities;
|
|
4
|
-
|
|
5
|
-
@layer base {
|
|
6
|
-
:root {
|
|
7
|
-
--background: 0 0% 100%;
|
|
8
|
-
--foreground: 0 0% 9%;
|
|
9
|
-
--primary: 0 0% 9%;
|
|
10
|
-
--primary-foreground: 0 0% 98%;
|
|
11
|
-
--secondary: 0 0% 96%;
|
|
12
|
-
--secondary-foreground: 0 0% 9%;
|
|
13
|
-
--muted: 0 0% 96%;
|
|
14
|
-
--muted-foreground: 0 0% 45%;
|
|
15
|
-
--accent: 0 0% 96%;
|
|
16
|
-
--accent-foreground: 0 0% 9%;
|
|
17
|
-
--destructive: 0 84% 60%;
|
|
18
|
-
--destructive-foreground: 0 0% 98%;
|
|
19
|
-
--border: 0 0% 90%;
|
|
20
|
-
--input: 0 0% 90%;
|
|
21
|
-
--ring: 0 0% 9%;
|
|
22
|
-
--card: 0 0% 100%;
|
|
23
|
-
--card-foreground: 0 0% 9%;
|
|
24
|
-
--popover: 0 0% 100%;
|
|
25
|
-
--popover-foreground: 0 0% 9%;
|
|
26
|
-
--radius: 0.5rem;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
* {
|
|
30
|
-
@apply border-border;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
body {
|
|
34
|
-
@apply bg-background text-foreground antialiased;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 0 0% 9%;
|
|
9
|
+
--primary: 0 0% 9%;
|
|
10
|
+
--primary-foreground: 0 0% 98%;
|
|
11
|
+
--secondary: 0 0% 96%;
|
|
12
|
+
--secondary-foreground: 0 0% 9%;
|
|
13
|
+
--muted: 0 0% 96%;
|
|
14
|
+
--muted-foreground: 0 0% 45%;
|
|
15
|
+
--accent: 0 0% 96%;
|
|
16
|
+
--accent-foreground: 0 0% 9%;
|
|
17
|
+
--destructive: 0 84% 60%;
|
|
18
|
+
--destructive-foreground: 0 0% 98%;
|
|
19
|
+
--border: 0 0% 90%;
|
|
20
|
+
--input: 0 0% 90%;
|
|
21
|
+
--ring: 0 0% 9%;
|
|
22
|
+
--card: 0 0% 100%;
|
|
23
|
+
--card-foreground: 0 0% 9%;
|
|
24
|
+
--popover: 0 0% 100%;
|
|
25
|
+
--popover-foreground: 0 0% 9%;
|
|
26
|
+
--radius: 0.5rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
* {
|
|
30
|
+
@apply border-border;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
body {
|
|
34
|
+
@apply bg-background text-foreground antialiased;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* IndexNow key file — GET /indexnow-key.txt
|
|
3
|
-
*
|
|
4
|
-
* Brainerce's SEO Autopilot pings IndexNow (instant search-engine indexing)
|
|
5
|
-
* whenever a blog post publishes. The ping includes
|
|
6
|
-
* `keyLocation: https://<your-domain>/indexnow-key.txt`, and search engines
|
|
7
|
-
* verify ownership by fetching this file and matching the key.
|
|
8
|
-
*
|
|
9
|
-
* The key comes from `getStoreInfo().seo.indexNowKey` — it is NOT a secret
|
|
10
|
-
* (the file is public by protocol design). Returns 404 until the platform
|
|
11
|
-
* generates a key for this sales channel, which is harmless.
|
|
12
|
-
*/
|
|
13
|
-
import { getServerClient } from '@/core/lib/brainerce';
|
|
14
|
-
|
|
15
|
-
export const revalidate = 3600;
|
|
16
|
-
|
|
17
|
-
export async function GET() {
|
|
18
|
-
const info = await getServerClient()
|
|
19
|
-
.getStoreInfo()
|
|
20
|
-
.catch(() => null);
|
|
21
|
-
const key = info?.seo?.indexNowKey;
|
|
22
|
-
if (!key) return new Response(null, { status: 404 });
|
|
23
|
-
return new Response(key, {
|
|
24
|
-
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
25
|
-
});
|
|
26
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* IndexNow key file — GET /indexnow-key.txt
|
|
3
|
+
*
|
|
4
|
+
* Brainerce's SEO Autopilot pings IndexNow (instant search-engine indexing)
|
|
5
|
+
* whenever a blog post publishes. The ping includes
|
|
6
|
+
* `keyLocation: https://<your-domain>/indexnow-key.txt`, and search engines
|
|
7
|
+
* verify ownership by fetching this file and matching the key.
|
|
8
|
+
*
|
|
9
|
+
* The key comes from `getStoreInfo().seo.indexNowKey` — it is NOT a secret
|
|
10
|
+
* (the file is public by protocol design). Returns 404 until the platform
|
|
11
|
+
* generates a key for this sales channel, which is harmless.
|
|
12
|
+
*/
|
|
13
|
+
import { getServerClient } from '@/core/lib/brainerce';
|
|
14
|
+
|
|
15
|
+
export const revalidate = 3600;
|
|
16
|
+
|
|
17
|
+
export async function GET() {
|
|
18
|
+
const info = await getServerClient()
|
|
19
|
+
.getStoreInfo()
|
|
20
|
+
.catch(() => null);
|
|
21
|
+
const key = info?.seo?.indexNowKey;
|
|
22
|
+
if (!key) return new Response(null, { status: 404 });
|
|
23
|
+
return new Response(key, {
|
|
24
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -6,6 +6,7 @@ import { AnnouncementBar } from '@/ui/layout/announcement-bar';
|
|
|
6
6
|
import { SiteHeader } from '@/ui/layout/site-header';
|
|
7
7
|
import { SiteFooter } from '@/ui/layout/site-footer';
|
|
8
8
|
import { BrainerceBotWidget } from '@/components/brainerce-bot';
|
|
9
|
+
import { TrackingBootstrap } from '@/components/tracking-bootstrap';
|
|
9
10
|
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
10
11
|
import { getDirection, getMessages, supportedLocales } from '@/i18n';
|
|
11
12
|
import { getNonce } from '@/core/lib/nonce';
|
|
@@ -90,6 +91,16 @@ export default async function RootLayout({
|
|
|
90
91
|
.replace(/&/g, '\\u0026'),
|
|
91
92
|
}}
|
|
92
93
|
/>
|
|
94
|
+
{/* Google Search Console verification — token set by the merchant in
|
|
95
|
+
the dashboard (channel settings → Google site verification). Also
|
|
96
|
+
what the Merchant Center website claim checks for. Renders nothing
|
|
97
|
+
until a token is configured. */}
|
|
98
|
+
{storeInfo?.seo?.googleSiteVerification ? (
|
|
99
|
+
<meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
|
|
100
|
+
) : null}
|
|
101
|
+
{/* RSS autodiscovery — feed readers and aggregators find the blog feed
|
|
102
|
+
from any page. The route itself lives at /blog/rss.xml. */}
|
|
103
|
+
<link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
|
|
93
104
|
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
94
105
|
SPA route changes. Cookieless (no consent banner needed); the
|
|
95
106
|
merchant can toggle it per sales channel in the dashboard. */}
|
|
@@ -105,6 +116,10 @@ export default async function RootLayout({
|
|
|
105
116
|
</head>
|
|
106
117
|
<body className={font.className}>
|
|
107
118
|
<StoreProvider locale={locale} initialStoreInfo={storeInfo} initialMessages={messages}>
|
|
119
|
+
{/* Mounted ahead of the page tree on purpose: React runs effects in
|
|
120
|
+
tree order, so booting the tags here guarantees fbq/ttq exist by
|
|
121
|
+
the time a page component fires its first e-commerce event. */}
|
|
122
|
+
<TrackingBootstrap />
|
|
108
123
|
<div className="min-h-screen flex flex-col">
|
|
109
124
|
<AnnouncementBar announcements={announcements} />
|
|
110
125
|
<SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
|
|
@@ -125,6 +140,7 @@ import { AnnouncementBar } from '@/ui/layout/announcement-bar';
|
|
|
125
140
|
import { SiteHeader } from '@/ui/layout/site-header';
|
|
126
141
|
import { SiteFooter } from '@/ui/layout/site-footer';
|
|
127
142
|
import { BrainerceBotWidget } from '@/components/brainerce-bot';
|
|
143
|
+
import { TrackingBootstrap } from '@/components/tracking-bootstrap';
|
|
128
144
|
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
129
145
|
import { getNonce } from '@/core/lib/nonce';
|
|
130
146
|
import './globals.css';
|
|
@@ -197,6 +213,16 @@ export default async function RootLayout({
|
|
|
197
213
|
.replace(/&/g, '\\u0026'),
|
|
198
214
|
}}
|
|
199
215
|
/>
|
|
216
|
+
{/* Google Search Console verification — token set by the merchant in
|
|
217
|
+
the dashboard (channel settings → Google site verification). Also
|
|
218
|
+
what the Merchant Center website claim checks for. Renders nothing
|
|
219
|
+
until a token is configured. */}
|
|
220
|
+
{storeInfo?.seo?.googleSiteVerification ? (
|
|
221
|
+
<meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
|
|
222
|
+
) : null}
|
|
223
|
+
{/* RSS autodiscovery — feed readers and aggregators find the blog feed
|
|
224
|
+
from any page. The route itself lives at /blog/rss.xml. */}
|
|
225
|
+
<link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
|
|
200
226
|
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
201
227
|
SPA route changes. Cookieless (no consent banner needed); the
|
|
202
228
|
merchant can toggle it per sales channel in the dashboard. */}
|
|
@@ -212,6 +238,10 @@ export default async function RootLayout({
|
|
|
212
238
|
</head>
|
|
213
239
|
<body className={font.className}>
|
|
214
240
|
<StoreProvider initialStoreInfo={storeInfo}>
|
|
241
|
+
{/* Mounted ahead of the page tree on purpose: React runs effects in
|
|
242
|
+
tree order, so booting the tags here guarantees fbq/ttq exist by
|
|
243
|
+
the time a page component fires its first e-commerce event. */}
|
|
244
|
+
<TrackingBootstrap />
|
|
215
245
|
<div className="min-h-screen flex flex-col">
|
|
216
246
|
<AnnouncementBar announcements={announcements} />
|
|
217
247
|
<SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
|
|
@@ -2,23 +2,35 @@
|
|
|
2
2
|
* llms.txt — GET /llms.txt
|
|
3
3
|
*
|
|
4
4
|
* A machine-readable site summary for AI answer engines (ChatGPT, Perplexity,
|
|
5
|
-
* Claude, AI Overviews)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Claude, AI Overviews): a concise markdown map of what the site is and where
|
|
6
|
+
* its key content lives, so AI crawlers ground answers in (and cite) the
|
|
7
|
+
* right pages. /agents.md is the richer agent-facing companion (the newer
|
|
8
|
+
* convention) — keep the two consistent.
|
|
8
9
|
*/
|
|
9
10
|
import { getServerClient } from '@/core/lib/brainerce';
|
|
10
11
|
|
|
11
12
|
export const revalidate = 3600;
|
|
12
13
|
|
|
14
|
+
interface CategoryNodeLike {
|
|
15
|
+
name: string;
|
|
16
|
+
slug?: string | null;
|
|
17
|
+
children?: CategoryNodeLike[];
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
export async function GET() {
|
|
14
21
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
15
22
|
const client = getServerClient();
|
|
16
23
|
|
|
17
|
-
const [info, posts] = await Promise.all([
|
|
24
|
+
const [info, posts, categoriesRes] = await Promise.all([
|
|
18
25
|
client.getStoreInfo().catch(() => null),
|
|
19
26
|
client.blog.getPosts({ limit: 20 }).catch(() => null),
|
|
27
|
+
client.getCategories().catch(() => null),
|
|
20
28
|
]);
|
|
21
29
|
|
|
30
|
+
const topCategories: CategoryNodeLike[] = (
|
|
31
|
+
(categoriesRes?.categories as CategoryNodeLike[] | undefined) ?? []
|
|
32
|
+
).filter((c) => c.slug);
|
|
33
|
+
|
|
22
34
|
const lines: string[] = [
|
|
23
35
|
`# ${info?.name ?? 'Store'}`,
|
|
24
36
|
'',
|
|
@@ -26,8 +38,19 @@ export async function GET() {
|
|
|
26
38
|
'## Key pages',
|
|
27
39
|
'',
|
|
28
40
|
`- [Products](${baseUrl}/products): full catalog`,
|
|
41
|
+
...topCategories
|
|
42
|
+
.slice(0, 20)
|
|
43
|
+
.map((c) => `- [${c.name}](${baseUrl}/category/${c.slug}): category`),
|
|
29
44
|
`- [Blog](${baseUrl}/blog): guides and articles`,
|
|
30
|
-
|
|
45
|
+
`- [FAQ](${baseUrl}/faq)`,
|
|
46
|
+
`- [Contact](${baseUrl}/contact)`,
|
|
47
|
+
...(info?.contactEmail ? [`- Contact email: ${info.contactEmail}`] : []),
|
|
48
|
+
'',
|
|
49
|
+
'## Machine-readable surfaces',
|
|
50
|
+
'',
|
|
51
|
+
`- [Agent guide](${baseUrl}/agents.md)`,
|
|
52
|
+
`- [Sitemap](${baseUrl}/sitemap.xml)`,
|
|
53
|
+
`- [Blog RSS](${baseUrl}/blog/rss.xml)`,
|
|
31
54
|
];
|
|
32
55
|
|
|
33
56
|
if (posts && posts.data.length > 0) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 404 page — rendered for unknown URLs and for `notFound()` calls from
|
|
5
|
+
* product/category/blog pages (deleted products, renamed slugs). A branded
|
|
6
|
+
* 404 with a path back to the catalog keeps the visitor (and their SEO
|
|
7
|
+
* equity) on the site instead of bouncing off the framework default.
|
|
8
|
+
*/
|
|
9
|
+
export default function NotFound() {
|
|
10
|
+
return (
|
|
11
|
+
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-24 text-center">
|
|
12
|
+
<p className="text-sm font-medium text-muted-foreground">404</p>
|
|
13
|
+
<h1 className="mt-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
|
14
|
+
<%- language === 'he' ? 'העמוד לא נמצא' : 'Page not found' %>
|
|
15
|
+
</h1>
|
|
16
|
+
<p className="mt-4 text-muted-foreground">
|
|
17
|
+
<%- language === 'he' ? 'יכול להיות שהמוצר הוסר או שהכתובת השתנתה.' : 'The product may have been removed, or the address may have changed.' %>
|
|
18
|
+
</p>
|
|
19
|
+
<div className="mt-8 flex gap-4">
|
|
20
|
+
<Link
|
|
21
|
+
href="/"
|
|
22
|
+
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
|
23
|
+
>
|
|
24
|
+
<%- language === 'he' ? 'לדף הבית' : 'Go home' %>
|
|
25
|
+
</Link>
|
|
26
|
+
<Link
|
|
27
|
+
href="/products"
|
|
28
|
+
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
|
|
29
|
+
>
|
|
30
|
+
<%- language === 'he' ? 'לכל המוצרים' : 'Browse products' %>
|
|
31
|
+
</Link>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ImageResponse } from 'next/og';
|
|
2
|
+
import { fetchStoreInfo } from '@/core/lib/brainerce';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default Open Graph card for every page that doesn't set its own og:image
|
|
6
|
+
* (home, /products, /faq, /contact, content pages without an OG image).
|
|
7
|
+
* Product / category / blog pages override this with their real images.
|
|
8
|
+
*
|
|
9
|
+
* Without this file, any page lacking an image gets NO social card at all —
|
|
10
|
+
* a bare-domain link in WhatsApp/Facebook/X renders as plain text.
|
|
11
|
+
*/
|
|
12
|
+
export const size = { width: 1200, height: 630 };
|
|
13
|
+
export const contentType = 'image/png';
|
|
14
|
+
export const alt = 'Store';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Satori (the OG renderer) ships a Latin-only default font — a Hebrew or
|
|
18
|
+
* Arabic store name would render as tofu (□□□). Fetch a Noto Sans variant
|
|
19
|
+
* with the needed coverage at request time; fetching the CSS without a
|
|
20
|
+
* browser UA makes Google Fonts return TTF (which Satori accepts — it can't
|
|
21
|
+
* parse woff2). Cached by the framework; on any failure we render without
|
|
22
|
+
* text rather than shipping tofu.
|
|
23
|
+
*/
|
|
24
|
+
async function loadFont(text: string): Promise<ArrayBuffer | null> {
|
|
25
|
+
try {
|
|
26
|
+
const css = await fetch(
|
|
27
|
+
`https://fonts.googleapis.com/css2?family=Noto+Sans+Hebrew:wght@700&family=Noto+Sans:wght@700&display=swap&text=${encodeURIComponent(text)}`,
|
|
28
|
+
{ cache: 'force-cache' }
|
|
29
|
+
).then((r) => r.text());
|
|
30
|
+
const url = css.match(/src:\s*url\((.+?)\)\s*format\(['"](?:truetype|opentype)['"]\)/)?.[1];
|
|
31
|
+
if (!url) return null;
|
|
32
|
+
return await fetch(url, { cache: 'force-cache' }).then((r) => r.arrayBuffer());
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Basic Latin through Latin Extended-B (U+0020–U+024F) plus whitespace,
|
|
39
|
+
// punctuation, and digits — the coverage of Satori's bundled default font.
|
|
40
|
+
const LATIN_ONLY = /^[ -ɏ\s\p{P}\p{N}]*$/u;
|
|
41
|
+
|
|
42
|
+
export default async function OpenGraphImage() {
|
|
43
|
+
const info = await fetchStoreInfo();
|
|
44
|
+
const name = info?.name?.trim() || '';
|
|
45
|
+
const description = info?.metaDescription?.trim() || '';
|
|
46
|
+
const logo = info?.logo || null;
|
|
47
|
+
|
|
48
|
+
const text = `${name} ${description}`.trim();
|
|
49
|
+
const font = text ? await loadFont(text) : null;
|
|
50
|
+
// Default font covers Latin only — if the font fetch failed and the text
|
|
51
|
+
// needs more coverage, drop the text instead of rendering tofu.
|
|
52
|
+
const canRenderText = Boolean(text) && (font !== null || LATIN_ONLY.test(text));
|
|
53
|
+
|
|
54
|
+
return new ImageResponse(
|
|
55
|
+
<div
|
|
56
|
+
style={{
|
|
57
|
+
width: '100%',
|
|
58
|
+
height: '100%',
|
|
59
|
+
display: 'flex',
|
|
60
|
+
flexDirection: 'column',
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
justifyContent: 'center',
|
|
63
|
+
gap: 32,
|
|
64
|
+
background: 'linear-gradient(135deg, #111113 0%, #1c1c22 55%, #26262e 100%)',
|
|
65
|
+
color: '#fafafa',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{logo ? (
|
|
69
|
+
<img src={logo} alt="" style={{ maxHeight: 220, maxWidth: 520, objectFit: 'contain' }} />
|
|
70
|
+
) : null}
|
|
71
|
+
{canRenderText && name ? (
|
|
72
|
+
<div
|
|
73
|
+
style={{
|
|
74
|
+
display: 'flex',
|
|
75
|
+
fontSize: logo ? 56 : 84,
|
|
76
|
+
fontWeight: 700,
|
|
77
|
+
textAlign: 'center',
|
|
78
|
+
maxWidth: 1000,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{name}
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
{canRenderText && description ? (
|
|
85
|
+
<div
|
|
86
|
+
style={{
|
|
87
|
+
display: 'flex',
|
|
88
|
+
fontSize: 32,
|
|
89
|
+
color: '#a1a1aa',
|
|
90
|
+
textAlign: 'center',
|
|
91
|
+
maxWidth: 960,
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{description.length > 120 ? `${description.slice(0, 119)}…` : description}
|
|
95
|
+
</div>
|
|
96
|
+
) : null}
|
|
97
|
+
</div>,
|
|
98
|
+
{
|
|
99
|
+
...size,
|
|
100
|
+
...(font
|
|
101
|
+
? {
|
|
102
|
+
fonts: [
|
|
103
|
+
{ name: 'Noto Sans', data: font, weight: 700 as const, style: 'normal' as const },
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
}
|