create-brainerce-store 1.51.0 → 1.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/index.js +9 -68
- 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/themes/luxury/globals.css +405 -399
- package/templates/nextjs/themes/minimal/globals.css +36 -30
- package/templates/nextjs/themes/playful/globals.css +406 -400
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
3
4
|
import { formatPrice } from 'brainerce';
|
|
4
5
|
import { useStoreInfo, useCart } from '@/core/providers/store-provider';
|
|
5
6
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
@@ -35,9 +36,7 @@ export function FreeShippingBar({ className }: FreeShippingBarProps) {
|
|
|
35
36
|
return (
|
|
36
37
|
<div className={cn('rounded-lg border border-green-200 bg-green-50 p-3', className)}>
|
|
37
38
|
<div className="flex items-center gap-2 text-sm font-medium text-green-700">
|
|
38
|
-
<
|
|
39
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
40
|
-
</svg>
|
|
39
|
+
<Check className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
41
40
|
{t('freeShippingQualified')}
|
|
42
41
|
</div>
|
|
43
42
|
</div>
|
|
@@ -1,103 +1,95 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
97
|
-
</span>
|
|
98
|
-
</p>
|
|
99
|
-
)}
|
|
100
|
-
</div>
|
|
101
|
-
</div>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { Clock } from 'lucide-react';
|
|
5
|
+
import type { ReservationInfo } from 'brainerce';
|
|
6
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface ReservationCountdownProps {
|
|
10
|
+
reservation: ReservationInfo;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
|
|
15
|
+
const t = useTranslations('reservation');
|
|
16
|
+
const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
|
|
17
|
+
|
|
18
|
+
const calculateRemaining = useCallback(() => {
|
|
19
|
+
if (!reservation.expiresAt) return 0;
|
|
20
|
+
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
21
|
+
const nowMs = Date.now();
|
|
22
|
+
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
23
|
+
}, [reservation.expiresAt]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setRemainingSeconds(calculateRemaining());
|
|
27
|
+
|
|
28
|
+
const interval = setInterval(() => {
|
|
29
|
+
const remaining = calculateRemaining();
|
|
30
|
+
setRemainingSeconds(remaining);
|
|
31
|
+
|
|
32
|
+
if (remaining <= 0) {
|
|
33
|
+
clearInterval(interval);
|
|
34
|
+
}
|
|
35
|
+
}, 1000);
|
|
36
|
+
|
|
37
|
+
return () => clearInterval(interval);
|
|
38
|
+
}, [calculateRemaining]);
|
|
39
|
+
|
|
40
|
+
if (!reservation.hasReservation) return null;
|
|
41
|
+
|
|
42
|
+
const minutes = Math.floor(remainingSeconds / 60);
|
|
43
|
+
const seconds = remainingSeconds % 60;
|
|
44
|
+
const isExpired = remainingSeconds <= 0;
|
|
45
|
+
const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
|
|
46
|
+
|
|
47
|
+
const displayMessage = reservation.countdownMessage
|
|
48
|
+
? reservation.countdownMessage.replace(
|
|
49
|
+
'{time}',
|
|
50
|
+
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
51
|
+
)
|
|
52
|
+
: null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={cn(
|
|
57
|
+
'flex items-center gap-3 rounded-lg px-4 py-3 text-sm',
|
|
58
|
+
isExpired
|
|
59
|
+
? 'bg-destructive/10 border-destructive/20 text-destructive border'
|
|
60
|
+
: isUrgent
|
|
61
|
+
? 'border border-orange-200 bg-orange-50 text-orange-800 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300'
|
|
62
|
+
: 'bg-primary/5 border-primary/20 text-foreground border',
|
|
63
|
+
className
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<Clock
|
|
67
|
+
className={cn(
|
|
68
|
+
'h-5 w-5 flex-shrink-0',
|
|
69
|
+
isExpired
|
|
70
|
+
? 'text-destructive'
|
|
71
|
+
: isUrgent
|
|
72
|
+
? 'text-orange-600 dark:text-orange-400'
|
|
73
|
+
: 'text-primary'
|
|
74
|
+
)}
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<div className="flex-1">
|
|
79
|
+
{isExpired ? (
|
|
80
|
+
<p className="font-medium">{t('expired')}</p>
|
|
81
|
+
) : displayMessage ? (
|
|
82
|
+
<p>{displayMessage}</p>
|
|
83
|
+
) : (
|
|
84
|
+
<p>
|
|
85
|
+
{isUrgent ? `${t('hurry')} ` : ''}
|
|
86
|
+
{t('reservedFor')}{' '}
|
|
87
|
+
<span className="font-semibold tabular-nums">
|
|
88
|
+
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
89
|
+
</span>
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Link } from '@/core/lib/navigation';
|
|
4
4
|
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
5
6
|
|
|
6
7
|
/** Homepage hero: welcome headline, subtitle, and shop-now CTA. */
|
|
7
8
|
export function HeroSection({ storeName }: { storeName?: string | null }) {
|
|
@@ -17,12 +18,9 @@ export function HeroSection({ storeName }: { storeName?: string | null }) {
|
|
|
17
18
|
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
|
18
19
|
{t('heroSubtitle')}
|
|
19
20
|
</p>
|
|
20
|
-
<
|
|
21
|
-
href="/products"
|
|
22
|
-
|
|
23
|
-
>
|
|
24
|
-
{tc('shopNow')}
|
|
25
|
-
</Link>
|
|
21
|
+
<Button asChild size="lg" className="mt-8 rounded px-6 text-base">
|
|
22
|
+
<Link href="/products">{tc('shopNow')}</Link>
|
|
23
|
+
</Button>
|
|
26
24
|
</div>
|
|
27
25
|
</section>
|
|
28
26
|
);
|
|
@@ -1,125 +1,116 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Storefront announcement bar — fully driven by the merchant's dashboard.
|
|
3
|
-
*
|
|
4
|
-
* The merchant configures announcements in the Brainerce dashboard under
|
|
5
|
-
* Sell → Content → Announcement
|
|
6
|
-
*
|
|
7
|
-
* Everything below is generic rendering logic — it adapts automatically to
|
|
8
|
-
* any announcement shape returned by the API, so **you should not hardcode
|
|
9
|
-
* the message or severity here**.
|
|
10
|
-
*
|
|
11
|
-
* API contract:
|
|
12
|
-
* GET → client.content.announcement.list(locale) → Content<'ANNOUNCEMENT'>[]
|
|
13
|
-
* Empty array → renders nothing (page layout stays intact).
|
|
14
|
-
*
|
|
15
|
-
* Time-bounded: each announcement may have `startsAt` / `endsAt` ISO
|
|
16
|
-
* timestamps. We filter client-side so the cached server response can be
|
|
17
|
-
* shared across visitors regardless of the current time.
|
|
18
|
-
*/
|
|
19
|
-
'use client';
|
|
20
|
-
|
|
21
|
-
import * as React from 'react';
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
next
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
{announcement.data.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
</svg>
|
|
118
|
-
</button>
|
|
119
|
-
) : null}
|
|
120
|
-
</div>
|
|
121
|
-
);
|
|
122
|
-
})}
|
|
123
|
-
</div>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Storefront announcement bar — fully driven by the merchant's dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The merchant configures announcements in the Brainerce dashboard under
|
|
5
|
+
* Sell → Content → Announcement
|
|
6
|
+
*
|
|
7
|
+
* Everything below is generic rendering logic — it adapts automatically to
|
|
8
|
+
* any announcement shape returned by the API, so **you should not hardcode
|
|
9
|
+
* the message or severity here**.
|
|
10
|
+
*
|
|
11
|
+
* API contract:
|
|
12
|
+
* GET → client.content.announcement.list(locale) → Content<'ANNOUNCEMENT'>[]
|
|
13
|
+
* Empty array → renders nothing (page layout stays intact).
|
|
14
|
+
*
|
|
15
|
+
* Time-bounded: each announcement may have `startsAt` / `endsAt` ISO
|
|
16
|
+
* timestamps. We filter client-side so the cached server response can be
|
|
17
|
+
* shared across visitors regardless of the current time.
|
|
18
|
+
*/
|
|
19
|
+
'use client';
|
|
20
|
+
|
|
21
|
+
import * as React from 'react';
|
|
22
|
+
import { X } from 'lucide-react';
|
|
23
|
+
import type { Content } from 'brainerce';
|
|
24
|
+
|
|
25
|
+
interface AnnouncementBarProps {
|
|
26
|
+
/** Pre-fetched list from the server. Pass `[]` to render nothing. */
|
|
27
|
+
announcements: Content<'ANNOUNCEMENT'>[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SEVERITY_STYLES: Record<'info' | 'warning' | 'success', string> = {
|
|
31
|
+
info: 'bg-blue-600 text-white',
|
|
32
|
+
warning: 'bg-amber-500 text-amber-950',
|
|
33
|
+
success: 'bg-emerald-600 text-white',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const STORAGE_KEY = 'brainerce_dismissed_announcements';
|
|
37
|
+
|
|
38
|
+
export function AnnouncementBar({ announcements }: AnnouncementBarProps) {
|
|
39
|
+
const [dismissed, setDismissed] = React.useState<Set<string>>(() => new Set());
|
|
40
|
+
|
|
41
|
+
// Hydrate dismissals from localStorage on mount (avoids SSR/CSR mismatch).
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
if (typeof window === 'undefined') return;
|
|
44
|
+
try {
|
|
45
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
46
|
+
if (raw) setDismissed(new Set(JSON.parse(raw) as string[]));
|
|
47
|
+
} catch {
|
|
48
|
+
// ignore malformed storage
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const dismiss = (id: string) => {
|
|
53
|
+
setDismissed((prev) => {
|
|
54
|
+
const next = new Set(prev);
|
|
55
|
+
next.add(id);
|
|
56
|
+
if (typeof window !== 'undefined') {
|
|
57
|
+
try {
|
|
58
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore quota errors
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const visible = announcements.filter((a) => {
|
|
69
|
+
if (dismissed.has(a.id)) return false;
|
|
70
|
+
const startOk = !a.data.startsAt || new Date(a.data.startsAt).getTime() <= now;
|
|
71
|
+
const endOk = !a.data.endsAt || new Date(a.data.endsAt).getTime() >= now;
|
|
72
|
+
return startOk && endOk;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (visible.length === 0) return null;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div role="region" aria-label="Site announcements">
|
|
79
|
+
{visible.map((announcement) => {
|
|
80
|
+
const severity = announcement.data.severity;
|
|
81
|
+
const tone = SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.info;
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
key={announcement.id}
|
|
85
|
+
className={`flex items-center justify-center gap-3 px-4 py-2 text-sm ${tone}`}
|
|
86
|
+
>
|
|
87
|
+
<span className="flex-1 text-center">
|
|
88
|
+
{announcement.data.message}
|
|
89
|
+
{announcement.data.ctaHref && announcement.data.ctaLabel ? (
|
|
90
|
+
<>
|
|
91
|
+
{' '}
|
|
92
|
+
<a
|
|
93
|
+
href={announcement.data.ctaHref}
|
|
94
|
+
className="underline underline-offset-2"
|
|
95
|
+
>
|
|
96
|
+
{announcement.data.ctaLabel}
|
|
97
|
+
</a>
|
|
98
|
+
</>
|
|
99
|
+
) : null}
|
|
100
|
+
</span>
|
|
101
|
+
{announcement.data.dismissible ? (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => dismiss(announcement.id)}
|
|
105
|
+
aria-label="Dismiss announcement"
|
|
106
|
+
className="text-current opacity-80 transition-opacity hover:opacity-100"
|
|
107
|
+
>
|
|
108
|
+
<X className="h-4 w-4" aria-hidden="true" />
|
|
109
|
+
</button>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|