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.
Files changed (43) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +3 -0
  4. package/messages/he.json +3 -0
  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/forgot-password/page.tsx +3 -2
  15. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  16. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  17. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  18. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  19. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  20. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +847 -761
  21. package/templates/nextjs/base/src/core/lib/render-mode.ts +49 -0
  22. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  23. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  24. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  25. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  26. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  27. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  28. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  29. package/templates/nextjs/designs/atelier/globals.css +408 -402
  30. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  31. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  32. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  33. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  34. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  35. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  36. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  37. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  39. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  40. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  41. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +558 -553
  42. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  43. 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 ? (