create-brainerce-store 1.84.0 → 1.86.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 (52) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +9 -2
  4. package/messages/he.json +9 -2
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
  15. package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
  16. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  17. package/templates/nextjs/base/src/app/icon.svg +11 -0
  18. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  19. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  20. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  21. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  22. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
  23. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  24. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
  25. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  26. package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
  27. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  28. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  29. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  30. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  31. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  32. package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
  33. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
  34. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  35. package/templates/nextjs/designs/atelier/globals.css +408 -402
  36. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  37. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  38. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  39. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  40. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  41. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  42. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  43. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  44. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  45. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  46. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
  47. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  48. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
  49. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  50. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
  51. package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -52
  52. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +428 -426
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+
3
+ import { useStoreInfo } from '@/core/providers/store-provider';
4
+ import { resolveStoreName } from '@/core/lib/store-name';
5
+
6
+ /**
7
+ * Client twin of `resolveStoreName()` — the one call for "what is this store
8
+ * called?" inside `'use client'` components, exactly as `useCurrency()` is
9
+ * for the currency code. Live `storeInfo.name` wins; the build-time
10
+ * `NEXT_PUBLIC_STORE_NAME` and then the scaffold literal fill in before the
11
+ * provider has resolved. See `store-name.ts` for the chain.
12
+ *
13
+ * @param override pass when the caller already has a more specific name.
14
+ */
15
+ export function useStoreName(override?: string | null): string {
16
+ const { storeInfo } = useStoreInfo();
17
+ return resolveStoreName(storeInfo, override);
18
+ }
@@ -1,27 +1,27 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { useTranslations } from '@/core/lib/translations';
5
- import { Button } from '@/components/ui/button';
6
-
7
- /** Homepage hero: welcome headline, subtitle, and shop-now CTA. */
8
- export function HeroSection({ storeName }: { storeName?: string | null }) {
9
- const t = useTranslations('home');
10
- const tc = useTranslations('common');
11
-
12
- return (
13
- <section className="bg-muted">
14
- <div className="mx-auto max-w-7xl px-4 py-20 text-center sm:px-6 lg:px-8">
15
- <h1 className="text-foreground text-4xl font-bold tracking-tight sm:text-5xl">
16
- {t('welcomeTo')} {storeName || tc('store')}
17
- </h1>
18
- <p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
19
- {t('heroSubtitle')}
20
- </p>
21
- <Button asChild size="lg" className="mt-8 rounded px-6 text-base">
22
- <Link href="/products">{tc('shopNow')}</Link>
23
- </Button>
24
- </div>
25
- </section>
26
- );
27
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { useTranslations } from '@/core/lib/translations';
5
+ import { Button } from '@/components/ui/button';
6
+
7
+ /** Homepage hero: welcome headline, subtitle, and shop-now CTA. */
8
+ export function HeroSection({ storeName }: { storeName?: string | null }) {
9
+ const t = useTranslations('home');
10
+ const tc = useTranslations('common');
11
+
12
+ return (
13
+ <section className="bg-muted">
14
+ <div className="mx-auto max-w-7xl px-4 py-20 text-center sm:px-6 lg:px-8">
15
+ <h1 className="text-foreground text-4xl font-bold ltr:tracking-tight sm:text-5xl">
16
+ {t('welcomeTo')} {storeName || tc('store')}
17
+ </h1>
18
+ <p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
19
+ {t('heroSubtitle')}
20
+ </p>
21
+ <Button asChild size="lg" className="mt-8 rounded px-6 text-base">
22
+ <Link href="/products">{tc('shopNow')}</Link>
23
+ </Button>
24
+ </div>
25
+ </section>
26
+ );
27
+ }
@@ -205,7 +205,7 @@ export function HeaderSearch({ className }: HeaderSearchProps) {
205
205
  {index === 0 && productCount > 0 && (
206
206
  <li
207
207
  role="presentation"
208
- className="text-muted-foreground px-4 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide"
208
+ className="text-muted-foreground px-4 pb-1 pt-2 text-[11px] font-semibold ltr:uppercase ltr:tracking-wide"
209
209
  >
210
210
  {tp('pageTitle')}
211
211
  </li>
@@ -213,7 +213,7 @@ export function HeaderSearch({ className }: HeaderSearchProps) {
213
213
  {index === productCount && option.kind === 'category' && (
214
214
  <li
215
215
  role="presentation"
216
- className="text-muted-foreground px-4 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide"
216
+ className="text-muted-foreground px-4 pb-1 pt-2 text-[11px] font-semibold ltr:uppercase ltr:tracking-wide"
217
217
  >
218
218
  {t('categories')}
219
219
  </li>
@@ -1,160 +1,160 @@
1
- /**
2
- * Storefront footer — fully driven by the merchant's dashboard configuration.
3
- *
4
- * The merchant configures the footer in the Brainerce dashboard under
5
- * Sell → Content → Footer
6
- *
7
- * Renders a brand block (store-name + social icons), the merchant's link
8
- * columns, and a copyright bar. Social icons come from lucide-react where
9
- * available; X (Twitter) and TikTok have no lucide glyph, so those two stay
10
- * as local inline SVGs. Unknown platforms fall back to a letter avatar.
11
- *
12
- * API contract:
13
- * GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
14
- * Returns null on 404 — we render a minimal fallback so the layout never
15
- * crashes when the merchant hasn't seeded the footer yet.
16
- */
17
- import * as React from 'react';
18
- import { Facebook, Instagram, Linkedin, Youtube } from 'lucide-react';
19
- import type { Content } from 'brainerce';
20
- // Locale-aware Link (client component — fine to render from this server
21
- // component). Merchant-configured link columns keep plain <a> since their
22
- // URLs are arbitrary (may be external).
23
- import { Link } from '@/core/lib/navigation';
24
- // Client component — the signup posts from the browser, which is also what
25
- // satisfies the endpoint's browser-Origin requirement.
26
- import { NewsletterSignup } from './newsletter-signup';
27
-
28
- interface SiteFooterProps {
29
- /** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
30
- footer: Content<'FOOTER'> | null;
31
- /** Store name shown in the brand block + fallback copyright. */
32
- storeName?: string;
33
- }
34
-
35
- type SvgComponent = React.ComponentType<React.SVGProps<SVGSVGElement>>;
36
-
37
- // No lucide equivalent — keep as local SVGs.
38
- const XIcon: SvgComponent = (props) => (
39
- <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
40
- <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
41
- </svg>
42
- );
43
-
44
- const TikTokIcon: SvgComponent = (props) => (
45
- <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
46
- <path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5.8 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1.84-.1z" />
47
- </svg>
48
- );
49
-
50
- const SOCIAL_ICONS: Record<string, SvgComponent> = {
51
- instagram: Instagram,
52
- facebook: Facebook,
53
- x: XIcon,
54
- twitter: XIcon,
55
- linkedin: Linkedin,
56
- youtube: Youtube,
57
- tiktok: TikTokIcon,
58
- };
59
-
60
- export function SiteFooter({ footer, storeName }: SiteFooterProps) {
61
- const data = footer?.data;
62
- const columns = data?.columns ?? [];
63
- const social = data?.social ?? [];
64
- const year = new Date().getFullYear();
65
- const brandLabel = storeName ?? 'Store';
66
-
67
- if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
68
- return (
69
- <footer className="border-border bg-muted/30 text-muted-foreground mt-16 border-t py-8 text-sm">
70
- <div className="mx-auto max-w-7xl space-y-6 px-4 text-center sm:px-6 lg:px-8">
71
- <div className="mx-auto max-w-md text-start">
72
- <NewsletterSignup />
73
- </div>
74
- <div>© {year} {brandLabel}. All rights reserved.</div>
75
- </div>
76
- </footer>
77
- );
78
- }
79
-
80
- return (
81
- <footer className="border-border bg-muted/30 mt-16 border-t">
82
- <div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
83
- <div className="grid grid-cols-1 gap-10 md:grid-cols-12">
84
- <div className="md:col-span-4">
85
- <Link
86
- href="/"
87
- className="text-foreground text-lg font-semibold tracking-tight"
88
- >
89
- {brandLabel}
90
- </Link>
91
- {social.length > 0 ? (
92
- <div className="mt-4 flex flex-wrap items-center gap-2">
93
- {social.map((entry, idx) => {
94
- const key = entry.platform.toLowerCase();
95
- const Icon = SOCIAL_ICONS[key];
96
- return (
97
- <a
98
- key={`${entry.platform}-${idx}`}
99
- href={entry.url}
100
- target="_blank"
101
- rel="noopener noreferrer"
102
- aria-label={entry.platform}
103
- className="border-border hover:border-foreground/40 hover:text-foreground text-muted-foreground inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors"
104
- >
105
- {Icon ? (
106
- <Icon className="h-4 w-4" />
107
- ) : (
108
- <span className="text-xs font-medium uppercase">
109
- {entry.platform.charAt(0)}
110
- </span>
111
- )}
112
- </a>
113
- );
114
- })}
115
- </div>
116
- ) : null}
117
-
118
- {/*
119
- Newsletter signup. Remove this block if the merchant does not want
120
- a mailing list — nothing else depends on it. Confirmed opt-in: the
121
- address is not subscribed until the recipient clicks the link they
122
- are mailed, which is why the success copy says "check your email".
123
- */}
124
- <div className="mt-8">
125
- <NewsletterSignup />
126
- </div>
127
- </div>
128
-
129
- {columns.length > 0 ? (
130
- <div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-8">
131
- {columns.map((col, idx) => (
132
- <div key={`${col.title}-${idx}`}>
133
- <h3 className="text-foreground mb-3 text-sm font-semibold tracking-wide">
134
- {col.title}
135
- </h3>
136
- <ul className="space-y-2">
137
- {col.links.map((link, linkIdx) => (
138
- <li key={`${link.url}-${linkIdx}`}>
139
- <a
140
- href={link.url}
141
- className="text-muted-foreground hover:text-foreground text-sm transition-colors"
142
- >
143
- {link.label}
144
- </a>
145
- </li>
146
- ))}
147
- </ul>
148
- </div>
149
- ))}
150
- </div>
151
- ) : null}
152
- </div>
153
-
154
- <div className="border-border text-muted-foreground mt-10 flex flex-col items-center justify-between gap-2 border-t pt-6 text-xs sm:flex-row">
155
- <span>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</span>
156
- </div>
157
- </div>
158
- </footer>
159
- );
160
- }
1
+ /**
2
+ * Storefront footer — fully driven by the merchant's dashboard configuration.
3
+ *
4
+ * The merchant configures the footer in the Brainerce dashboard under
5
+ * Sell → Content → Footer
6
+ *
7
+ * Renders a brand block (store-name + social icons), the merchant's link
8
+ * columns, and a copyright bar. Social icons come from lucide-react where
9
+ * available; X (Twitter) and TikTok have no lucide glyph, so those two stay
10
+ * as local inline SVGs. Unknown platforms fall back to a letter avatar.
11
+ *
12
+ * API contract:
13
+ * GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
14
+ * Returns null on 404 — we render a minimal fallback so the layout never
15
+ * crashes when the merchant hasn't seeded the footer yet.
16
+ */
17
+ import * as React from 'react';
18
+ import { Facebook, Instagram, Linkedin, Youtube } from 'lucide-react';
19
+ import type { Content } from 'brainerce';
20
+ // Locale-aware Link (client component — fine to render from this server
21
+ // component). Merchant-configured link columns keep plain <a> since their
22
+ // URLs are arbitrary (may be external).
23
+ import { Link } from '@/core/lib/navigation';
24
+ // Client component — the signup posts from the browser, which is also what
25
+ // satisfies the endpoint's browser-Origin requirement.
26
+ import { NewsletterSignup } from './newsletter-signup';
27
+
28
+ interface SiteFooterProps {
29
+ /** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
30
+ footer: Content<'FOOTER'> | null;
31
+ /** Store name shown in the brand block + fallback copyright. */
32
+ storeName?: string;
33
+ }
34
+
35
+ type SvgComponent = React.ComponentType<React.SVGProps<SVGSVGElement>>;
36
+
37
+ // No lucide equivalent — keep as local SVGs.
38
+ const XIcon: SvgComponent = (props) => (
39
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
40
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
41
+ </svg>
42
+ );
43
+
44
+ const TikTokIcon: SvgComponent = (props) => (
45
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
46
+ <path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5.8 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1.84-.1z" />
47
+ </svg>
48
+ );
49
+
50
+ const SOCIAL_ICONS: Record<string, SvgComponent> = {
51
+ instagram: Instagram,
52
+ facebook: Facebook,
53
+ x: XIcon,
54
+ twitter: XIcon,
55
+ linkedin: Linkedin,
56
+ youtube: Youtube,
57
+ tiktok: TikTokIcon,
58
+ };
59
+
60
+ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
61
+ const data = footer?.data;
62
+ const columns = data?.columns ?? [];
63
+ const social = data?.social ?? [];
64
+ const year = new Date().getFullYear();
65
+ const brandLabel = storeName ?? 'Store';
66
+
67
+ if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
68
+ return (
69
+ <footer className="border-border bg-muted/30 text-muted-foreground mt-16 border-t py-8 text-sm">
70
+ <div className="mx-auto max-w-7xl space-y-6 px-4 text-center sm:px-6 lg:px-8">
71
+ <div className="mx-auto max-w-md text-start">
72
+ <NewsletterSignup />
73
+ </div>
74
+ <div>© {year} {brandLabel}. All rights reserved.</div>
75
+ </div>
76
+ </footer>
77
+ );
78
+ }
79
+
80
+ return (
81
+ <footer className="border-border bg-muted/30 mt-16 border-t">
82
+ <div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
83
+ <div className="grid grid-cols-1 gap-10 md:grid-cols-12">
84
+ <div className="md:col-span-4">
85
+ <Link
86
+ href="/"
87
+ className="text-foreground text-lg font-semibold ltr:tracking-tight"
88
+ >
89
+ {brandLabel}
90
+ </Link>
91
+ {social.length > 0 ? (
92
+ <div className="mt-4 flex flex-wrap items-center gap-2">
93
+ {social.map((entry, idx) => {
94
+ const key = entry.platform.toLowerCase();
95
+ const Icon = SOCIAL_ICONS[key];
96
+ return (
97
+ <a
98
+ key={`${entry.platform}-${idx}`}
99
+ href={entry.url}
100
+ target="_blank"
101
+ rel="noopener noreferrer"
102
+ aria-label={entry.platform}
103
+ className="border-border hover:border-foreground/40 hover:text-foreground text-muted-foreground inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors"
104
+ >
105
+ {Icon ? (
106
+ <Icon className="h-4 w-4" />
107
+ ) : (
108
+ <span className="text-xs font-medium uppercase">
109
+ {entry.platform.charAt(0)}
110
+ </span>
111
+ )}
112
+ </a>
113
+ );
114
+ })}
115
+ </div>
116
+ ) : null}
117
+
118
+ {/*
119
+ Newsletter signup. Remove this block if the merchant does not want
120
+ a mailing list — nothing else depends on it. Confirmed opt-in: the
121
+ address is not subscribed until the recipient clicks the link they
122
+ are mailed, which is why the success copy says "check your email".
123
+ */}
124
+ <div className="mt-8">
125
+ <NewsletterSignup />
126
+ </div>
127
+ </div>
128
+
129
+ {columns.length > 0 ? (
130
+ <div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-8">
131
+ {columns.map((col, idx) => (
132
+ <div key={`${col.title}-${idx}`}>
133
+ <h3 className="text-foreground mb-3 text-sm font-semibold ltr:tracking-wide">
134
+ {col.title}
135
+ </h3>
136
+ <ul className="space-y-2">
137
+ {col.links.map((link, linkIdx) => (
138
+ <li key={`${link.url}-${linkIdx}`}>
139
+ <a
140
+ href={link.url}
141
+ className="text-muted-foreground hover:text-foreground text-sm transition-colors"
142
+ >
143
+ {link.label}
144
+ </a>
145
+ </li>
146
+ ))}
147
+ </ul>
148
+ </div>
149
+ ))}
150
+ </div>
151
+ ) : null}
152
+ </div>
153
+
154
+ <div className="border-border text-muted-foreground mt-10 flex flex-col items-center justify-between gap-2 border-t pt-6 text-xs sm:flex-row">
155
+ <span>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</span>
156
+ </div>
157
+ </div>
158
+ </footer>
159
+ );
160
+ }
@@ -53,7 +53,7 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
53
53
  return (
54
54
  <header className="border-border bg-background/95 supports-[backdrop-filter]:bg-background/70 sticky top-0 z-40 border-b backdrop-blur">
55
55
  <div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
56
- <Link href="/" className="text-lg font-semibold tracking-tight" aria-label={brandLabel}>
56
+ <Link href="/" className="text-lg font-semibold ltr:tracking-tight" aria-label={brandLabel}>
57
57
  {brandLabel}
58
58
  </Link>
59
59
  {/* Search lives in the fallback branch too — a store whose merchant
@@ -88,7 +88,7 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
88
88
  <div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
89
89
  <Link
90
90
  href="/"
91
- className="flex items-center gap-2 font-semibold tracking-tight"
91
+ className="flex items-center gap-2 font-semibold ltr:tracking-tight"
92
92
  aria-label={brandLabel}
93
93
  >
94
94
  {logo ? (
@@ -1,62 +1,85 @@
1
- 'use client';
2
-
3
- import { formatPrice } from 'brainerce';
4
- import { Badge } from '@/components/ui/badge';
5
- import { useCurrency } from '@/core/lib/use-currency';
6
- import { cn } from '@/core/lib/utils';
7
-
8
- interface PriceDisplayProps {
9
- price: string | number;
10
- salePrice?: string | number | null;
11
- currency?: string;
12
- className?: string;
13
- size?: 'sm' | 'md' | 'lg';
14
- }
15
-
16
- const sizeClasses = {
17
- sm: 'text-sm',
18
- md: 'text-base',
19
- lg: 'text-xl font-semibold',
20
- };
21
-
22
- export function PriceDisplay({
23
- price,
24
- salePrice,
25
- currency,
26
- className,
27
- size = 'md',
28
- }: PriceDisplayProps) {
29
- const currencyCode = useCurrency(currency);
30
-
31
- const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
- const sale =
33
- salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
34
- const isOnSale = sale !== null && sale < basePrice;
35
-
36
- const discountPercent =
37
- isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
38
-
39
- return (
40
- <span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
41
- {isOnSale ? (
42
- <>
43
- <span className="text-destructive font-medium">
44
- {formatPrice(sale!, { currency: currencyCode }) as string}
45
- </span>
46
- <span className="text-muted-foreground text-[0.85em] line-through">
47
- {formatPrice(basePrice, { currency: currencyCode }) as string}
48
- </span>
49
- {discountPercent > 0 && (
50
- <Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
51
- -{discountPercent}%
52
- </Badge>
53
- )}
54
- </>
55
- ) : (
56
- <span className="text-foreground font-medium">
57
- {formatPrice(basePrice, { currency: currencyCode }) as string}
58
- </span>
59
- )}
60
- </span>
61
- );
62
- }
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { useCurrency } from '@/core/lib/use-currency';
6
+ import { useTranslations } from '@/core/lib/translations';
7
+ import { useStoreInfo } from '@/core/providers/store-provider';
8
+ import { cn } from '@/core/lib/utils';
9
+
10
+ interface PriceDisplayProps {
11
+ price: string | number;
12
+ salePrice?: string | number | null;
13
+ currency?: string;
14
+ className?: string;
15
+ size?: 'sm' | 'md' | 'lg';
16
+ }
17
+
18
+ const sizeClasses = {
19
+ sm: 'text-sm',
20
+ md: 'text-base',
21
+ lg: 'text-xl font-semibold',
22
+ };
23
+
24
+ export function PriceDisplay({
25
+ price,
26
+ salePrice,
27
+ currency,
28
+ className,
29
+ size = 'md',
30
+ }: PriceDisplayProps) {
31
+ const currencyCode = useCurrency(currency);
32
+ const { storeInfo } = useStoreInfo();
33
+ const t = useTranslations('common');
34
+
35
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
36
+ const sale =
37
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
38
+ const isOnSale = sale !== null && sale < basePrice;
39
+
40
+ const discountPercent =
41
+ isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
42
+
43
+ // FX overlay disclosure (see core/lib/display-price.ts): `currency` is the
44
+ // region-converted DISPLAY currency when it differs from the store's own
45
+ // base currency. The shopper is still charged in the base currency at
46
+ // checkout, so every converted price needs a visible "estimated" cue —
47
+ // otherwise nothing on screen explains why the cart/checkout total switches
48
+ // currency from what was just browsed.
49
+ const isConverted = !!currency && !!storeInfo?.currency && currency !== storeInfo.currency;
50
+
51
+ return (
52
+ <span className={cn('inline-flex flex-wrap items-center gap-2', sizeClasses[size], className)}>
53
+ {isOnSale ? (
54
+ <>
55
+ <span className="text-destructive font-medium">
56
+ {formatPrice(sale!, { currency: currencyCode }) as string}
57
+ </span>
58
+ <span className="text-muted-foreground text-[0.85em] line-through">
59
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
60
+ </span>
61
+ {discountPercent > 0 && (
62
+ <Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
63
+ -{discountPercent}%
64
+ </Badge>
65
+ )}
66
+ </>
67
+ ) : (
68
+ <span className="text-foreground font-medium">
69
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
70
+ </span>
71
+ )}
72
+ {isConverted && (
73
+ <span
74
+ className="text-muted-foreground cursor-help text-[0.7em] font-normal underline decoration-dotted"
75
+ title={t('estimatedPriceTooltip', {
76
+ currency: currencyCode,
77
+ baseCurrency: storeInfo!.currency,
78
+ })}
79
+ >
80
+ {t('estimatedPrice')}
81
+ </span>
82
+ )}
83
+ </span>
84
+ );
85
+ }