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,39 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { CartNudge } from 'brainerce';
|
|
4
|
-
import { cn } from '@/core/lib/utils';
|
|
5
|
-
|
|
6
|
-
interface CartNudgesProps {
|
|
7
|
-
nudges: CartNudge[];
|
|
8
|
-
className?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function CartNudges({ nudges, className }: CartNudgesProps) {
|
|
12
|
-
if (nudges.length === 0) return null;
|
|
13
|
-
|
|
14
|
-
return (
|
|
15
|
-
<div className={cn('space-y-2', className)}>
|
|
16
|
-
{nudges.map((nudge) => (
|
|
17
|
-
<div
|
|
18
|
-
key={nudge.ruleId}
|
|
19
|
-
className="bg-primary/5 border-primary/20 flex items-start gap-3 rounded-none border px-4 py-3"
|
|
20
|
-
>
|
|
21
|
-
<svg
|
|
22
|
-
className="text-primary mt-0.5 h-5 w-5 flex-shrink-0"
|
|
23
|
-
fill="none"
|
|
24
|
-
viewBox="0 0 24 24"
|
|
25
|
-
stroke="currentColor"
|
|
26
|
-
>
|
|
27
|
-
<path
|
|
28
|
-
strokeLinecap="round"
|
|
29
|
-
strokeLinejoin="round"
|
|
30
|
-
strokeWidth={2}
|
|
31
|
-
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
32
|
-
/>
|
|
33
|
-
</svg>
|
|
34
|
-
<p className="text-foreground flex-1 text-sm">{nudge.text}</p>
|
|
35
|
-
</div>
|
|
36
|
-
))}
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Link } from '@/core/lib/navigation';
|
|
4
|
-
import { useCartPage } from '@/core/hooks/use-cart-page';
|
|
5
|
-
import { CartItem } from '@/ui/cart/cart-item';
|
|
6
|
-
import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
|
|
7
|
-
import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
|
|
8
|
-
import { CartSummary } from '@/ui/cart/cart-summary';
|
|
9
|
-
import { CouponInput } from '@/ui/cart/coupon-input';
|
|
10
|
-
import { CartNudges } from '@/ui/cart/cart-nudges';
|
|
11
|
-
import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
|
|
12
|
-
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
13
|
-
import { CartRecommendationSection } from '@/ui/product/recommendation-section';
|
|
14
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
15
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
16
|
-
|
|
17
|
-
export function CartView() {
|
|
18
|
-
const t = useTranslations('cart');
|
|
19
|
-
const tc = useTranslations('common');
|
|
20
|
-
const { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
|
|
21
|
-
|
|
22
|
-
if (cartLoading) {
|
|
23
|
-
return (
|
|
24
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
25
|
-
<LoadingSpinner size="lg" />
|
|
26
|
-
</div>
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Empty cart state
|
|
31
|
-
if (!cart || cart.items.length === 0) {
|
|
32
|
-
return (
|
|
33
|
-
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
34
|
-
<svg
|
|
35
|
-
className="text-muted-foreground mx-auto mb-4 h-16 w-16"
|
|
36
|
-
fill="none"
|
|
37
|
-
viewBox="0 0 24 24"
|
|
38
|
-
stroke="currentColor"
|
|
39
|
-
>
|
|
40
|
-
<path
|
|
41
|
-
strokeLinecap="round"
|
|
42
|
-
strokeLinejoin="round"
|
|
43
|
-
strokeWidth={1.5}
|
|
44
|
-
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
|
|
45
|
-
/>
|
|
46
|
-
</svg>
|
|
47
|
-
<h1 className="text-foreground text-2xl font-bold">{t('emptyTitle')}</h1>
|
|
48
|
-
<p className="text-muted-foreground mt-2">{t('emptySubtitle')}</p>
|
|
49
|
-
<Link
|
|
50
|
-
href="/products"
|
|
51
|
-
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
52
|
-
>
|
|
53
|
-
{tc('continueShopping')}
|
|
54
|
-
</Link>
|
|
55
|
-
</div>
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
61
|
-
<h1 className="text-foreground mb-6 text-2xl font-bold">
|
|
62
|
-
{t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
|
|
63
|
-
</h1>
|
|
64
|
-
|
|
65
|
-
{/* Reservation countdown */}
|
|
66
|
-
{cart.reservation?.hasReservation && (
|
|
67
|
-
<ReservationCountdown reservation={cart.reservation} className="mb-6" />
|
|
68
|
-
)}
|
|
69
|
-
|
|
70
|
-
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
71
|
-
{/* Cart Items */}
|
|
72
|
-
<div className="lg:col-span-2">
|
|
73
|
-
{/* Nudges */}
|
|
74
|
-
{cart.nudges && cart.nudges.length > 0 && (
|
|
75
|
-
<CartNudges nudges={cart.nudges} className="mb-4" />
|
|
76
|
-
)}
|
|
77
|
-
|
|
78
|
-
{/* Cart items */}
|
|
79
|
-
<div>
|
|
80
|
-
{cart.items.map((item) => (
|
|
81
|
-
<div key={item.id}>
|
|
82
|
-
<CartItem item={item} onUpdate={refreshCart} />
|
|
83
|
-
{upgrades?.upgrades?.[item.productId] && (
|
|
84
|
-
<CartUpgradeBanner
|
|
85
|
-
suggestion={upgrades.upgrades[item.productId]}
|
|
86
|
-
cartItem={item}
|
|
87
|
-
onUpgrade={refreshCart}
|
|
88
|
-
className="mb-2 ms-24"
|
|
89
|
-
/>
|
|
90
|
-
)}
|
|
91
|
-
</div>
|
|
92
|
-
))}
|
|
93
|
-
</div>
|
|
94
|
-
|
|
95
|
-
{/* Bundle offers */}
|
|
96
|
-
{bundles?.bundles && bundles.bundles.length > 0 && (
|
|
97
|
-
<div className="mt-6 space-y-3">
|
|
98
|
-
<h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
|
|
99
|
-
{bundles.bundles.map((offer) => (
|
|
100
|
-
<CartBundleOfferCard
|
|
101
|
-
key={offer.id}
|
|
102
|
-
offer={offer}
|
|
103
|
-
cartId={cart.id}
|
|
104
|
-
onAdd={refreshCart}
|
|
105
|
-
/>
|
|
106
|
-
))}
|
|
107
|
-
</div>
|
|
108
|
-
)}
|
|
109
|
-
|
|
110
|
-
{/* Coupon input */}
|
|
111
|
-
<div className="border-border mt-6 border-t pt-4">
|
|
112
|
-
<CouponInput cart={cart} onUpdate={refreshCart} />
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
|
|
116
|
-
{/* Summary sidebar */}
|
|
117
|
-
<div className="lg:col-span-1">
|
|
118
|
-
<div className="bg-muted/50 border-border sticky top-24 rounded-none border p-6">
|
|
119
|
-
<FreeShippingBar className="mb-4" />
|
|
120
|
-
<CartSummary />
|
|
121
|
-
|
|
122
|
-
<Link
|
|
123
|
-
href="/checkout"
|
|
124
|
-
className="bg-primary text-primary-foreground mt-6 inline-flex w-full items-center justify-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90"
|
|
125
|
-
>
|
|
126
|
-
{t('proceedToCheckout')}
|
|
127
|
-
</Link>
|
|
128
|
-
|
|
129
|
-
<Link
|
|
130
|
-
href="/products"
|
|
131
|
-
className="text-muted-foreground hover:text-foreground mt-3 inline-flex w-full items-center justify-center px-6 py-2 text-sm transition-colors"
|
|
132
|
-
>
|
|
133
|
-
{tc('continueShopping')}
|
|
134
|
-
</Link>
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
{/* Cross-sell recommendations */}
|
|
140
|
-
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
141
|
-
<CartRecommendationSection
|
|
142
|
-
title={t('youMightAlsoNeed')}
|
|
143
|
-
items={cartRecs.recommendations}
|
|
144
|
-
className="mt-10"
|
|
145
|
-
/>
|
|
146
|
-
)}
|
|
147
|
-
</div>
|
|
148
|
-
);
|
|
149
|
-
}
|
|
@@ -1,401 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
-
import { Link } from '@/core/lib/navigation';
|
|
5
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
6
|
-
import { useStoreInfo } from '@/core/providers/store-provider';
|
|
7
|
-
import { useHomeData } from '@/core/hooks/use-home-data';
|
|
8
|
-
import { ProductGrid } from '@/ui/product/product-grid';
|
|
9
|
-
import { ProductCard } from '@/ui/product/product-card';
|
|
10
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
11
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
12
|
-
import { TrustIcons } from '@/ui/layout/trust-icons';
|
|
13
|
-
|
|
14
|
-
/** Adds `revealed` to every [data-reveal] element as it enters the viewport. */
|
|
15
|
-
function useScrollReveal(ready: boolean) {
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
if (!ready || typeof IntersectionObserver === 'undefined') return;
|
|
18
|
-
const els = document.querySelectorAll('[data-reveal]');
|
|
19
|
-
const io = new IntersectionObserver(
|
|
20
|
-
(entries) => {
|
|
21
|
-
for (const entry of entries) {
|
|
22
|
-
if (entry.isIntersecting) {
|
|
23
|
-
entry.target.classList.add('revealed');
|
|
24
|
-
io.unobserve(entry.target);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
{ threshold: 0.12 }
|
|
29
|
-
);
|
|
30
|
-
els.forEach((el) => io.observe(el));
|
|
31
|
-
return () => io.disconnect();
|
|
32
|
-
}, [ready]);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Catalog-numbered section header — the exhibition idiom. */
|
|
36
|
-
function SectionHeader({
|
|
37
|
-
kicker,
|
|
38
|
-
title,
|
|
39
|
-
end,
|
|
40
|
-
}: {
|
|
41
|
-
kicker: string;
|
|
42
|
-
title: string;
|
|
43
|
-
end?: React.ReactNode;
|
|
44
|
-
}) {
|
|
45
|
-
return (
|
|
46
|
-
<>
|
|
47
|
-
<div className="flex items-baseline justify-between">
|
|
48
|
-
<div>
|
|
49
|
-
<p className="placard tabular">{kicker}</p>
|
|
50
|
-
<h2 className="text-foreground mt-1 font-serif text-2xl font-normal">{title}</h2>
|
|
51
|
-
</div>
|
|
52
|
-
{end}
|
|
53
|
-
</div>
|
|
54
|
-
<div className="border-border mt-6 border-b" />
|
|
55
|
-
</>
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function HomeClient() {
|
|
60
|
-
const { storeInfo, loading: storeLoading } = useStoreInfo();
|
|
61
|
-
const { products, banners, loading } = useHomeData();
|
|
62
|
-
const [joined, setJoined] = useState(false);
|
|
63
|
-
const t = useTranslations('home');
|
|
64
|
-
const tc = useTranslations('common');
|
|
65
|
-
|
|
66
|
-
const storeName = storeInfo?.name || process.env.NEXT_PUBLIC_STORE_NAME || '';
|
|
67
|
-
|
|
68
|
-
const ready = !storeLoading && !loading;
|
|
69
|
-
useScrollReveal(ready);
|
|
70
|
-
|
|
71
|
-
// One tile per category, dressed with the first product photo we have for
|
|
72
|
-
// it. When the channel exposes no category data, the wall simply skips
|
|
73
|
-
// this section — never invent catalog content.
|
|
74
|
-
const categoryTiles = useMemo(() => {
|
|
75
|
-
const map = new Map<string, { name: string; image: string | null; href: string }>();
|
|
76
|
-
for (const p of products) {
|
|
77
|
-
for (const c of p.categories ?? []) {
|
|
78
|
-
if (!map.has(c.id)) {
|
|
79
|
-
map.set(c.id, {
|
|
80
|
-
name: c.name,
|
|
81
|
-
image: p.images?.[0]?.url ?? null,
|
|
82
|
-
href: `/products?category=${c.id}`,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return [...map.values()].filter((c) => c.image).slice(0, 4);
|
|
88
|
-
}, [products]);
|
|
89
|
-
|
|
90
|
-
// "On the wall now" — sale pieces first, newest fill the rest.
|
|
91
|
-
const wallStrip = useMemo(() => {
|
|
92
|
-
const sale = products.filter((p) => p.salePrice);
|
|
93
|
-
const rest = products.filter((p) => !p.salePrice);
|
|
94
|
-
return [...sale, ...rest].slice(0, 6);
|
|
95
|
-
}, [products]);
|
|
96
|
-
|
|
97
|
-
const heroA = products[0]?.images?.[0]?.url ?? null;
|
|
98
|
-
const heroB = products[2]?.images?.[0]?.url ?? null;
|
|
99
|
-
const storyImage =
|
|
100
|
-
products.find((p) => (p.images?.length ?? 0) > 2)?.images?.[1]?.url ??
|
|
101
|
-
products[1]?.images?.[0]?.url ??
|
|
102
|
-
null;
|
|
103
|
-
|
|
104
|
-
if (!ready) {
|
|
105
|
-
return (
|
|
106
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
107
|
-
<LoadingSpinner size="lg" />
|
|
108
|
-
</div>
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const marqueeItems = [
|
|
113
|
-
t('marqueeHandmade'),
|
|
114
|
-
t('trustShipping'),
|
|
115
|
-
t('trustWarranty'),
|
|
116
|
-
t('trustGift'),
|
|
117
|
-
t('marqueeOneOff'),
|
|
118
|
-
];
|
|
119
|
-
|
|
120
|
-
return (
|
|
121
|
-
<div>
|
|
122
|
-
{/* Discount banners — a quiet vernissage line, not a promo slab */}
|
|
123
|
-
{banners.length > 0 && (
|
|
124
|
-
<div className="bg-background border-border border-b">
|
|
125
|
-
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-center gap-x-3 gap-y-1 px-4 py-2.5 sm:px-6 lg:px-8">
|
|
126
|
-
{banners.map((banner, idx) => (
|
|
127
|
-
<span
|
|
128
|
-
key={banner.ruleId}
|
|
129
|
-
className="text-muted-foreground text-[0.6875rem] tracking-[0.08em]"
|
|
130
|
-
>
|
|
131
|
-
{idx > 0 ? (
|
|
132
|
-
<span aria-hidden="true" className="text-accent me-3">
|
|
133
|
-
·
|
|
134
|
-
</span>
|
|
135
|
-
) : null}
|
|
136
|
-
{banner.text}
|
|
137
|
-
</span>
|
|
138
|
-
))}
|
|
139
|
-
</div>
|
|
140
|
-
</div>
|
|
141
|
-
)}
|
|
142
|
-
|
|
143
|
-
{/* Hero — editorial split: statement wall + hung collage */}
|
|
144
|
-
<section className="relative overflow-hidden">
|
|
145
|
-
<div aria-hidden="true">
|
|
146
|
-
<div className="wash wash-sand -top-40 start-[-12%] h-[36rem] w-[36rem]" />
|
|
147
|
-
<div className="wash wash-blush bottom-[-8rem] end-[-10%] h-[30rem] w-[30rem]" />
|
|
148
|
-
{/* The ghost word — the store's own name, printed into the wall */}
|
|
149
|
-
{storeName && (
|
|
150
|
-
<span className="text-foreground/[0.045] pointer-events-none absolute -bottom-16 start-2 select-none font-serif text-[clamp(7rem,20vw,17rem)] font-light leading-none">
|
|
151
|
-
{storeName}
|
|
152
|
-
</span>
|
|
153
|
-
)}
|
|
154
|
-
</div>
|
|
155
|
-
<div className="relative mx-auto grid max-w-7xl grid-cols-12 items-center gap-y-12 px-4 py-16 sm:px-6 md:min-h-[78vh] md:py-20 lg:px-8">
|
|
156
|
-
<div className="col-span-12 text-start md:col-span-5">
|
|
157
|
-
<p className="placard">{t('heroOverline')}</p>
|
|
158
|
-
<h1 className="text-foreground mt-5 font-serif text-[clamp(2.5rem,6.5vw,4.25rem)] font-light leading-[1.12]">
|
|
159
|
-
{storeName}
|
|
160
|
-
</h1>
|
|
161
|
-
<p className="text-foreground/70 mt-5 max-w-md font-serif text-lg font-light leading-relaxed">
|
|
162
|
-
{t('heroSubtitle')}
|
|
163
|
-
</p>
|
|
164
|
-
<div className="brass-rule mt-8 w-24" />
|
|
165
|
-
<div className="mt-10 flex flex-wrap items-center gap-4">
|
|
166
|
-
<Link
|
|
167
|
-
href="/products"
|
|
168
|
-
className="bg-primary text-primary-foreground inline-flex items-center px-8 py-3.5 text-[0.8125rem] font-semibold"
|
|
169
|
-
>
|
|
170
|
-
{tc('shopNow')}
|
|
171
|
-
</Link>
|
|
172
|
-
<Link href="/contact" className="text-foreground text-xs">
|
|
173
|
-
{t('ctaStory')} ←
|
|
174
|
-
</Link>
|
|
175
|
-
</div>
|
|
176
|
-
</div>
|
|
177
|
-
|
|
178
|
-
{/* The hung collage — two framed works, offset like a gallery wall */}
|
|
179
|
-
<div className="relative col-span-12 h-[46vh] md:col-span-7 md:h-[62vh]">
|
|
180
|
-
{heroA && (
|
|
181
|
-
<div className="bg-surface border-border hung absolute top-0 h-[78%] w-[68%] overflow-hidden border md:end-10">
|
|
182
|
-
<Image
|
|
183
|
-
src={heroA}
|
|
184
|
-
alt={products[0]?.name ?? ''}
|
|
185
|
-
fill
|
|
186
|
-
sizes="(max-width: 768px) 70vw, 40vw"
|
|
187
|
-
className="object-cover"
|
|
188
|
-
priority
|
|
189
|
-
/>
|
|
190
|
-
</div>
|
|
191
|
-
)}
|
|
192
|
-
{heroB && (
|
|
193
|
-
<div className="bg-surface border-border hung absolute bottom-0 start-0 h-[52%] w-[44%] overflow-hidden border md:start-6">
|
|
194
|
-
<Image
|
|
195
|
-
src={heroB}
|
|
196
|
-
alt={products[2]?.name ?? ''}
|
|
197
|
-
fill
|
|
198
|
-
sizes="(max-width: 768px) 45vw, 25vw"
|
|
199
|
-
className="object-cover"
|
|
200
|
-
/>
|
|
201
|
-
</div>
|
|
202
|
-
)}
|
|
203
|
-
<div className="brass-rule absolute -bottom-6 end-16 hidden w-24 md:block" />
|
|
204
|
-
</div>
|
|
205
|
-
</div>
|
|
206
|
-
</section>
|
|
207
|
-
|
|
208
|
-
{/* Marquee — the engraved drifting band */}
|
|
209
|
-
<div className="marquee border-border border-y py-3" aria-hidden="true">
|
|
210
|
-
<div className="marquee-track">
|
|
211
|
-
{[0, 1].map((half) => (
|
|
212
|
-
<span key={half} className="inline-flex">
|
|
213
|
-
{marqueeItems.map((item, i) => (
|
|
214
|
-
<span
|
|
215
|
-
key={`${half}-${i}`}
|
|
216
|
-
className="text-muted-foreground mx-6 inline-flex items-center gap-6 text-[0.6875rem] tracking-[0.14em]"
|
|
217
|
-
>
|
|
218
|
-
{item}
|
|
219
|
-
<span className="text-accent">◆</span>
|
|
220
|
-
</span>
|
|
221
|
-
))}
|
|
222
|
-
</span>
|
|
223
|
-
))}
|
|
224
|
-
</div>
|
|
225
|
-
</div>
|
|
226
|
-
|
|
227
|
-
{/* No. 01 — Collections */}
|
|
228
|
-
{categoryTiles.length > 0 && (
|
|
229
|
-
<section className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" data-reveal>
|
|
230
|
-
<SectionHeader kicker={t('collectionsKicker')} title={t('collections')} />
|
|
231
|
-
<div className="mt-10 grid grid-cols-2 gap-x-4 gap-y-10 lg:grid-cols-4 lg:gap-x-6">
|
|
232
|
-
{categoryTiles.map((cat) => (
|
|
233
|
-
<Link key={cat.name} href={cat.href} className="group block">
|
|
234
|
-
<div className="bg-surface border-border hung relative aspect-[4/5] overflow-hidden border">
|
|
235
|
-
<Image
|
|
236
|
-
src={cat.image as string}
|
|
237
|
-
alt={cat.name}
|
|
238
|
-
fill
|
|
239
|
-
sizes="(max-width: 1024px) 50vw, 25vw"
|
|
240
|
-
className="object-cover transition-transform duration-700 group-hover:scale-[1.04]"
|
|
241
|
-
/>
|
|
242
|
-
<div className="bg-surface/95 border-border absolute inset-x-0 bottom-0 border-t py-2.5 text-center">
|
|
243
|
-
<span className="text-foreground font-serif text-sm">{cat.name}</span>
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
</Link>
|
|
247
|
-
))}
|
|
248
|
-
</div>
|
|
249
|
-
</section>
|
|
250
|
-
)}
|
|
251
|
-
|
|
252
|
-
<div className="hairline-fade" />
|
|
253
|
-
|
|
254
|
-
{/* No. 02 — Selected pieces */}
|
|
255
|
-
<section className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" data-reveal>
|
|
256
|
-
<SectionHeader
|
|
257
|
-
kicker={t('featuredKicker')}
|
|
258
|
-
title={t('featuredProducts')}
|
|
259
|
-
end={
|
|
260
|
-
<Link href="/products" className="text-foreground text-xs">
|
|
261
|
-
{t('viewExhibition')} ←
|
|
262
|
-
</Link>
|
|
263
|
-
}
|
|
264
|
-
/>
|
|
265
|
-
<div className="mt-10">
|
|
266
|
-
<ProductGrid products={products.slice(0, 8)} />
|
|
267
|
-
</div>
|
|
268
|
-
</section>
|
|
269
|
-
|
|
270
|
-
{/* The story — editorial band on the mat */}
|
|
271
|
-
<section className="bg-surface border-border border-y" data-reveal>
|
|
272
|
-
<div className="mx-auto grid max-w-7xl grid-cols-12 items-center gap-y-10 px-4 py-16 sm:px-6 md:py-24 lg:px-8">
|
|
273
|
-
<div className="col-span-12 md:col-span-5">
|
|
274
|
-
<p className="placard">{t('storyOverline')}</p>
|
|
275
|
-
<h2 className="text-foreground mt-4 font-serif text-[clamp(1.75rem,4vw,2.75rem)] font-light leading-[1.2]">
|
|
276
|
-
{t('storyTitle')}
|
|
277
|
-
</h2>
|
|
278
|
-
<p className="text-foreground/75 mt-6 max-w-md font-serif text-[1.0625rem] font-light leading-[1.9]">
|
|
279
|
-
{t('storyBody')}
|
|
280
|
-
</p>
|
|
281
|
-
<div className="brass-rule mt-8 w-16" />
|
|
282
|
-
<Link href="/products" className="text-foreground mt-8 inline-block text-xs">
|
|
283
|
-
{t('storyCta')} ←
|
|
284
|
-
</Link>
|
|
285
|
-
</div>
|
|
286
|
-
<div className="col-span-12 md:col-span-6 md:col-start-7">
|
|
287
|
-
{storyImage && (
|
|
288
|
-
<div className="bg-background border-border hung relative aspect-[4/3] overflow-hidden border">
|
|
289
|
-
<Image
|
|
290
|
-
src={storyImage}
|
|
291
|
-
alt={t('storyOverline')}
|
|
292
|
-
fill
|
|
293
|
-
sizes="(max-width: 768px) 100vw, 50vw"
|
|
294
|
-
className="object-cover"
|
|
295
|
-
/>
|
|
296
|
-
</div>
|
|
297
|
-
)}
|
|
298
|
-
</div>
|
|
299
|
-
</div>
|
|
300
|
-
</section>
|
|
301
|
-
|
|
302
|
-
{/* No. 03 — On the wall now: horizontal snap strip */}
|
|
303
|
-
{wallStrip.length > 0 && (
|
|
304
|
-
<section className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" data-reveal>
|
|
305
|
-
<SectionHeader kicker={t('onWallKicker')} title={t('onWall')} />
|
|
306
|
-
<div className="-mx-4 mt-10 flex snap-x snap-mandatory gap-5 overflow-x-auto px-4 pb-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
|
307
|
-
{wallStrip.map((p) => (
|
|
308
|
-
<div key={p.id} className="w-[70vw] flex-shrink-0 snap-start sm:w-[40vw] lg:w-[23%]">
|
|
309
|
-
<ProductCard product={p} />
|
|
310
|
-
</div>
|
|
311
|
-
))}
|
|
312
|
-
</div>
|
|
313
|
-
</section>
|
|
314
|
-
)}
|
|
315
|
-
|
|
316
|
-
<div className="hairline-fade" />
|
|
317
|
-
|
|
318
|
-
{/* No. 04 — Kind words: quotes as placards */}
|
|
319
|
-
<section className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" data-reveal>
|
|
320
|
-
<SectionHeader kicker={t('voicesKicker')} title={t('voices')} />
|
|
321
|
-
<div className="mt-10 grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
322
|
-
{[1, 2, 3].map((n) => (
|
|
323
|
-
<figure key={n} className="bg-surface border-border hung border p-8">
|
|
324
|
-
<div className="text-accent text-lg" aria-hidden="true">
|
|
325
|
-
★★★★★
|
|
326
|
-
</div>
|
|
327
|
-
<blockquote className="text-foreground/85 mt-4 font-serif text-[1.0625rem] font-light leading-[1.8]">
|
|
328
|
-
{t(`quote${n}` as 'quote1')}
|
|
329
|
-
</blockquote>
|
|
330
|
-
<figcaption className="placard mt-6">{t(`name${n}` as 'name1')}</figcaption>
|
|
331
|
-
</figure>
|
|
332
|
-
))}
|
|
333
|
-
</div>
|
|
334
|
-
</section>
|
|
335
|
-
|
|
336
|
-
{/* Visit band — the closing wall */}
|
|
337
|
-
<section className="border-border relative overflow-hidden border-t" data-reveal>
|
|
338
|
-
<div aria-hidden="true">
|
|
339
|
-
<div className="wash wash-sand -top-24 end-[15%] h-[26rem] w-[26rem]" />
|
|
340
|
-
</div>
|
|
341
|
-
<div className="relative mx-auto max-w-7xl px-4 py-20 text-center sm:px-6 md:py-28 lg:px-8">
|
|
342
|
-
<p className="placard">{t('heroOverline')}</p>
|
|
343
|
-
<h2 className="text-foreground mt-4 font-serif text-[clamp(1.75rem,4.5vw,3rem)] font-light">
|
|
344
|
-
{t('visitTitle')}
|
|
345
|
-
</h2>
|
|
346
|
-
<p className="text-muted-foreground tabular mt-4 text-[0.8125rem] tracking-[0.08em]">
|
|
347
|
-
{t('visitSub')}
|
|
348
|
-
</p>
|
|
349
|
-
<div className="brass-rule mx-auto mt-8 w-24" />
|
|
350
|
-
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
|
351
|
-
<Link
|
|
352
|
-
href="/contact"
|
|
353
|
-
className="bg-primary text-primary-foreground inline-flex items-center px-8 py-3.5 text-[0.8125rem] font-semibold"
|
|
354
|
-
>
|
|
355
|
-
{t('visitCta')}
|
|
356
|
-
</Link>
|
|
357
|
-
<Link href="/products" className="text-foreground text-xs">
|
|
358
|
-
{t('viewExhibition')} ←
|
|
359
|
-
</Link>
|
|
360
|
-
</div>
|
|
361
|
-
|
|
362
|
-
{/* Newsletter — the guest book */}
|
|
363
|
-
<div className="mx-auto mt-14 max-w-md">
|
|
364
|
-
{joined ? (
|
|
365
|
-
<p className="border-border bg-surface text-foreground border py-4 text-center text-sm tracking-[0.04em]">
|
|
366
|
-
{t('newsletterSuccess')}
|
|
367
|
-
</p>
|
|
368
|
-
) : (
|
|
369
|
-
<form
|
|
370
|
-
onSubmit={(e) => {
|
|
371
|
-
e.preventDefault();
|
|
372
|
-
setJoined(true);
|
|
373
|
-
}}
|
|
374
|
-
className="flex items-stretch"
|
|
375
|
-
>
|
|
376
|
-
<input
|
|
377
|
-
type="email"
|
|
378
|
-
required
|
|
379
|
-
placeholder={t('newsletterPlaceholder')}
|
|
380
|
-
className="border-border bg-surface min-w-0 flex-1 border px-4 py-3 text-sm focus:outline-none"
|
|
381
|
-
/>
|
|
382
|
-
<button
|
|
383
|
-
type="submit"
|
|
384
|
-
className="bg-primary text-primary-foreground border-primary border px-6 text-[0.8125rem] font-semibold"
|
|
385
|
-
>
|
|
386
|
-
{t('newsletterCta')}
|
|
387
|
-
</button>
|
|
388
|
-
</form>
|
|
389
|
-
)}
|
|
390
|
-
{!joined && <p className="placard mt-3 text-center">{t('newsletterHook')}</p>}
|
|
391
|
-
</div>
|
|
392
|
-
|
|
393
|
-
{/* Trust icons — the promise row */}
|
|
394
|
-
<div className="mt-16">
|
|
395
|
-
<TrustIcons />
|
|
396
|
-
</div>
|
|
397
|
-
</div>
|
|
398
|
-
</section>
|
|
399
|
-
</div>
|
|
400
|
-
);
|
|
401
|
-
}
|