create-brainerce-store 1.83.0 → 1.85.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 +3 -0
- package/messages/he.json +3 -0
- 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/forgot-password/page.tsx +3 -2
- 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/auth/oauth-buttons.tsx +174 -137
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +847 -761
- package/templates/nextjs/base/src/core/lib/render-mode.ts +49 -0
- package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
- 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/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/product-card.tsx +271 -268
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +558 -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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PaymentClientSdk } from 'brainerce';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render-mode negotiation: predict which mode a payment intent will come back
|
|
5
|
+
* with, so the checkout can pass the matching return URL BEFORE the intent
|
|
6
|
+
* exists. An iframe provider returns the shopper inside the frame, to a
|
|
7
|
+
* same-origin transition page (`/payment-complete`) that posts to the parent;
|
|
8
|
+
* a redirect provider returns them straight to `/order-confirmation`.
|
|
9
|
+
*
|
|
10
|
+
* This is the platform's own rule, verbatim:
|
|
11
|
+
* 1. no preference -> the provider's default (`renderType`)
|
|
12
|
+
* 2. preference listed in displayModes -> the preference
|
|
13
|
+
* 3. preference not listed -> the provider's default
|
|
14
|
+
* 4. displayModes absent (legacy) -> treated as `[renderType]`
|
|
15
|
+
*
|
|
16
|
+
* The SDK exports the same function as `resolveRenderType` from 'brainerce'.
|
|
17
|
+
* This local copy exists because scaffolded stores pin an exact SDK version
|
|
18
|
+
* that must be at least seven days old on npm (see cli-shared/versions.ts),
|
|
19
|
+
* and the version carrying the export is younger than that. Replace this
|
|
20
|
+
* file's body with `export { resolveRenderType } from 'brainerce'` once the
|
|
21
|
+
* pin reaches it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type PreferredRenderType = 'redirect' | 'iframe';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The mode this storefront asks the platform for, or `undefined` to take each
|
|
28
|
+
* provider's default. Set to `'iframe'` only if the payment step has a frame
|
|
29
|
+
* to render into AND the provider's iframe has been verified on a real
|
|
30
|
+
* checkout; set to `'redirect'` to keep every provider on a full-page
|
|
31
|
+
* redirect. The platform only honours a mode the provider declares, and the
|
|
32
|
+
* checkout always branches on the `renderType` that comes BACK, so a wrong
|
|
33
|
+
* preference degrades to the provider default rather than breaking payment.
|
|
34
|
+
*/
|
|
35
|
+
export const PREFERRED_RENDER_MODE: PreferredRenderType | undefined = undefined;
|
|
36
|
+
|
|
37
|
+
type ProviderSdk = Pick<PaymentClientSdk, 'renderType'> & {
|
|
38
|
+
/** Every mode the provider can serve. Sent by the platform; older SDK types omit it. */
|
|
39
|
+
displayModes?: PaymentClientSdk['renderType'][];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function resolveRenderType(
|
|
43
|
+
sdk: ProviderSdk | null | undefined,
|
|
44
|
+
preferred?: PreferredRenderType
|
|
45
|
+
): PaymentClientSdk['renderType'] {
|
|
46
|
+
if (!sdk) return 'redirect';
|
|
47
|
+
const declared = sdk.displayModes?.length ? sdk.displayModes : [sdk.renderType];
|
|
48
|
+
return preferred && declared.includes(preferred) ? preferred : sdk.renderType;
|
|
49
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for "what is this store called?" — the twin of
|
|
3
|
+
* `resolve-currency.ts` / `use-currency.ts`, and the ONLY file that carries
|
|
4
|
+
* the name the scaffolder knew at generation time.
|
|
5
|
+
*
|
|
6
|
+
* The chain, strongest first:
|
|
7
|
+
* 1. `storeInfo.name` — live, from `/api/vc/<id>/info`. Server components
|
|
8
|
+
* and `generateMetadata` get it from `fetchStoreInfo()`; client
|
|
9
|
+
* components from `useStoreInfo()`. Always right, and free wherever the
|
|
10
|
+
* layout already fetched it.
|
|
11
|
+
* 2. `process.env.NEXT_PUBLIC_STORE_NAME` — written to `.env.local` by
|
|
12
|
+
* `npm run setup` (which `npm run connect` runs for you) and inlined at
|
|
13
|
+
* BUILD time. It is what the client bundle carries before the live data
|
|
14
|
+
* arrives, and what a page renders when the API is unreachable. Refresh
|
|
15
|
+
* it with `npm run setup`, then rebuild.
|
|
16
|
+
* 3. `SCAFFOLD_STORE_NAME` — the literal below, baked when the project was
|
|
17
|
+
* generated. Under `--defer-connection` the scaffolder knew no store, so
|
|
18
|
+
* this is just the project directory name: a placeholder, never a brand.
|
|
19
|
+
* It must never be the only source anywhere (it once was, and a Hebrew
|
|
20
|
+
* sports store went live titled "storefront").
|
|
21
|
+
*
|
|
22
|
+
* ⛔ No imports here, on purpose: this file is used by server components,
|
|
23
|
+
* `generateMetadata`, route handlers, the OG image and `'use client'` hooks
|
|
24
|
+
* alike, so it must never pull in `next/headers` or React.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The name the scaffolder knew — see (3) above. Rendered by the scaffolder
|
|
28
|
+
* as a complete JS string literal (`storeNameJs`), so quotes, backticks and
|
|
29
|
+
* `</script>` inside a real store name are already escaped here; every
|
|
30
|
+
* consumer still escapes for ITS context (React text, JSON-LD, `<title>`). */
|
|
31
|
+
export const SCAFFOLD_STORE_NAME: string = <%- storeNameJs %>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the display name for this store.
|
|
35
|
+
*
|
|
36
|
+
* @param storeInfo result of `fetchStoreInfo()` / `useStoreInfo()` — may be
|
|
37
|
+
* null when the fetch failed or has not resolved yet
|
|
38
|
+
* @param override pass when the caller already has a more specific name
|
|
39
|
+
* (a sales-channel label, an order's captured store name)
|
|
40
|
+
*/
|
|
41
|
+
export function resolveStoreName(
|
|
42
|
+
storeInfo: { name?: string | null } | null | undefined,
|
|
43
|
+
override?: string | null
|
|
44
|
+
): string {
|
|
45
|
+
return (
|
|
46
|
+
override?.trim() ||
|
|
47
|
+
storeInfo?.name?.trim() ||
|
|
48
|
+
process.env.NEXT_PUBLIC_STORE_NAME?.trim() ||
|
|
49
|
+
SCAFFOLD_STORE_NAME
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `<title>` template for pages that set their own title: "Page | Store". */
|
|
54
|
+
export function buildTitleTemplate(storeName: string): string {
|
|
55
|
+
return `%s | ${storeName}`;
|
|
56
|
+
}
|
|
@@ -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 ? (
|