create-brainerce-store 1.66.0 → 1.67.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 (46) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/nextjs/base/.env.local.ejs +45 -24
  4. package/templates/nextjs/base/.eslintrc.json +51 -57
  5. package/templates/nextjs/base/AGENTS.md.ejs +107 -81
  6. package/templates/nextjs/base/CLAUDE.md.ejs +118 -92
  7. package/templates/nextjs/base/next.config.ts +103 -86
  8. package/templates/nextjs/base/scripts/fetch-store-info.mjs +104 -97
  9. package/templates/nextjs/base/src/app/agents.md/route.ts +4 -3
  10. package/templates/nextjs/base/src/app/api/auth/me/route.ts +65 -59
  11. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +255 -242
  12. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +311 -308
  13. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +277 -276
  14. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +4 -3
  15. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +6 -5
  16. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +46 -46
  17. package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +25 -26
  18. package/templates/nextjs/base/src/app/layout.tsx.ejs +273 -257
  19. package/templates/nextjs/base/src/app/llms.txt/route.ts +4 -3
  20. package/templates/nextjs/base/src/app/opengraph-image.tsx +1 -1
  21. package/templates/nextjs/base/src/app/page.tsx +65 -64
  22. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +92 -92
  23. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -5
  24. package/templates/nextjs/base/src/app/robots.ts +3 -2
  25. package/templates/nextjs/base/src/app/sitemap.ts +4 -3
  26. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +294 -292
  27. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +60 -59
  28. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +60 -61
  29. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +2 -1
  30. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +81 -0
  31. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +60 -110
  32. package/templates/nextjs/base/src/core/lib/site-url.ts +197 -0
  33. package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
  34. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
  35. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +301 -285
  36. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +97 -96
  37. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +142 -141
  38. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +139 -138
  39. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +295 -267
  40. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
  41. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +72 -71
  42. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +83 -82
  43. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +120 -119
  44. package/templates/nextjs/ui-canvas/product/faq-section.tsx.ejs +54 -0
  45. package/templates/nextjs/ui-canvas/product/review-form.tsx +267 -266
  46. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
@@ -1,148 +1,148 @@
1
- import type { ProductReview } from 'brainerce';
2
- import { getServerClient } from '@/core/lib/brainerce';
3
- <% if (i18nEnabled) { %>
4
- import { getMessages, defaultLocale } from '@/i18n';
5
- <% } else { %>
6
- import { messages as staticMessages, defaultLocale } from '@/i18n';
7
- <% } %>
8
- import { ReviewForm } from './review-form';
9
- import { IconStar } from '@/ui/shared/icons';
10
-
11
- // Server component — it renders before any client i18n context exists, so the
12
- // wrapper strings (title / empty state / verified badge) are read straight
13
- // from the `reviews` namespace in messages/, keyed by the store locale.
14
- type ReviewStrings = Record<string, string>;
15
-
16
- async function getReviewStrings(locale: string): Promise<ReviewStrings> {
17
- <% if (i18nEnabled) { %>
18
- return ((await getMessages(locale)).reviews ?? {}) as ReviewStrings;
19
- <% } else { %>
20
- return (staticMessages.reviews ?? {}) as ReviewStrings;
21
- <% } %>
22
- }
23
-
24
- interface ReviewsSectionProps {
25
- productId: string;
26
- initialReviews?: ProductReview[];
27
- locale?: string;
28
- /** Where to send users back to after submit (defaults to the current product URL). */
29
- returnUrl?: string;
30
- }
31
-
32
- /**
33
- * Renders the visible reviews for a product plus a submit form.
34
- *
35
- * Server component — fetches with the SDK on the server so reviews are in
36
- * the initial HTML payload (good for SEO; JSON-LD on the same page already
37
- * carries `aggregateRating` when reviewCount > 0).
38
- */
39
- export async function ReviewsSection({
40
- productId,
41
- initialReviews,
42
- locale = defaultLocale,
43
- }: ReviewsSectionProps) {
44
- const t = await getReviewStrings(locale);
45
- let reviews: ProductReview[] = initialReviews ?? [];
46
-
47
- if (!initialReviews) {
48
- try {
49
- const result = await getServerClient().listProductReviews(productId, { page: 1, limit: 20 });
50
- reviews = result.data;
51
- } catch {
52
- // Review listing disabled at store level or network error — render the form anyway.
53
- reviews = [];
54
- }
55
- }
56
-
57
- const avg =
58
- reviews.length > 0
59
- ? Math.round((reviews.reduce((s, r) => s + r.rating, 0) / reviews.length) * 10) / 10
60
- : null;
61
-
62
- return (
63
- <section aria-labelledby="reviews-heading" className="border-t bg-secondary/40">
64
- <div className="container-narrow grid gap-10 py-14 lg:grid-cols-3 lg:py-16">
65
- {/* Summary column */}
66
- <div>
67
- <h2 id="reviews-heading" className="text-2xl sm:text-3xl">
68
- {t.title}
69
- </h2>
70
- {avg !== null && (
71
- <p className="mt-4 flex items-center gap-3">
72
- <span className="font-display text-4xl text-foreground">{avg}</span>
73
- <span className="inline-flex flex-col gap-1">
74
- <Stars rating={Math.round(avg)} />
75
- <span className="text-xs text-muted-foreground">
76
- ({reviews.length})
77
- </span>
78
- </span>
79
- </p>
80
- )}
81
-
82
- {/* Submit / edit form */}
83
- <div className="card mt-6 p-5">
84
- <ReviewForm productId={productId} />
85
- </div>
86
- </div>
87
-
88
- {/* Review list */}
89
- <div className="lg:col-span-2">
90
- {reviews.length === 0 ? (
91
- <div className="card flex h-full min-h-40 flex-col items-center justify-center gap-2 p-8 text-center">
92
- <Stars rating={0} />
93
- <p className="font-medium text-foreground">{t.beFirst ?? t.noReviews}</p>
94
- <p className="text-sm text-muted-foreground">{t.noReviews}</p>
95
- </div>
96
- ) : (
97
- <ul className="space-y-4">
98
- {reviews.map((review) => (
99
- <ReviewCard key={review.id} review={review} verifiedLabel={t.verifiedPurchase} />
100
- ))}
101
- </ul>
102
- )}
103
- </div>
104
- </div>
105
- </section>
106
- );
107
- }
108
-
109
- function ReviewCard({ review, verifiedLabel }: { review: ProductReview; verifiedLabel: string }) {
110
- return (
111
- <li>
112
- <article className="card p-5">
113
- <header className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
114
- <Stars rating={review.rating} />
115
- <span className="text-sm font-semibold text-foreground">{review.authorName}</span>
116
- {review.verifiedPurchase && <span className="badge-soft">{verifiedLabel}</span>}
117
- <time
118
- dateTime={review.createdAt}
119
- className="ms-auto text-xs text-muted-foreground"
120
- >
121
- {new Date(review.createdAt).toLocaleDateString()}
122
- </time>
123
- </header>
124
- {review.body && (
125
- <p className="mt-3 whitespace-pre-line break-words text-sm leading-6 text-foreground">
126
- {review.body}
127
- </p>
128
- )}
129
- </article>
130
- </li>
131
- );
132
- }
133
-
134
- function Stars({ rating }: { rating: number }) {
135
- return (
136
- <span aria-label={`${rating} of 5 stars`} className="inline-flex gap-0.5">
137
- {[1, 2, 3, 4, 5].map((n) => (
138
- <span
139
- key={n}
140
- aria-hidden="true"
141
- className={n <= rating ? 'text-primary' : 'text-border'}
142
- >
143
- <IconStar size={16} filled={n <= rating} />
144
- </span>
145
- ))}
146
- </span>
147
- );
148
- }
1
+ import type { ProductReview } from 'brainerce';
2
+ import { getServerClient } from '@/core/lib/brainerce.server';
3
+ <% if (i18nEnabled) { %>
4
+ import { getMessages, defaultLocale } from '@/i18n';
5
+ <% } else { %>
6
+ import { messages as staticMessages, defaultLocale } from '@/i18n';
7
+ <% } %>
8
+ import { ReviewForm } from './review-form';
9
+ import { IconStar } from '@/ui/shared/icons';
10
+
11
+ // Server component — it renders before any client i18n context exists, so the
12
+ // wrapper strings (title / empty state / verified badge) are read straight
13
+ // from the `reviews` namespace in messages/, keyed by the store locale.
14
+ type ReviewStrings = Record<string, string>;
15
+
16
+ async function getReviewStrings(locale: string): Promise<ReviewStrings> {
17
+ <% if (i18nEnabled) { %>
18
+ return ((await getMessages(locale)).reviews ?? {}) as ReviewStrings;
19
+ <% } else { %>
20
+ return (staticMessages.reviews ?? {}) as ReviewStrings;
21
+ <% } %>
22
+ }
23
+
24
+ interface ReviewsSectionProps {
25
+ productId: string;
26
+ initialReviews?: ProductReview[];
27
+ locale?: string;
28
+ /** Where to send users back to after submit (defaults to the current product URL). */
29
+ returnUrl?: string;
30
+ }
31
+
32
+ /**
33
+ * Renders the visible reviews for a product plus a submit form.
34
+ *
35
+ * Server component — fetches with the SDK on the server so reviews are in
36
+ * the initial HTML payload (good for SEO; JSON-LD on the same page already
37
+ * carries `aggregateRating` when reviewCount > 0).
38
+ */
39
+ export async function ReviewsSection({
40
+ productId,
41
+ initialReviews,
42
+ locale = defaultLocale,
43
+ }: ReviewsSectionProps) {
44
+ const t = await getReviewStrings(locale);
45
+ let reviews: ProductReview[] = initialReviews ?? [];
46
+
47
+ if (!initialReviews) {
48
+ try {
49
+ const result = await (await getServerClient()).listProductReviews(productId, { page: 1, limit: 20 });
50
+ reviews = result.data;
51
+ } catch {
52
+ // Review listing disabled at store level or network error — render the form anyway.
53
+ reviews = [];
54
+ }
55
+ }
56
+
57
+ const avg =
58
+ reviews.length > 0
59
+ ? Math.round((reviews.reduce((s, r) => s + r.rating, 0) / reviews.length) * 10) / 10
60
+ : null;
61
+
62
+ return (
63
+ <section aria-labelledby="reviews-heading" className="border-t bg-secondary/40">
64
+ <div className="container-narrow grid gap-10 py-14 lg:grid-cols-3 lg:py-16">
65
+ {/* Summary column */}
66
+ <div>
67
+ <h2 id="reviews-heading" className="text-2xl sm:text-3xl">
68
+ {t.title}
69
+ </h2>
70
+ {avg !== null && (
71
+ <p className="mt-4 flex items-center gap-3">
72
+ <span className="font-display text-4xl text-foreground">{avg}</span>
73
+ <span className="inline-flex flex-col gap-1">
74
+ <Stars rating={Math.round(avg)} />
75
+ <span className="text-xs text-muted-foreground">
76
+ ({reviews.length})
77
+ </span>
78
+ </span>
79
+ </p>
80
+ )}
81
+
82
+ {/* Submit / edit form */}
83
+ <div className="card mt-6 p-5">
84
+ <ReviewForm productId={productId} />
85
+ </div>
86
+ </div>
87
+
88
+ {/* Review list */}
89
+ <div className="lg:col-span-2">
90
+ {reviews.length === 0 ? (
91
+ <div className="card flex h-full min-h-40 flex-col items-center justify-center gap-2 p-8 text-center">
92
+ <Stars rating={0} />
93
+ <p className="font-medium text-foreground">{t.beFirst ?? t.noReviews}</p>
94
+ <p className="text-sm text-muted-foreground">{t.noReviews}</p>
95
+ </div>
96
+ ) : (
97
+ <ul className="space-y-4">
98
+ {reviews.map((review) => (
99
+ <ReviewCard key={review.id} review={review} verifiedLabel={t.verifiedPurchase} />
100
+ ))}
101
+ </ul>
102
+ )}
103
+ </div>
104
+ </div>
105
+ </section>
106
+ );
107
+ }
108
+
109
+ function ReviewCard({ review, verifiedLabel }: { review: ProductReview; verifiedLabel: string }) {
110
+ return (
111
+ <li>
112
+ <article className="card p-5">
113
+ <header className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
114
+ <Stars rating={review.rating} />
115
+ <span className="text-sm font-semibold text-foreground">{review.authorName}</span>
116
+ {review.verifiedPurchase && <span className="badge-soft">{verifiedLabel}</span>}
117
+ <time
118
+ dateTime={review.createdAt}
119
+ className="ms-auto text-xs text-muted-foreground"
120
+ >
121
+ {new Date(review.createdAt).toLocaleDateString()}
122
+ </time>
123
+ </header>
124
+ {review.body && (
125
+ <p className="mt-3 whitespace-pre-line break-words text-sm leading-6 text-foreground">
126
+ {review.body}
127
+ </p>
128
+ )}
129
+ </article>
130
+ </li>
131
+ );
132
+ }
133
+
134
+ function Stars({ rating }: { rating: number }) {
135
+ return (
136
+ <span aria-label={`${rating} of 5 stars`} className="inline-flex gap-0.5">
137
+ {[1, 2, 3, 4, 5].map((n) => (
138
+ <span
139
+ key={n}
140
+ aria-hidden="true"
141
+ className={n <= rating ? 'text-primary' : 'text-border'}
142
+ >
143
+ <IconStar size={16} filled={n <= rating} />
144
+ </span>
145
+ ))}
146
+ </span>
147
+ );
148
+ }
@@ -1,71 +1,72 @@
1
- /**
2
- * FAQ accordion (bare skeleton) — fully driven by the merchant's dashboard
3
- * configuration.
4
- *
5
- * The merchant configures FAQs in the Brainerce dashboard under
6
- * Sell → Content → FAQ
7
- *
8
- * Each FAQ row has a `key` (e.g. 'main', 'shipping', 'returns'). Pass the
9
- * pre-fetched payload as `faq`; this component knows nothing about which
10
- * key it came from.
11
- *
12
- * Two render branches:
13
- * 1. `faq` has items → accordion of question/answer pairs
14
- * 2. `faq` is null OR has no items → friendly empty state with a contact
15
- * CTA, so the /faq page never looks broken before the merchant seeds.
16
- *
17
- * Security: FAQ answers may contain merchant-authored HTML — we sanitize
18
- * via `sanitizeHtml` before injecting via dangerouslySetInnerHTML.
19
- */
20
- 'use client';
21
-
22
- import * as React from 'react';
23
- import type { Content } from 'brainerce';
24
- import { useTranslations } from '@/core/lib/translations';
25
- import { sanitizeHtml } from '@/core/lib/sanitize';
26
-
27
- interface FaqSectionProps {
28
- /** Pre-fetched FAQ row. `null` renders the empty-state fallback. */
29
- faq: Content<'FAQ'> | null;
30
- }
31
-
32
- export function FaqSection({ faq }: FaqSectionProps) {
33
- const t = useTranslations('content');
34
-
35
- const items = faq?.data?.items ?? [];
36
- const heading = faq?.name || t('faqTitle');
37
-
38
- // Empty state — runs when the merchant hasn't created a FAQ row yet,
39
- // or the row exists but contains no items.
40
- if (!faq || items.length === 0) {
41
- return (
42
- <section>
43
- {/* DESIGN ME — FAQ empty state: heading + contact CTA; fed by Content<'FAQ'> pre-fetched in the /faq route. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
44
- <h1>{heading}</h1>
45
- <p>
46
- <strong>{t('faqEmptyTitle')}</strong>
47
- <br />
48
- {t('faqEmptyBody')}
49
- </p>
50
- <a href="/contact">{t('faqContactCta')}</a>
51
- </section>
52
- );
53
- }
54
-
55
- return (
56
- <section>
57
- {/* DESIGN ME — FAQ accordion: question/answer pairs from Content<'FAQ'> pre-fetched in the /faq route; answers are sanitized merchant HTML. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
58
- <h1>{heading}</h1>
59
- <ul>
60
- {items.map((item, idx) => (
61
- <li key={idx}>
62
- <details open={idx === 0}>
63
- <summary>{item.question}</summary>
64
- <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(item.answer) }} />
65
- </details>
66
- </li>
67
- ))}
68
- </ul>
69
- </section>
70
- );
71
- }
1
+ /**
2
+ * FAQ accordion (bare skeleton) — fully driven by the merchant's dashboard
3
+ * configuration.
4
+ *
5
+ * The merchant configures FAQs in the Brainerce dashboard under
6
+ * Sell → Content → FAQ
7
+ *
8
+ * Each FAQ row has a `key` (e.g. 'main', 'shipping', 'returns'). Pass the
9
+ * pre-fetched payload as `faq`; this component knows nothing about which
10
+ * key it came from.
11
+ *
12
+ * Two render branches:
13
+ * 1. `faq` has items → accordion of question/answer pairs
14
+ * 2. `faq` is null OR has no items → friendly empty state with a contact
15
+ * CTA, so the /faq page never looks broken before the merchant seeds.
16
+ *
17
+ * Security: FAQ answers may contain merchant-authored HTML — we sanitize
18
+ * via `sanitizeHtml` before injecting via dangerouslySetInnerHTML.
19
+ */
20
+ 'use client';
21
+
22
+ import * as React from 'react';
23
+ import { Link } from '@/core/lib/navigation';
24
+ import type { Content } from 'brainerce';
25
+ import { useTranslations } from '@/core/lib/translations';
26
+ import { sanitizeHtml } from '@/core/lib/sanitize';
27
+
28
+ interface FaqSectionProps {
29
+ /** Pre-fetched FAQ row. `null` renders the empty-state fallback. */
30
+ faq: Content<'FAQ'> | null;
31
+ }
32
+
33
+ export function FaqSection({ faq }: FaqSectionProps) {
34
+ const t = useTranslations('content');
35
+
36
+ const items = faq?.data?.items ?? [];
37
+ const heading = faq?.name || t('faqTitle');
38
+
39
+ // Empty state — runs when the merchant hasn't created a FAQ row yet,
40
+ // or the row exists but contains no items.
41
+ if (!faq || items.length === 0) {
42
+ return (
43
+ <section>
44
+ {/* DESIGN ME — FAQ empty state: heading + contact CTA; fed by Content<'FAQ'> pre-fetched in the /faq route. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
45
+ <h1>{heading}</h1>
46
+ <p>
47
+ <strong>{t('faqEmptyTitle')}</strong>
48
+ <br />
49
+ {t('faqEmptyBody')}
50
+ </p>
51
+ <Link href="/contact">{t('faqContactCta')}</Link>
52
+ </section>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <section>
58
+ {/* DESIGN ME — FAQ accordion: question/answer pairs from Content<'FAQ'> pre-fetched in the /faq route; answers are sanitized merchant HTML. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
59
+ <h1>{heading}</h1>
60
+ <ul>
61
+ {items.map((item, idx) => (
62
+ <li key={idx}>
63
+ <details open={idx === 0}>
64
+ <summary>{item.question}</summary>
65
+ <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(item.answer) }} />
66
+ </details>
67
+ </li>
68
+ ))}
69
+ </ul>
70
+ </section>
71
+ );
72
+ }
@@ -1,82 +1,83 @@
1
- /**
2
- * Storefront footer (bare skeleton) — fully driven by the merchant's
3
- * dashboard configuration.
4
- *
5
- * The merchant configures the footer in the Brainerce dashboard under
6
- * Sell → Content → Footer
7
- *
8
- * Renders a brand block (store-name + social links), the merchant's link
9
- * columns, and a copyright bar.
10
- *
11
- * API contract:
12
- * GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
13
- * Returns null on 404 — we render a minimal fallback so the layout never
14
- * crashes when the merchant hasn't seeded the footer yet.
15
- */
16
- import * as React from 'react';
17
- import type { Content } from 'brainerce';
18
-
19
- interface SiteFooterProps {
20
- /** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
21
- footer: Content<'FOOTER'> | null;
22
- /** Store name shown in the brand block + fallback copyright. */
23
- storeName?: string;
24
- }
25
-
26
- export function SiteFooter({ footer, storeName }: SiteFooterProps) {
27
- const data = footer?.data;
28
- const columns = data?.columns ?? [];
29
- const social = data?.social ?? [];
30
- const year = new Date().getFullYear();
31
- const brandLabel = storeName ?? 'Store';
32
-
33
- if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
34
- return (
35
- <footer>
36
- {/* DESIGN ME — footer (fallback branch): copyright only; Content<'FOOTER'> pre-fetched in the layout. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
37
- <p>
38
- © {year} {brandLabel}. All rights reserved.
39
- </p>
40
- </footer>
41
- );
42
- }
43
-
44
- return (
45
- <footer>
46
- {/* DESIGN ME — footer: brand block, merchant link columns and social links (Content<'FOOTER'> pre-fetched in the layout), copyright bar. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
47
- <div>
48
- <a href="/">{brandLabel}</a>
49
- {social.length > 0 ? (
50
- <ul className="flex flex-wrap items-center gap-2">
51
- {social.map((entry, idx) => (
52
- <li key={`${entry.platform}-${idx}`}>
53
- <a href={entry.url} target="_blank" rel="noopener noreferrer" aria-label={entry.platform}>
54
- {entry.platform}
55
- </a>
56
- </li>
57
- ))}
58
- </ul>
59
- ) : null}
60
- </div>
61
-
62
- {columns.length > 0 ? (
63
- <div className="flex flex-wrap gap-8">
64
- {columns.map((col, idx) => (
65
- <nav key={`${col.title}-${idx}`} aria-label={col.title}>
66
- <h3>{col.title}</h3>
67
- <ul>
68
- {col.links.map((link, linkIdx) => (
69
- <li key={`${link.url}-${linkIdx}`}>
70
- <a href={link.url}>{link.label}</a>
71
- </li>
72
- ))}
73
- </ul>
74
- </nav>
75
- ))}
76
- </div>
77
- ) : null}
78
-
79
- <p>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</p>
80
- </footer>
81
- );
82
- }
1
+ /**
2
+ * Storefront footer (bare skeleton) — fully driven by the merchant's
3
+ * dashboard configuration.
4
+ *
5
+ * The merchant configures the footer in the Brainerce dashboard under
6
+ * Sell → Content → Footer
7
+ *
8
+ * Renders a brand block (store-name + social links), the merchant's link
9
+ * columns, and a copyright bar.
10
+ *
11
+ * API contract:
12
+ * GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
13
+ * Returns null on 404 — we render a minimal fallback so the layout never
14
+ * crashes when the merchant hasn't seeded the footer yet.
15
+ */
16
+ import * as React from 'react';
17
+ import { Link } from '@/core/lib/navigation';
18
+ import type { Content } from 'brainerce';
19
+
20
+ interface SiteFooterProps {
21
+ /** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
22
+ footer: Content<'FOOTER'> | null;
23
+ /** Store name shown in the brand block + fallback copyright. */
24
+ storeName?: string;
25
+ }
26
+
27
+ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
28
+ const data = footer?.data;
29
+ const columns = data?.columns ?? [];
30
+ const social = data?.social ?? [];
31
+ const year = new Date().getFullYear();
32
+ const brandLabel = storeName ?? 'Store';
33
+
34
+ if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
35
+ return (
36
+ <footer>
37
+ {/* DESIGN ME — footer (fallback branch): copyright only; Content<'FOOTER'> pre-fetched in the layout. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
38
+ <p>
39
+ © {year} {brandLabel}. All rights reserved.
40
+ </p>
41
+ </footer>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <footer>
47
+ {/* DESIGN ME — footer: brand block, merchant link columns and social links (Content<'FOOTER'> pre-fetched in the layout), copyright bar. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
48
+ <div>
49
+ <Link href="/">{brandLabel}</Link>
50
+ {social.length > 0 ? (
51
+ <ul className="flex flex-wrap items-center gap-2">
52
+ {social.map((entry, idx) => (
53
+ <li key={`${entry.platform}-${idx}`}>
54
+ <a href={entry.url} target="_blank" rel="noopener noreferrer" aria-label={entry.platform}>
55
+ {entry.platform}
56
+ </a>
57
+ </li>
58
+ ))}
59
+ </ul>
60
+ ) : null}
61
+ </div>
62
+
63
+ {columns.length > 0 ? (
64
+ <div className="flex flex-wrap gap-8">
65
+ {columns.map((col, idx) => (
66
+ <nav key={`${col.title}-${idx}`} aria-label={col.title}>
67
+ <h3>{col.title}</h3>
68
+ <ul>
69
+ {col.links.map((link, linkIdx) => (
70
+ <li key={`${link.url}-${linkIdx}`}>
71
+ <a href={link.url}>{link.label}</a>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ </nav>
76
+ ))}
77
+ </div>
78
+ ) : null}
79
+
80
+ <p>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</p>
81
+ </footer>
82
+ );
83
+ }