create-brainerce-store 1.65.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.
- package/dist/index.js +2 -2
- package/messages/en.json +505 -502
- package/messages/he.json +505 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +45 -24
- package/templates/nextjs/base/.eslintrc.json +51 -57
- package/templates/nextjs/base/AGENTS.md.ejs +107 -81
- package/templates/nextjs/base/CLAUDE.md.ejs +118 -92
- package/templates/nextjs/base/next.config.ts +103 -86
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +104 -97
- package/templates/nextjs/base/src/app/agents.md/route.ts +4 -3
- package/templates/nextjs/base/src/app/api/auth/me/route.ts +65 -59
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +255 -242
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +311 -308
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +277 -276
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +4 -3
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +6 -5
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +46 -46
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +25 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +273 -257
- package/templates/nextjs/base/src/app/llms.txt/route.ts +4 -3
- package/templates/nextjs/base/src/app/opengraph-image.tsx +1 -1
- package/templates/nextjs/base/src/app/page.tsx +65 -64
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +92 -92
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +10 -5
- package/templates/nextjs/base/src/app/robots.ts +3 -2
- package/templates/nextjs/base/src/app/sitemap.ts +4 -3
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +294 -292
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +60 -59
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +60 -61
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +16 -2
- package/templates/nextjs/base/src/core/lib/brainerce.server.ts +81 -0
- package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +60 -110
- package/templates/nextjs/base/src/core/lib/site-url.ts +197 -0
- package/templates/nextjs/base/src/ui/product/faq-section.tsx.ejs +46 -0
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +301 -285
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +97 -96
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +142 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +139 -138
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +295 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +72 -71
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +83 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +120 -119
- package/templates/nextjs/ui-canvas/product/faq-section.tsx.ejs +54 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +267 -266
- 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
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
footer
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|