create-brainerce-store 1.51.0 → 1.52.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.
Files changed (170) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +9 -68
  3. package/messages/en.json +32 -0
  4. package/messages/he.json +32 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +9 -0
  7. package/templates/nextjs/base/AI-GUIDE.md +52 -3
  8. package/templates/nextjs/base/CLAUDE.md +10 -0
  9. package/templates/nextjs/base/package.json.ejs +52 -39
  10. package/templates/nextjs/base/src/app/globals.css +36 -30
  11. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
  12. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  13. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  14. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  15. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  16. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  17. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  18. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  19. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  20. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  21. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  22. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  23. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  24. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  25. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  26. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  27. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  28. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
  29. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
  30. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
  32. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
  33. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
  34. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
  35. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
  37. package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
  38. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
  39. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
  40. package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
  41. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
  42. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
  43. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
  44. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
  45. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
  46. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
  47. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
  48. package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
  49. package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
  50. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
  51. package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
  52. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
  53. package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
  54. package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
  55. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
  56. package/templates/nextjs/base/tailwind.config.ts +69 -42
  57. package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
  58. package/templates/nextjs/designs/atelier/globals.css +396 -0
  59. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  60. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  61. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
  62. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  63. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  64. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  65. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  66. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
  67. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  68. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  69. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
  70. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
  71. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  72. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  73. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  74. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  75. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  76. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  77. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  78. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  79. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  80. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  81. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  82. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  83. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  84. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  85. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  86. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  87. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  88. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  89. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  90. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
  91. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
  92. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  93. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  94. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  95. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  96. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  97. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  98. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  99. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
  100. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  101. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  102. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  103. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  104. package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
  105. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  106. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  107. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  108. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  109. package/templates/nextjs/themes/luxury/globals.css +405 -399
  110. package/templates/nextjs/themes/minimal/globals.css +36 -30
  111. package/templates/nextjs/themes/playful/globals.css +406 -400
  112. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
  113. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
  114. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
  115. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
  116. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
  117. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
  118. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
  119. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
  120. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
  121. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
  122. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
  123. package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
  124. package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
  125. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
  126. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
  127. package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
  128. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
  129. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
  130. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  131. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
  132. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
  133. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
  134. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
  135. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
  136. package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
  137. package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
  138. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
  139. package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
  140. package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
  141. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
  142. package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
  143. package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
  144. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
  145. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
  146. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
  147. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
  148. package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
  149. package/templates/nextjs/designs/passepartout/globals.css +0 -412
  150. package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
  151. package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
  152. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
  153. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
  154. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
  155. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
  156. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
  157. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
  158. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
  159. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
  160. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
  161. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
  162. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
  163. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
  164. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
  165. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
  166. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
  167. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
  168. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
  169. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
  170. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Storefront announcement bar — fully driven by the merchant's dashboard.
3
+ *
4
+ * The merchant configures announcements in the Brainerce dashboard under
5
+ * Sell → Content → Announcement
6
+ *
7
+ * Rendering adapts automatically to any announcement shape returned by the
8
+ * API — never hardcode the message or severity here.
9
+ *
10
+ * API contract:
11
+ * GET → client.content.announcement.list(locale) → Content<'ANNOUNCEMENT'>[]
12
+ * Empty array → renders nothing (page layout stays intact).
13
+ *
14
+ * Time-bounded: each announcement may have `startsAt` / `endsAt` ISO
15
+ * timestamps. We filter client-side so the cached server response can be
16
+ * shared across visitors regardless of the current time.
17
+ *
18
+ * Tone by `data.severity`: info/success ride the forest brand band;
19
+ * warning switches to the destructive tone so it reads as urgent.
20
+ */
21
+ 'use client';
22
+
23
+ import * as React from 'react';
24
+ import type { Content } from 'brainerce';
25
+ import { IconX } from '@/ui/shared/icons';
26
+
27
+ interface AnnouncementBarProps {
28
+ /** Pre-fetched list from the server. Pass `[]` to render nothing. */
29
+ announcements: Content<'ANNOUNCEMENT'>[];
30
+ }
31
+
32
+ const STORAGE_KEY = 'brainerce_dismissed_announcements';
33
+
34
+ export function AnnouncementBar({ announcements }: AnnouncementBarProps) {
35
+ const [dismissed, setDismissed] = React.useState<Set<string>>(() => new Set());
36
+
37
+ // Hydrate dismissals from localStorage on mount (avoids SSR/CSR mismatch).
38
+ React.useEffect(() => {
39
+ if (typeof window === 'undefined') return;
40
+ try {
41
+ const raw = window.localStorage.getItem(STORAGE_KEY);
42
+ if (raw) setDismissed(new Set(JSON.parse(raw) as string[]));
43
+ } catch {
44
+ // ignore malformed storage
45
+ }
46
+ }, []);
47
+
48
+ const dismiss = (id: string) => {
49
+ setDismissed((prev) => {
50
+ const next = new Set(prev);
51
+ next.add(id);
52
+ if (typeof window !== 'undefined') {
53
+ try {
54
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
55
+ } catch {
56
+ // ignore quota errors
57
+ }
58
+ }
59
+ return next;
60
+ });
61
+ };
62
+
63
+ const now = Date.now();
64
+ const visible = announcements.filter((a) => {
65
+ if (dismissed.has(a.id)) return false;
66
+ const startOk = !a.data.startsAt || new Date(a.data.startsAt).getTime() <= now;
67
+ const endOk = !a.data.endsAt || new Date(a.data.endsAt).getTime() >= now;
68
+ return startOk && endOk;
69
+ });
70
+
71
+ if (visible.length === 0) return null;
72
+
73
+ return (
74
+ <div role="region" aria-label="Site announcements">
75
+ {visible.map((announcement) => {
76
+ const isWarning = announcement.data.severity === 'warning';
77
+ return (
78
+ <div
79
+ key={announcement.id}
80
+ data-severity={announcement.data.severity}
81
+ className={
82
+ isWarning
83
+ ? 'bg-destructive text-destructive-foreground'
84
+ : 'bg-primary text-primary-foreground'
85
+ }
86
+ >
87
+ <div className="container-page flex min-h-9 items-center justify-center gap-3 py-1.5 text-center text-[13px] font-medium">
88
+ <span>
89
+ {announcement.data.message}
90
+ {announcement.data.ctaHref && announcement.data.ctaLabel ? (
91
+ <>
92
+ {' '}
93
+ <a
94
+ href={announcement.data.ctaHref}
95
+ className="underline underline-offset-2 hover:opacity-80"
96
+ >
97
+ {announcement.data.ctaLabel}
98
+ </a>
99
+ </>
100
+ ) : null}
101
+ </span>
102
+ {announcement.data.dismissible ? (
103
+ <button
104
+ type="button"
105
+ onClick={() => dismiss(announcement.id)}
106
+ aria-label="Dismiss announcement"
107
+ className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-primary-foreground/15"
108
+ >
109
+ <IconX size={16} />
110
+ </button>
111
+ ) : null}
112
+ </div>
113
+ </div>
114
+ );
115
+ })}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * FAQ accordion (bare skeleton) — fully driven by the merchant's dashboard
3
+ * configuration.
4
+ *
5
+ * The merchant configures FAQs in the Brainerce dashboard under
6
+ * Sell → Content → FAQ
7
+ *
8
+ * Each FAQ row has a `key` (e.g. 'main', 'shipping', 'returns'). Pass the
9
+ * pre-fetched payload as `faq`; this component knows nothing about which
10
+ * key it came from.
11
+ *
12
+ * Two render branches:
13
+ * 1. `faq` has items → accordion of question/answer pairs
14
+ * 2. `faq` is null OR has no items → friendly empty state with a contact
15
+ * CTA, so the /faq page never looks broken before the merchant seeds.
16
+ *
17
+ * Security: FAQ answers may contain merchant-authored HTML — we sanitize
18
+ * via `sanitizeHtml` before injecting via dangerouslySetInnerHTML.
19
+ */
20
+ 'use client';
21
+
22
+ import * as React from 'react';
23
+ import type { Content } from 'brainerce';
24
+ import { useTranslations } from '@/core/lib/translations';
25
+ import { sanitizeHtml } from '@/core/lib/sanitize';
26
+
27
+ interface FaqSectionProps {
28
+ /** Pre-fetched FAQ row. `null` renders the empty-state fallback. */
29
+ faq: Content<'FAQ'> | null;
30
+ }
31
+
32
+ export function FaqSection({ faq }: FaqSectionProps) {
33
+ const t = useTranslations('content');
34
+
35
+ const items = faq?.data?.items ?? [];
36
+ const heading = faq?.name || t('faqTitle');
37
+
38
+ // Empty state — runs when the merchant hasn't created a FAQ row yet,
39
+ // or the row exists but contains no items.
40
+ if (!faq || items.length === 0) {
41
+ return (
42
+ <section className="container-page py-16 lg:py-24">
43
+ <div className="card mx-auto flex max-w-lg flex-col items-center gap-3 px-8 py-14 text-center">
44
+ <h1 className="text-3xl">{heading}</h1>
45
+ <p className="text-muted-foreground">
46
+ <strong className="mb-1 block font-semibold text-foreground">
47
+ {t('faqEmptyTitle')}
48
+ </strong>
49
+ {t('faqEmptyBody')}
50
+ </p>
51
+ <a href="/contact" className="btn-primary btn-md mt-3">
52
+ {t('faqContactCta')}
53
+ </a>
54
+ </div>
55
+ </section>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <section className="container-page max-w-3xl py-14 lg:py-20">
61
+ <h1 className="mb-8 text-center text-3xl sm:text-4xl">{heading}</h1>
62
+ <ul className="space-y-3">
63
+ {items.map((item, idx) => (
64
+ <li key={idx}>
65
+ <details open={idx === 0} className="group card overflow-hidden">
66
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 font-sans text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/60 [&::-webkit-details-marker]:hidden">
67
+ {item.question}
68
+ <span
69
+ aria-hidden="true"
70
+ className="shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-180"
71
+ >
72
+ <svg
73
+ width="20"
74
+ height="20"
75
+ viewBox="0 0 24 24"
76
+ fill="none"
77
+ stroke="currentColor"
78
+ strokeWidth="1.75"
79
+ strokeLinecap="round"
80
+ strokeLinejoin="round"
81
+ >
82
+ <path d="m6 9 6 6 6-6" />
83
+ </svg>
84
+ </span>
85
+ </summary>
86
+ <div
87
+ className="prose-store border-t px-5 py-4"
88
+ dangerouslySetInnerHTML={{ __html: sanitizeHtml(item.answer) }}
89
+ />
90
+ </details>
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ </section>
95
+ );
96
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Auth-aware account control for the site header.
5
+ *
6
+ * The header itself is a server component fed by merchant-configured Content,
7
+ * so it can't read client auth state. This small client island bridges that:
8
+ * it links to `/account` when the visitor is signed in (the account page shows
9
+ * profile + orders + sign-out) and to `/login` otherwise.
10
+ */
11
+ import * as React from 'react';
12
+ import { Link } from '@/core/lib/navigation';
13
+ import { useAuth } from '@/core/providers/store-provider';
14
+ import { useTranslations } from '@/core/lib/translations';
15
+ import { IconUser } from '@/ui/shared/icons';
16
+
17
+ export function HeaderAccount() {
18
+ const { isLoggedIn } = useAuth();
19
+ const t = useTranslations('nav');
20
+
21
+ // Guests land on /login; signed-in visitors go to /account (which itself
22
+ // bounces guests back to /login, so the link is safe even mid auth-resolve).
23
+ const href = isLoggedIn ? '/account' : '/login';
24
+ const label = isLoggedIn ? t('account') : t('login');
25
+
26
+ return (
27
+ <Link
28
+ href={href}
29
+ aria-label={label}
30
+ className="inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground transition-colors hover:bg-secondary"
31
+ >
32
+ <IconUser size={20} />
33
+ <span className="sr-only">{label}</span>
34
+ </Link>
35
+ );
36
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Cart link for the site header — client island so it can read the live
5
+ * item count from useCart(). Icon + count bubble; count hides at zero.
6
+ */
7
+ import * as React from 'react';
8
+ import { Link } from '@/core/lib/navigation';
9
+ import { useCart } from '@/core/providers/store-provider';
10
+ import { useTranslations } from '@/core/lib/translations';
11
+ import { IconBag } from '@/ui/shared/icons';
12
+
13
+ export function HeaderCart() {
14
+ const { itemCount } = useCart();
15
+ const t = useTranslations('nav');
16
+
17
+ return (
18
+ <Link
19
+ id="cart-anchor"
20
+ href="/cart"
21
+ aria-label={t('cart')}
22
+ className="relative inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground transition-colors hover:bg-secondary"
23
+ >
24
+ <IconBag size={20} />
25
+ {itemCount > 0 && (
26
+ <span
27
+ aria-hidden="true"
28
+ className="absolute -top-0.5 -end-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[11px] font-bold leading-none text-primary-foreground"
29
+ >
30
+ {itemCount > 99 ? '99+' : itemCount}
31
+ </span>
32
+ )}
33
+ <span className="sr-only">{t('cart')}</span>
34
+ </Link>
35
+ );
36
+ }
@@ -0,0 +1,137 @@
1
+ <% if (i18nEnabled) { %>
2
+ 'use client';
3
+
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import { usePathname } from 'next/navigation';
6
+ import { supportedLocales } from '@/i18n';
7
+
8
+ const LOCALE_NAMES: Record<string, string> = {
9
+ en: 'English',
10
+ he: 'עברית',
11
+ ar: 'العربية',
12
+ es: 'Español',
13
+ fr: 'Français',
14
+ de: 'Deutsch',
15
+ it: 'Italiano',
16
+ pt: 'Português',
17
+ nl: 'Nederlands',
18
+ ja: '日本語',
19
+ ko: '한국어',
20
+ zh: '中文',
21
+ ru: 'Русский',
22
+ tr: 'Türkçe',
23
+ pl: 'Polski',
24
+ sv: 'Svenska',
25
+ da: 'Dansk',
26
+ no: 'Norsk',
27
+ fi: 'Suomi',
28
+ cs: 'Čeština',
29
+ th: 'ไทย',
30
+ vi: 'Tiếng Việt',
31
+ };
32
+
33
+ const defaultLocale = '<%= defaultLocale %>';
34
+
35
+ /**
36
+ * DESIGN ME — locale switcher in the header; reads supported locales from
37
+ * `@/i18n` and swaps the URL prefix. Keep the aria-label and the redirect
38
+ * logic exactly as-is.
39
+ */
40
+ export function LanguageSwitcher() {
41
+ const pathname = usePathname();
42
+ const firstSeg = pathname.split('/')[1];
43
+ const currentLocale = supportedLocales.includes(firstSeg as (typeof supportedLocales)[number])
44
+ ? firstSeg
45
+ : defaultLocale;
46
+
47
+ function switchLocale(newLocale: string) {
48
+ // Strip any existing locale segment, then re-add only if the new one
49
+ // isn't the default — matches the as-needed prefix scheme in middleware.
50
+ const segments = pathname.split('/');
51
+ if (supportedLocales.includes(segments[1] as (typeof supportedLocales)[number])) {
52
+ segments.splice(1, 1);
53
+ }
54
+ const bare = segments.join('/') || '/';
55
+ window.location.href =
56
+ newLocale === defaultLocale ? bare : `/${newLocale}${bare === '/' ? '' : bare}`;
57
+ }
58
+
59
+ const [open, setOpen] = useState(false);
60
+ const rootRef = useRef<HTMLDivElement>(null);
61
+
62
+ // Close on outside click / Escape — a native <select> looks cheap next to
63
+ // the rest of the header, so this is a small styled popover instead.
64
+ useEffect(() => {
65
+ if (!open) return;
66
+ function onDown(e: MouseEvent) {
67
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
68
+ }
69
+ function onKey(e: KeyboardEvent) {
70
+ if (e.key === 'Escape') setOpen(false);
71
+ }
72
+ document.addEventListener('mousedown', onDown);
73
+ document.addEventListener('keydown', onKey);
74
+ return () => {
75
+ document.removeEventListener('mousedown', onDown);
76
+ document.removeEventListener('keydown', onKey);
77
+ };
78
+ }, [open]);
79
+
80
+ if (supportedLocales.length <= 1) return null;
81
+
82
+ return (
83
+ <div ref={rootRef} className="relative">
84
+ <button
85
+ type="button"
86
+ aria-label="Select language"
87
+ aria-expanded={open}
88
+ aria-haspopup="listbox"
89
+ onClick={() => setOpen((v) => !v)}
90
+ className="text-foreground/70 hover:text-foreground hover:bg-secondary/60 inline-flex h-9 items-center gap-1.5 rounded-full px-3 text-sm font-medium transition-colors"
91
+ >
92
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} className="h-4 w-4" aria-hidden="true">
93
+ <circle cx="12" cy="12" r="9" />
94
+ <path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.7-3.8-9S9.5 5.6 12 3z" />
95
+ </svg>
96
+ {LOCALE_NAMES[currentLocale] || currentLocale}
97
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden="true">
98
+ <path d="M6 9l6 6 6-6" />
99
+ </svg>
100
+ </button>
101
+
102
+ {open && (
103
+ <ul
104
+ role="listbox"
105
+ aria-label="Select language"
106
+ className="border-border/60 bg-background absolute end-0 top-full z-50 mt-2 min-w-36 overflow-hidden rounded-xl border py-1 shadow-lg"
107
+ >
108
+ {supportedLocales.map((locale) => (
109
+ <li key={locale}>
110
+ <button
111
+ type="button"
112
+ role="option"
113
+ aria-selected={locale === currentLocale}
114
+ onClick={() => {
115
+ setOpen(false);
116
+ if (locale !== currentLocale) switchLocale(locale);
117
+ }}
118
+ className={`hover:bg-secondary/70 flex w-full items-center justify-between px-4 py-2 text-sm transition-colors ${
119
+ locale === currentLocale ? 'text-primary font-semibold' : 'text-foreground/80'
120
+ }`}
121
+ >
122
+ {LOCALE_NAMES[locale] || locale}
123
+ {locale === currentLocale && (
124
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} className="h-4 w-4" aria-hidden="true">
125
+ <path d="M5 13l4 4L19 7" />
126
+ </svg>
127
+ )}
128
+ </button>
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ )}
133
+ </div>
134
+ );
135
+ }
136
+
137
+ <% } %>
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Dashboard nav items default to CMS-page URLs (`/pages/<slug>`), but this
3
+ * storefront ships dedicated routes for some of those destinations. Remap the
4
+ * known slugs so merchant nav always lands on the richer page instead of a
5
+ * thin (or missing) CMS page. Unknown URLs pass through untouched.
6
+ */
7
+ const ROUTE_ALIASES: Record<string, string> = {
8
+ '/pages/contact': '/contact',
9
+ '/pages/faq': '/faq',
10
+ };
11
+
12
+ export function resolveNavUrl(url: string): string {
13
+ return ROUTE_ALIASES[url] ?? url;
14
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Inline rich-text block (bare skeleton) — fully driven by the merchant's
3
+ * dashboard.
4
+ *
5
+ * The merchant configures rich-text blocks in the Brainerce dashboard
6
+ * under Sell → Content → Rich Text. Use this component anywhere on a page
7
+ * to drop a merchant-editable HTML block (intro copy, banner descriptions,
8
+ * inline announcements that aren't tied to the top-of-page Announcement
9
+ * bar, etc.).
10
+ *
11
+ * Security: HTML is sanitized via `sanitizeHtml` (isomorphic-dompurify)
12
+ * before injecting via dangerouslySetInnerHTML.
13
+ *
14
+ * DESIGN ME — merchant-editable HTML block; fed by
15
+ * `client.content.richText.get(key, locale)` pre-fetched in the route.
16
+ * Style the injected HTML via the wrapper `className`.
17
+ */
18
+ import * as React from 'react';
19
+ import type { Content } from 'brainerce';
20
+ import { sanitizeHtml } from '@/core/lib/sanitize';
21
+
22
+ interface RichTextBlockProps {
23
+ /** Pre-fetched RICH_TEXT row, e.g. via `client.content.richText.get(key, locale)`. */
24
+ block: Content<'RICH_TEXT'> | null;
25
+ /** Optional wrapper className for layout positioning. */
26
+ className?: string;
27
+ }
28
+
29
+ export function RichTextBlock({ block, className }: RichTextBlockProps) {
30
+ if (!block?.data?.html) return null;
31
+ return (
32
+ <div className={className} dangerouslySetInnerHTML={{ __html: sanitizeHtml(block.data.html) }} />
33
+ );
34
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Storefront footer — driven by the merchant's dashboard configuration
3
+ * (Sell → Content → Footer), rendered as a deep-forest "signature" band:
4
+ * brand block + tagline + social, merchant link columns, copyright bar.
5
+ *
6
+ * API contract:
7
+ * GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
8
+ * Returns null on 404 — the fallback branch renders the same designed
9
+ * chrome with essential storefront links so the page never looks broken.
10
+ */
11
+ import * as React from 'react';
12
+ import type { Content } from 'brainerce';
13
+ <% if (i18nEnabled) { %>
14
+ import { getMessages, defaultLocale } from '@/i18n';
15
+ <% } else { %>
16
+ import { messages as staticMessages, defaultLocale } from '@/i18n';
17
+ <% } %>
18
+ import { IconSocial, IconShield } from '@/ui/shared/icons';
19
+ import { resolveNavUrl } from '@/ui/layout/nav-url';
20
+
21
+ interface SiteFooterProps {
22
+ /** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
23
+ footer: Content<'FOOTER'> | null;
24
+ /** Store name shown in the brand block + fallback copyright. */
25
+ storeName?: string;
26
+ /** Store locale for the translation-key copy (server component). */
27
+ locale?: string;
28
+ }
29
+
30
+ export async function SiteFooter({ footer, storeName, locale = defaultLocale }: SiteFooterProps) {
31
+ <% if (i18nEnabled) { %>
32
+ const messages = await getMessages(locale);
33
+ <% } else { %>
34
+ const messages = staticMessages;
35
+ <% } %>
36
+ const t = (messages.footer ?? {}) as Record<string, string>;
37
+
38
+ const data = footer?.data;
39
+ const columns = data?.columns ?? [];
40
+ const social = data?.social ?? [];
41
+ const year = new Date().getFullYear();
42
+ const brandLabel = storeName ?? 'Store';
43
+
44
+ const fallbackLinks = [
45
+ { url: '/products', label: t.linkProducts },
46
+ { url: '/faq', label: t.linkFaq },
47
+ { url: '/contact', label: t.linkContact },
48
+ { url: '/cart', label: t.linkCart },
49
+ ];
50
+
51
+ return (
52
+ <footer className="mt-auto bg-primary text-primary-foreground">
53
+ <div className="container-page grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-4 lg:py-16">
54
+ {/* Brand block */}
55
+ <div className="sm:col-span-2 lg:col-span-2 lg:pe-16">
56
+ <a href="/" className="font-display text-2xl font-semibold">
57
+ {brandLabel}
58
+ </a>
59
+ <p className="mt-3 max-w-sm text-sm leading-6 text-primary-foreground/70">
60
+ {t.tagline}
61
+ </p>
62
+
63
+ {social.length > 0 && (
64
+ <div className="mt-6">
65
+ <p className="mb-3 text-xs font-semibold uppercase tracking-widest text-primary-foreground/60">
66
+ {t.followUs}
67
+ </p>
68
+ <ul className="flex flex-wrap items-center gap-2">
69
+ {social.map((entry, idx) => (
70
+ <li key={`${entry.platform}-${idx}`}>
71
+ <a
72
+ href={entry.url}
73
+ target="_blank"
74
+ rel="noopener noreferrer"
75
+ aria-label={entry.platform}
76
+ className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-primary-foreground/25 text-primary-foreground/85 transition-colors hover:bg-primary-foreground hover:text-primary"
77
+ >
78
+ <IconSocial platform={entry.platform} size={20} />
79
+ </a>
80
+ </li>
81
+ ))}
82
+ </ul>
83
+ </div>
84
+ )}
85
+ </div>
86
+
87
+ {/* Merchant link columns (or essential fallback links) */}
88
+ {columns.length > 0 ? (
89
+ columns.map((col, idx) => (
90
+ <nav key={`${col.title}-${idx}`} aria-label={col.title}>
91
+ <h3 className="mb-4 font-sans text-sm font-semibold text-primary-foreground">
92
+ {col.title}
93
+ </h3>
94
+ <ul className="space-y-2.5">
95
+ {col.links.map((link, linkIdx) => (
96
+ <li key={`${link.url}-${linkIdx}`}>
97
+ <a
98
+ href={resolveNavUrl(link.url)}
99
+ className="text-sm text-primary-foreground/70 transition-colors hover:text-primary-foreground"
100
+ >
101
+ {link.label}
102
+ </a>
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ </nav>
107
+ ))
108
+ ) : (
109
+ <nav aria-label={t.quickLinksTitle}>
110
+ <h3 className="mb-4 font-sans text-sm font-semibold text-primary-foreground">
111
+ {t.quickLinksTitle}
112
+ </h3>
113
+ <ul className="space-y-2.5">
114
+ {fallbackLinks.map((link) => (
115
+ <li key={link.url}>
116
+ <a
117
+ href={link.url}
118
+ className="text-sm text-primary-foreground/70 transition-colors hover:text-primary-foreground"
119
+ >
120
+ {link.label}
121
+ </a>
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ </nav>
126
+ )}
127
+ </div>
128
+
129
+ {/* Bottom bar */}
130
+ <div className="border-t border-primary-foreground/15">
131
+ <div className="container-page flex flex-col items-center justify-between gap-3 py-5 text-xs text-primary-foreground/60 sm:flex-row">
132
+ <p>{data?.copyright ?? `© ${year} ${brandLabel}. ${messages.common?.allRightsReserved ?? ''}`}</p>
133
+ <p className="inline-flex items-center gap-1.5">
134
+ <IconShield size={16} />
135
+ {t.securePayments}
136
+ </p>
137
+ </div>
138
+ </div>
139
+ </footer>
140
+ );
141
+ }