create-brainerce-store 1.51.0 → 1.52.1
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 +71 -0
- package/dist/index.js +24 -80
- package/messages/en.json +32 -0
- package/messages/he.json +32 -0
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +9 -0
- package/templates/nextjs/base/AI-GUIDE.md +52 -3
- package/templates/nextjs/base/CLAUDE.md +10 -0
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
- package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
- package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
- package/templates/nextjs/base/tailwind.config.ts +69 -42
- package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
- package/templates/nextjs/designs/atelier/globals.css +396 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
- package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
- package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
- package/templates/nextjs/designs/passepartout/globals.css +0 -412
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
- package/templates/nextjs/themes/luxury/globals.css +0 -399
- package/templates/nextjs/themes/luxury/theme.json +0 -23
- package/templates/nextjs/themes/minimal/globals.css +0 -30
- package/templates/nextjs/themes/minimal/theme.json +0 -23
- package/templates/nextjs/themes/playful/globals.css +0 -400
- package/templates/nextjs/themes/playful/theme.json +0 -23
|
@@ -73,7 +73,7 @@ export function AnnouncementBar({ announcements }: AnnouncementBarProps) {
|
|
|
73
73
|
|
|
74
74
|
return (
|
|
75
75
|
<div role="region" aria-label="Site announcements">
|
|
76
|
-
{/* DESIGN ME — announcement bar(s) at the very top of every page; data.severity + message + optional CTA come from Content<'ANNOUNCEMENT'>[] pre-fetched in the layout */}
|
|
76
|
+
{/* DESIGN ME — announcement bar(s) at the very top of every page; data.severity + message + optional CTA come from Content<'ANNOUNCEMENT'>[] 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. */}
|
|
77
77
|
{visible.map((announcement) => (
|
|
78
78
|
<div key={announcement.id} data-severity={announcement.data.severity} className="flex items-center gap-3">
|
|
79
79
|
<span>
|
|
@@ -40,7 +40,7 @@ export function FaqSection({ faq }: FaqSectionProps) {
|
|
|
40
40
|
if (!faq || items.length === 0) {
|
|
41
41
|
return (
|
|
42
42
|
<section>
|
|
43
|
-
{/* DESIGN ME — FAQ empty state: heading + contact CTA; fed by Content<'FAQ'> pre-fetched in the /faq route */}
|
|
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
44
|
<h1>{heading}</h1>
|
|
45
45
|
<p>
|
|
46
46
|
<strong>{t('faqEmptyTitle')}</strong>
|
|
@@ -54,7 +54,7 @@ export function FaqSection({ faq }: FaqSectionProps) {
|
|
|
54
54
|
|
|
55
55
|
return (
|
|
56
56
|
<section>
|
|
57
|
-
{/* DESIGN ME — FAQ accordion: question/answer pairs from Content<'FAQ'> pre-fetched in the /faq route; answers are sanitized merchant HTML */}
|
|
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
58
|
<h1>{heading}</h1>
|
|
59
59
|
<ul>
|
|
60
60
|
{items.map((item, idx) => (
|
|
@@ -24,7 +24,7 @@ export function HeaderAccount() {
|
|
|
24
24
|
|
|
25
25
|
return (
|
|
26
26
|
<Link href={href} aria-label={label}>
|
|
27
|
-
{/* DESIGN ME — header account link; auth state via useAuth(), label via the `nav` translation namespace */}
|
|
27
|
+
{/* DESIGN ME — header account link; auth state via useAuth(), label via the `nav` translation namespace. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
28
28
|
{label}
|
|
29
29
|
</Link>
|
|
30
30
|
);
|
|
@@ -35,6 +35,8 @@ const defaultLocale = '<%= defaultLocale %>';
|
|
|
35
35
|
* DESIGN ME — locale switcher in the header; reads supported locales from
|
|
36
36
|
* `@/i18n` and swaps the URL prefix. Keep the aria-label and the redirect
|
|
37
37
|
* logic exactly as-is.
|
|
38
|
+
*
|
|
39
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
38
40
|
*/
|
|
39
41
|
export function LanguageSwitcher() {
|
|
40
42
|
const pathname = usePathname();
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* DESIGN ME — merchant-editable HTML block; fed by
|
|
15
15
|
* `client.content.richText.get(key, locale)` pre-fetched in the route.
|
|
16
16
|
* Style the injected HTML via the wrapper `className`.
|
|
17
|
+
*
|
|
18
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
17
19
|
*/
|
|
18
20
|
import * as React from 'react';
|
|
19
21
|
import type { Content } from 'brainerce';
|
|
@@ -33,7 +33,7 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
|
33
33
|
if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
|
|
34
34
|
return (
|
|
35
35
|
<footer>
|
|
36
|
-
{/* DESIGN ME — footer (fallback branch): copyright only; Content<'FOOTER'> pre-fetched in the layout */}
|
|
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
37
|
<p>
|
|
38
38
|
© {year} {brandLabel}. All rights reserved.
|
|
39
39
|
</p>
|
|
@@ -43,7 +43,7 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
45
|
<footer>
|
|
46
|
-
{/* DESIGN ME — footer: brand block, merchant link columns and social links (Content<'FOOTER'> pre-fetched in the layout), copyright bar */}
|
|
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
47
|
<div>
|
|
48
48
|
<a href="/">{brandLabel}</a>
|
|
49
49
|
{social.length > 0 ? (
|
|
@@ -41,7 +41,7 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
|
41
41
|
if (!header) {
|
|
42
42
|
return (
|
|
43
43
|
<header>
|
|
44
|
-
{/* DESIGN ME — site header (fallback branch, no merchant nav yet): brand link + account + cart; data comes pre-fetched from the layout via the Content API */}
|
|
44
|
+
{/* DESIGN ME — site header (fallback branch, no merchant nav yet): brand link + account + cart; data comes pre-fetched from the layout via the Content API. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
45
45
|
<div className="flex items-center justify-between gap-4">
|
|
46
46
|
<a href="/" aria-label={brandLabel}>
|
|
47
47
|
{brandLabel}
|
|
@@ -62,7 +62,7 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
64
|
<header>
|
|
65
|
-
{/* DESIGN ME — site header: merchant-configured logo/nav/CTA (Content<'HEADER'> pre-fetched in the layout) + account + cart links. Keep every link working. */}
|
|
65
|
+
{/* DESIGN ME — site header: merchant-configured logo/nav/CTA (Content<'HEADER'> pre-fetched in the layout) + account + cart links. Keep every link working. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
66
66
|
<div className="flex items-center justify-between gap-4">
|
|
67
67
|
<a href="/" className="flex items-center gap-2" aria-label={brandLabel}>
|
|
68
68
|
{logo ? (
|
|
@@ -24,6 +24,8 @@ interface CustomizationFieldsProps {
|
|
|
24
24
|
* (engraving text, gift date, uploaded artwork, …). Field definitions,
|
|
25
25
|
* values and validation errors come from useProductPage(); every input type
|
|
26
26
|
* below must stay functional (including the image/gallery uploads).
|
|
27
|
+
*
|
|
28
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
27
29
|
*/
|
|
28
30
|
export function CustomizationFields({
|
|
29
31
|
fields,
|
|
@@ -9,6 +9,8 @@ interface DiscountBadgeProps {
|
|
|
9
9
|
/**
|
|
10
10
|
* DESIGN ME — discount-rule badge shown on product cards; text comes from
|
|
11
11
|
* the product's `discount.badgeText` (merchant-configured rule).
|
|
12
|
+
*
|
|
13
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
12
14
|
*/
|
|
13
15
|
export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
14
16
|
if (!discount) return null;
|
|
@@ -56,6 +56,8 @@ function ProductThumb({
|
|
|
56
56
|
* current product + selectable cross-sells + combined total + add-all button;
|
|
57
57
|
* items come from useProductPage().recommendations.crossSells, the feature
|
|
58
58
|
* gate from useStoreInfo().upsell.
|
|
59
|
+
*
|
|
60
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
59
61
|
*/
|
|
60
62
|
export function FrequentlyBoughtTogether({
|
|
61
63
|
items,
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* The price-delta side: this component does NOT compute totals. The server
|
|
20
20
|
* runs the free-allocation policy and returns the line's `unitPrice` /
|
|
21
21
|
* `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
|
|
22
|
+
*
|
|
23
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
import type { Modifier, ModifierGroup } from 'brainerce';
|
|
@@ -16,6 +16,8 @@ interface PriceDisplayProps {
|
|
|
16
16
|
* DESIGN ME — price renderer used by cards, product page and recommendations;
|
|
17
17
|
* computes sale state + discount % from props (currency via useCurrency()).
|
|
18
18
|
* Never hardcode currency symbols — formatPrice handles them.
|
|
19
|
+
*
|
|
20
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
19
21
|
*/
|
|
20
22
|
export function PriceDisplay({
|
|
21
23
|
price,
|
|
@@ -130,7 +130,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
130
130
|
|
|
131
131
|
return (
|
|
132
132
|
<article className={cn(className)}>
|
|
133
|
-
{/* DESIGN ME — product card (grid/listing unit): image, badges, name, price, stock, add-to-cart; product data arrives as a prop from useHomeData()/useProductListing() */}
|
|
133
|
+
{/* DESIGN ME — product card (grid/listing unit): image, badges, name, price, stock, add-to-cart; product data arrives as a prop from useHomeData()/useProductListing(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
134
134
|
<figure>
|
|
135
135
|
<Link href={`/products/${slug}`}>
|
|
136
136
|
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
@@ -108,7 +108,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
108
108
|
|
|
109
109
|
return (
|
|
110
110
|
<article>
|
|
111
|
-
{/* DESIGN ME — product page (PDP): gallery, buy box (price/stock/variants/customization/modifiers/quantity/add-to-cart), description, specs, cross-sells; everything comes from useProductPage(initialProduct) */}
|
|
111
|
+
{/* DESIGN ME — product page (PDP): gallery, buy box (price/stock/variants/customization/modifiers/quantity/add-to-cart), description, specs, cross-sells; everything comes from useProductPage(initialProduct). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
112
112
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
|
113
113
|
{/* Image Gallery */}
|
|
114
114
|
<figure>
|
|
@@ -18,7 +18,7 @@ export function ProductGrid({ products, className }: ProductGridProps) {
|
|
|
18
18
|
|
|
19
19
|
return (
|
|
20
20
|
<div className={cn('grid grid-cols-2 gap-4 lg:grid-cols-4', className)}>
|
|
21
|
-
{/* DESIGN ME — product grid: lays out ProductCard units; products arrive as a prop from the listing/home hooks */}
|
|
21
|
+
{/* DESIGN ME — product grid: lays out ProductCard units; products arrive as a prop from the listing/home hooks. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
22
22
|
{products.map((product) => (
|
|
23
23
|
<ProductCard key={product.id} product={product} />
|
|
24
24
|
))}
|
|
@@ -65,7 +65,7 @@ export function ProductListing() {
|
|
|
65
65
|
|
|
66
66
|
return (
|
|
67
67
|
<section>
|
|
68
|
-
{/* DESIGN ME — product listing page: header, category/brand/tag/custom-field filters, sort, product grid, load-more; everything comes from useProductListing() */}
|
|
68
|
+
{/* DESIGN ME — product listing page: header, category/brand/tag/custom-field filters, sort, product grid, load-more; everything comes from useProductListing(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
69
69
|
<header>
|
|
70
70
|
<h1>{searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}</h1>
|
|
71
71
|
{!loading && (
|
|
@@ -49,6 +49,8 @@ interface RecommendationSectionProps {
|
|
|
49
49
|
/**
|
|
50
50
|
* DESIGN ME — recommendation strip on the product page (upsells / related);
|
|
51
51
|
* items come from useProductPage().recommendations, title is passed in.
|
|
52
|
+
*
|
|
53
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
52
54
|
*/
|
|
53
55
|
export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
|
|
54
56
|
if (items.length === 0) return null;
|
|
@@ -74,6 +76,8 @@ interface CartRecommendationSectionProps {
|
|
|
74
76
|
/**
|
|
75
77
|
* DESIGN ME — cross-sell strip on the cart page ("you might also need");
|
|
76
78
|
* items come from useCartPage().cartRecs.
|
|
79
|
+
*
|
|
80
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
77
81
|
*/
|
|
78
82
|
export function CartRecommendationSection({
|
|
79
83
|
title,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { getClient } from '@/core/lib/brainerce';
|
|
5
5
|
import { checkAuthStatus } from '@/core/lib/auth';
|
|
6
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
7
|
import type { MyProductReview, ProductReview } from 'brainerce';
|
|
7
8
|
|
|
8
9
|
interface ReviewFormProps {
|
|
@@ -26,9 +27,12 @@ type Stage =
|
|
|
26
27
|
*
|
|
27
28
|
* DESIGN ME — review submit/edit form; auth via checkAuthStatus(), eligibility
|
|
28
29
|
* + existing review via getClient().getMyProductReview(productId).
|
|
30
|
+
*
|
|
31
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
29
32
|
*/
|
|
30
33
|
export function ReviewForm({ productId }: ReviewFormProps) {
|
|
31
34
|
const [stage, setStage] = useState<Stage>({ kind: 'loading' });
|
|
35
|
+
const t = useTranslations('reviews');
|
|
32
36
|
|
|
33
37
|
useEffect(() => {
|
|
34
38
|
let cancelled = false;
|
|
@@ -62,13 +66,13 @@ export function ReviewForm({ productId }: ReviewFormProps) {
|
|
|
62
66
|
}, [productId]);
|
|
63
67
|
|
|
64
68
|
if (stage.kind === 'loading') {
|
|
65
|
-
return <p role="status">
|
|
69
|
+
return <p role="status">{t('loading')}</p>;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
if (stage.kind === 'signed_out') {
|
|
69
73
|
return (
|
|
70
74
|
<p>
|
|
71
|
-
<a href="/account/login">
|
|
75
|
+
<a href="/account/login">{t('signIn')}</a> {t('signInSuffix')}
|
|
72
76
|
</p>
|
|
73
77
|
);
|
|
74
78
|
}
|
|
@@ -91,16 +95,17 @@ export function ReviewForm({ productId }: ReviewFormProps) {
|
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
function NotEligibleMessage({ reason }: { reason: MyProductReview['reason'] }) {
|
|
98
|
+
const t = useTranslations('reviews');
|
|
94
99
|
let message: string;
|
|
95
100
|
switch (reason) {
|
|
96
101
|
case 'no_eligible_order':
|
|
97
|
-
message = '
|
|
102
|
+
message = t('onlyPurchasers');
|
|
98
103
|
break;
|
|
99
104
|
case 'reviews_disabled':
|
|
100
|
-
message = '
|
|
105
|
+
message = t('reviewsDisabled');
|
|
101
106
|
break;
|
|
102
107
|
default:
|
|
103
|
-
message = '
|
|
108
|
+
message = t('cannotReview');
|
|
104
109
|
}
|
|
105
110
|
return <p>{message}</p>;
|
|
106
111
|
}
|
|
@@ -119,10 +124,11 @@ function EditReviewBlock({
|
|
|
119
124
|
onDeleted: () => void;
|
|
120
125
|
}) {
|
|
121
126
|
const [deleting, setDeleting] = useState(false);
|
|
127
|
+
const t = useTranslations('reviews');
|
|
122
128
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
123
129
|
|
|
124
130
|
async function handleDelete() {
|
|
125
|
-
if (!window.confirm('
|
|
131
|
+
if (!window.confirm(t('confirmDelete'))) return;
|
|
126
132
|
setDeleting(true);
|
|
127
133
|
setDeleteError(null);
|
|
128
134
|
try {
|
|
@@ -130,7 +136,7 @@ function EditReviewBlock({
|
|
|
130
136
|
onDeleted();
|
|
131
137
|
window.location.reload();
|
|
132
138
|
} catch {
|
|
133
|
-
setDeleteError('
|
|
139
|
+
setDeleteError(t('deleteFailed'));
|
|
134
140
|
} finally {
|
|
135
141
|
setDeleting(false);
|
|
136
142
|
}
|
|
@@ -145,9 +151,9 @@ function EditReviewBlock({
|
|
|
145
151
|
initialBody={initial.body ?? ''}
|
|
146
152
|
/>
|
|
147
153
|
<div className="flex items-center gap-2">
|
|
148
|
-
<p>
|
|
154
|
+
<p>{t('startOver')}</p>
|
|
149
155
|
<button type="button" onClick={handleDelete} disabled={deleting}>
|
|
150
|
-
{deleting ? '
|
|
156
|
+
{deleting ? t('deleting') : t('deleteReview')}
|
|
151
157
|
</button>
|
|
152
158
|
</div>
|
|
153
159
|
{deleteError && <p role="alert">{deleteError}</p>}
|
|
@@ -172,6 +178,7 @@ function ReviewEditor({
|
|
|
172
178
|
initialBody: string;
|
|
173
179
|
}) {
|
|
174
180
|
const [rating, setRating] = useState(initialRating);
|
|
181
|
+
const t = useTranslations('reviews');
|
|
175
182
|
const [body, setBody] = useState(initialBody);
|
|
176
183
|
const [submitting, setSubmitting] = useState(false);
|
|
177
184
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -194,17 +201,13 @@ function ReviewEditor({
|
|
|
194
201
|
} catch (err) {
|
|
195
202
|
const status = (err as { status?: number })?.status;
|
|
196
203
|
if (status === 409) {
|
|
197
|
-
setError(
|
|
204
|
+
setError(t('alreadySubmitted'));
|
|
198
205
|
} else if (status === 403) {
|
|
199
|
-
setError('
|
|
206
|
+
setError(t('onlyPurchasers'));
|
|
200
207
|
} else if (status === 429) {
|
|
201
|
-
setError('
|
|
208
|
+
setError(t('tooMany'));
|
|
202
209
|
} else {
|
|
203
|
-
setError(
|
|
204
|
-
mode === 'edit'
|
|
205
|
-
? 'Could not update your review. Please try again.'
|
|
206
|
-
: 'Could not submit your review. Please try again.'
|
|
207
|
-
);
|
|
210
|
+
setError(mode === 'edit' ? t('updateFailed') : t('submitFailed'));
|
|
208
211
|
}
|
|
209
212
|
} finally {
|
|
210
213
|
setSubmitting(false);
|
|
@@ -212,22 +215,16 @@ function ReviewEditor({
|
|
|
212
215
|
}
|
|
213
216
|
|
|
214
217
|
if (success) {
|
|
215
|
-
return (
|
|
216
|
-
<p role="status">
|
|
217
|
-
{mode === 'edit'
|
|
218
|
-
? 'Thanks! Your review has been updated.'
|
|
219
|
-
: 'Thanks! Your review has been submitted.'}
|
|
220
|
-
</p>
|
|
221
|
-
);
|
|
218
|
+
return <p role="status">{mode === 'edit' ? t('thanksUpdated') : t('thanksSubmitted')}</p>;
|
|
222
219
|
}
|
|
223
220
|
|
|
224
221
|
return (
|
|
225
222
|
<form onSubmit={handleSubmit}>
|
|
226
|
-
<h3>{mode === 'edit' ? '
|
|
223
|
+
<h3>{mode === 'edit' ? t('editYourReview') : t('writeReview')}</h3>
|
|
227
224
|
|
|
228
225
|
<fieldset>
|
|
229
|
-
<legend>
|
|
230
|
-
<div className="inline-flex gap-1" role="radiogroup" aria-label=
|
|
226
|
+
<legend>{t('yourRating')}</legend>
|
|
227
|
+
<div className="inline-flex gap-1" role="radiogroup" aria-label={t('starRating')}>
|
|
231
228
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
232
229
|
<button
|
|
233
230
|
key={n}
|
|
@@ -243,7 +240,7 @@ function ReviewEditor({
|
|
|
243
240
|
</fieldset>
|
|
244
241
|
|
|
245
242
|
<label className="block">
|
|
246
|
-
|
|
243
|
+
{t('yourReview')} <span>({t('optionalField')})</span>
|
|
247
244
|
<textarea
|
|
248
245
|
value={body}
|
|
249
246
|
onChange={(event) => setBody(event.target.value)}
|
|
@@ -258,11 +255,11 @@ function ReviewEditor({
|
|
|
258
255
|
<button type="submit" disabled={submitting}>
|
|
259
256
|
{submitting
|
|
260
257
|
? mode === 'edit'
|
|
261
|
-
? '
|
|
262
|
-
: '
|
|
258
|
+
? t('saving')
|
|
259
|
+
: t('submittingBtn')
|
|
263
260
|
: mode === 'edit'
|
|
264
|
-
? '
|
|
265
|
-
: '
|
|
261
|
+
? t('saveChanges')
|
|
262
|
+
: t('submitReview')}
|
|
266
263
|
</button>
|
|
267
264
|
</form>
|
|
268
265
|
);
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import type { ProductReview } from 'brainerce';
|
|
2
2
|
import { getServerClient } from '@/core/lib/brainerce';
|
|
3
|
+
<% if (i18nEnabled) { %>import { getMessages, defaultLocale } from '@/i18n';<% } else { %>import { defaultLocale, messages } from '@/i18n';<% } %>
|
|
3
4
|
import { ReviewForm } from './review-form';
|
|
4
5
|
|
|
6
|
+
// Server component — it renders before any client i18n context exists, so the
|
|
7
|
+
// wrapper strings (title / empty state / verified badge) are read straight
|
|
8
|
+
// from the `reviews` namespace in messages/, keyed by the store locale.
|
|
9
|
+
type ReviewStrings = Record<string, string>;
|
|
10
|
+
|
|
11
|
+
<% if (i18nEnabled) { %>async function getReviewStrings(locale: string): Promise<ReviewStrings> {
|
|
12
|
+
return ((await getMessages(locale)).reviews ?? {}) as ReviewStrings;
|
|
13
|
+
}<% } else { %>async function getReviewStrings(_locale: string): Promise<ReviewStrings> {
|
|
14
|
+
return messages.reviews as ReviewStrings;
|
|
15
|
+
}<% } %>
|
|
16
|
+
|
|
5
17
|
interface ReviewsSectionProps {
|
|
6
18
|
productId: string;
|
|
7
19
|
initialReviews?: ProductReview[];
|
|
20
|
+
locale?: string;
|
|
8
21
|
/** Where to send users back to after submit (defaults to the current product URL). */
|
|
9
22
|
returnUrl?: string;
|
|
10
23
|
}
|
|
@@ -16,7 +29,12 @@ interface ReviewsSectionProps {
|
|
|
16
29
|
* the initial HTML payload (good for SEO; JSON-LD on the same page already
|
|
17
30
|
* carries `aggregateRating` when reviewCount > 0).
|
|
18
31
|
*/
|
|
19
|
-
export async function ReviewsSection({
|
|
32
|
+
export async function ReviewsSection({
|
|
33
|
+
productId,
|
|
34
|
+
initialReviews,
|
|
35
|
+
locale = defaultLocale,
|
|
36
|
+
}: ReviewsSectionProps) {
|
|
37
|
+
const t = await getReviewStrings(locale);
|
|
20
38
|
let reviews: ProductReview[] = initialReviews ?? [];
|
|
21
39
|
|
|
22
40
|
if (!initialReviews) {
|
|
@@ -24,22 +42,22 @@ export async function ReviewsSection({ productId, initialReviews }: ReviewsSecti
|
|
|
24
42
|
const result = await getServerClient().listProductReviews(productId, { page: 1, limit: 20 });
|
|
25
43
|
reviews = result.data;
|
|
26
44
|
} catch {
|
|
27
|
-
//
|
|
45
|
+
// Review listing disabled at store level or network error — render the form anyway.
|
|
28
46
|
reviews = [];
|
|
29
47
|
}
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
return (
|
|
33
51
|
<section aria-labelledby="reviews-heading">
|
|
34
|
-
{/* DESIGN ME — product reviews: server-fetched review list (SDK listProductReviews) + eligibility-aware submit form */}
|
|
35
|
-
<h2 id="reviews-heading">
|
|
52
|
+
{/* DESIGN ME — product reviews: server-fetched review list (SDK listProductReviews) + eligibility-aware submit form. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
53
|
+
<h2 id="reviews-heading">{t.title}</h2>
|
|
36
54
|
|
|
37
55
|
{reviews.length === 0 ? (
|
|
38
|
-
<p>
|
|
56
|
+
<p>{t.noReviews}</p>
|
|
39
57
|
) : (
|
|
40
58
|
<ul>
|
|
41
59
|
{reviews.map((review) => (
|
|
42
|
-
<ReviewCard key={review.id} review={review} />
|
|
60
|
+
<ReviewCard key={review.id} review={review} verifiedLabel={t.verifiedPurchase} />
|
|
43
61
|
))}
|
|
44
62
|
</ul>
|
|
45
63
|
)}
|
|
@@ -49,14 +67,14 @@ export async function ReviewsSection({ productId, initialReviews }: ReviewsSecti
|
|
|
49
67
|
);
|
|
50
68
|
}
|
|
51
69
|
|
|
52
|
-
function ReviewCard({ review }: { review: ProductReview }) {
|
|
70
|
+
function ReviewCard({ review, verifiedLabel }: { review: ProductReview; verifiedLabel: string }) {
|
|
53
71
|
return (
|
|
54
72
|
<li>
|
|
55
73
|
<article>
|
|
56
74
|
<header className="flex flex-wrap items-center gap-2">
|
|
57
75
|
<Stars rating={review.rating} />
|
|
58
76
|
<span>{review.authorName}</span>
|
|
59
|
-
{review.verifiedPurchase && <span>
|
|
77
|
+
{review.verifiedPurchase && <span>{verifiedLabel}</span>}
|
|
60
78
|
<time dateTime={review.createdAt}>{new Date(review.createdAt).toLocaleDateString()}</time>
|
|
61
79
|
</header>
|
|
62
80
|
{review.body && <p className="whitespace-pre-line break-words">{review.body}</p>}
|
|
@@ -14,6 +14,8 @@ interface StockBadgeProps {
|
|
|
14
14
|
* DESIGN ME — stock status badge (in stock / low stock / out of stock);
|
|
15
15
|
* derives the label from the product/variant `inventory` prop. Keep the
|
|
16
16
|
* label logic; add your own tones per state.
|
|
17
|
+
*
|
|
18
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
17
19
|
*/
|
|
18
20
|
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
19
21
|
const t = useTranslations('productDetail');
|
|
@@ -34,6 +34,8 @@ interface AttributeGroup {
|
|
|
34
34
|
* (`displayType`, `swatchColor`, `swatchColor2`, `swatchImageUrl`) is
|
|
35
35
|
* computed per value — the skeleton renders plain buttons, use the swatch
|
|
36
36
|
* data to build color/image swatches in your design.
|
|
37
|
+
*
|
|
38
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
37
39
|
*/
|
|
38
40
|
export function VariantSelector({
|
|
39
41
|
product,
|
|
@@ -23,6 +23,8 @@ function isOptimizedHost(src: ImageProps['src']): boolean {
|
|
|
23
23
|
*
|
|
24
24
|
* DESIGN ME — shared image wrapper; render ALL catalog images through this
|
|
25
25
|
* component (not raw next/image) so unknown hosts never crash the page.
|
|
26
|
+
*
|
|
27
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
26
28
|
*/
|
|
27
29
|
export function CdnImage({ unoptimized, ...props }: ImageProps) {
|
|
28
30
|
return <Image {...props} unoptimized={unoptimized ?? !isOptimizedHost(props.src)} />;
|
|
@@ -13,6 +13,8 @@ interface LoadingSpinnerProps {
|
|
|
13
13
|
* text placeholder; replace with your own spinner/skeleton. Keep the
|
|
14
14
|
* `role="status"` + sr-only label, and keep the `size` prop (callers across
|
|
15
15
|
* the app pass sm/md/lg).
|
|
16
|
+
*
|
|
17
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
16
18
|
*/
|
|
17
19
|
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
|
18
20
|
const t = useTranslations('common');
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import type { ProductReview } from 'brainerce';
|
|
2
|
-
import { getServerClient } from '@/core/lib/brainerce';
|
|
3
|
-
import { ReviewForm } from './review-form';
|
|
4
|
-
|
|
5
|
-
interface ReviewsSectionProps {
|
|
6
|
-
productId: string;
|
|
7
|
-
initialReviews?: ProductReview[];
|
|
8
|
-
/** Where to send users back to after submit (defaults to the current product URL). */
|
|
9
|
-
returnUrl?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Renders the visible reviews for a product plus a submit form.
|
|
14
|
-
*
|
|
15
|
-
* Server component — fetches with the SDK on the server so reviews are in
|
|
16
|
-
* the initial HTML payload (good for SEO; JSON-LD on the same page already
|
|
17
|
-
* carries `aggregateRating` when reviewCount > 0).
|
|
18
|
-
*/
|
|
19
|
-
export async function ReviewsSection({ productId, initialReviews }: ReviewsSectionProps) {
|
|
20
|
-
let reviews: ProductReview[] = initialReviews ?? [];
|
|
21
|
-
|
|
22
|
-
if (!initialReviews) {
|
|
23
|
-
try {
|
|
24
|
-
const result = await getServerClient().listProductReviews(productId, { page: 1, limit: 20 });
|
|
25
|
-
reviews = result.data;
|
|
26
|
-
} catch {
|
|
27
|
-
// Reviews disabled at store level or network error — render the form anyway.
|
|
28
|
-
reviews = [];
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<section className="mt-8" aria-labelledby="reviews-heading">
|
|
34
|
-
<h2 id="reviews-heading" className="mb-4 text-xl font-semibold">
|
|
35
|
-
Reviews
|
|
36
|
-
</h2>
|
|
37
|
-
|
|
38
|
-
{reviews.length === 0 ? (
|
|
39
|
-
<p className="text-muted-foreground mb-6 text-sm">
|
|
40
|
-
No reviews yet — be the first to share your experience.
|
|
41
|
-
</p>
|
|
42
|
-
) : (
|
|
43
|
-
<ul className="mb-6 space-y-4">
|
|
44
|
-
{reviews.map((review) => (
|
|
45
|
-
<ReviewCard key={review.id} review={review} />
|
|
46
|
-
))}
|
|
47
|
-
</ul>
|
|
48
|
-
)}
|
|
49
|
-
|
|
50
|
-
<ReviewForm productId={productId} />
|
|
51
|
-
</section>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function ReviewCard({ review }: { review: ProductReview }) {
|
|
56
|
-
return (
|
|
57
|
-
<li className="rounded-md border p-4">
|
|
58
|
-
<header className="flex flex-wrap items-center gap-2">
|
|
59
|
-
<Stars rating={review.rating} />
|
|
60
|
-
<span className="text-sm font-medium">{review.authorName}</span>
|
|
61
|
-
{review.verifiedPurchase && (
|
|
62
|
-
<span className="rounded bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700">
|
|
63
|
-
Verified purchase
|
|
64
|
-
</span>
|
|
65
|
-
)}
|
|
66
|
-
<time className="text-muted-foreground ms-auto text-xs">
|
|
67
|
-
{new Date(review.createdAt).toLocaleDateString()}
|
|
68
|
-
</time>
|
|
69
|
-
</header>
|
|
70
|
-
{review.body && <p className="mt-2 whitespace-pre-line break-words text-sm">{review.body}</p>}
|
|
71
|
-
</li>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function Stars({ rating }: { rating: number }) {
|
|
76
|
-
return (
|
|
77
|
-
<span aria-label={`${rating} of 5 stars`} className="inline-flex">
|
|
78
|
-
{[1, 2, 3, 4, 5].map((n) => (
|
|
79
|
-
<span key={n} className={n <= rating ? 'text-yellow-500' : 'text-gray-300'}>
|
|
80
|
-
★
|
|
81
|
-
</span>
|
|
82
|
-
))}
|
|
83
|
-
</span>
|
|
84
|
-
);
|
|
85
|
-
}
|