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.
- package/README.md +130 -92
- package/dist/index.js +39 -17
- package/messages/en.json +9 -2
- package/messages/he.json +9 -2
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +10 -1
- package/templates/nextjs/base/AGENTS.md.ejs +229 -207
- package/templates/nextjs/base/AI-GUIDE.md +4 -0
- package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
- package/templates/nextjs/base/scripts/connect.mjs +115 -18
- package/templates/nextjs/base/scripts/env-file.mjs +93 -0
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
- package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
- package/templates/nextjs/base/src/app/icon.svg +11 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
- package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
- package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
- package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
- package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
- package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
- package/templates/nextjs/designs/atelier/globals.css +408 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
- package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
- package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -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 {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|