create-brainerce-store 1.50.5 → 1.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +171 -66
- package/messages/en.json +4 -1
- package/messages/he.json +4 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +51 -0
- package/templates/nextjs/base/.env.local.ejs +5 -0
- package/templates/nextjs/base/.eslintrc.json +57 -3
- package/templates/nextjs/base/AGENTS.md +41 -0
- package/templates/nextjs/base/AI-GUIDE.md +94 -0
- package/templates/nextjs/base/CLAUDE.md +42 -0
- package/templates/nextjs/base/TRANSLATIONS.md +1 -1
- package/templates/nextjs/base/next.config.ts +2 -2
- package/templates/nextjs/base/src/app/account/page.tsx +5 -5
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
- package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
- package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
- package/templates/nextjs/base/src/app/login/page.tsx +4 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
- package/templates/nextjs/base/src/app/page.tsx +4 -4
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
- package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
- package/templates/nextjs/base/src/app/products/page.tsx +4 -623
- package/templates/nextjs/base/src/app/register/page.tsx +4 -4
- package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
- package/templates/nextjs/base/src/app/sitemap.ts +1 -1
- package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
- package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
- package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
- package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +6 -6
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
- package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
- package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
- package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +5 -5
- package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +6 -6
- package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +5 -5
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +149 -0
- package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +2 -2
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +29 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +3 -3
- package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +18 -51
- package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +6 -6
- package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx +10 -73
- package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +2 -2
- package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +10 -10
- package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +45 -206
- package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +429 -0
- package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +4 -4
- package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +2 -2
- package/templates/nextjs/base/src/{components/reviews → ui/product}/reviews-section.tsx +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +2 -2
- package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
- package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
- package/templates/nextjs/base/tailwind.config.ts +12 -0
- package/templates/nextjs/designs/passepartout/app-overlay/layout.tsx.ejs +253 -0
- package/templates/nextjs/designs/passepartout/globals.css +412 -0
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +87 -0
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +87 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-bundle-offer.tsx +111 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +39 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-upgrade-banner.tsx +141 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +149 -0
- package/templates/nextjs/designs/passepartout/ui/cart/free-shipping-bar.tsx +62 -0
- package/templates/nextjs/designs/passepartout/ui/cart/reservation-countdown.tsx +103 -0
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +401 -0
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +139 -0
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +45 -0
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +103 -0
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +159 -0
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +155 -0
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +60 -0
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +34 -0
- package/templates/nextjs/designs/passepartout/ui/product/discount-badge.tsx +22 -0
- package/templates/nextjs/designs/passepartout/ui/product/frequently-bought-together.tsx +205 -0
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +111 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +265 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +667 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +35 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +427 -0
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +113 -0
- package/templates/nextjs/designs/passepartout/ui/product/review-form.tsx +294 -0
- package/templates/nextjs/designs/passepartout/ui/product/reviews-section.tsx.ejs +96 -0
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +52 -0
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +307 -0
- package/templates/nextjs/designs/passepartout/ui/shared/fly-to-cart.ts +85 -0
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +33 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +87 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +121 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +25 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +97 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +103 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +117 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +52 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +79 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +74 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +34 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +443 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +17 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +166 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +163 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +50 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +269 -0
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx +78 -0
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +43 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +210 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +25 -0
- package/templates/nextjs/base/src/app/home-client.tsx +0 -98
- /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
- /package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{components → ui}/layout/language-switcher.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { formatPrice } from 'brainerce';
|
|
4
|
+
import { useStoreInfo, useCart } from '@/core/providers/store-provider';
|
|
5
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
6
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface FreeShippingBarProps {
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function FreeShippingBar({ className }: FreeShippingBarProps) {
|
|
14
|
+
// Hooks must be called unconditionally and in the same order on every
|
|
15
|
+
// render — keep all of them above any early `return null` branch.
|
|
16
|
+
const t = useTranslations('cart');
|
|
17
|
+
const { storeInfo } = useStoreInfo();
|
|
18
|
+
const { totals } = useCart();
|
|
19
|
+
const currency = useCurrency();
|
|
20
|
+
|
|
21
|
+
const upsell = storeInfo?.upsell;
|
|
22
|
+
const threshold = upsell?.freeShippingThreshold;
|
|
23
|
+
const enabled = upsell?.freeShippingBarEnabled !== false;
|
|
24
|
+
|
|
25
|
+
// Don't render if disabled or no threshold configured
|
|
26
|
+
if (!enabled || !threshold || threshold <= 0) return null;
|
|
27
|
+
|
|
28
|
+
const subtotal = totals.subtotal;
|
|
29
|
+
const remaining = Math.max(0, threshold - subtotal);
|
|
30
|
+
const progress = Math.min(100, (subtotal / threshold) * 100);
|
|
31
|
+
const qualified = remaining <= 0;
|
|
32
|
+
|
|
33
|
+
// Don't show if already qualified
|
|
34
|
+
if (qualified) {
|
|
35
|
+
return (
|
|
36
|
+
<div className={cn('rounded-none border border-green-200 bg-green-50 p-3', className)}>
|
|
37
|
+
<div className="flex items-center gap-2 text-sm font-medium text-green-700">
|
|
38
|
+
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
39
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
40
|
+
</svg>
|
|
41
|
+
{t('freeShippingQualified')}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className={cn('rounded-none border border-amber-200 bg-amber-50 p-3', className)}>
|
|
49
|
+
<p className="mb-2 text-sm text-amber-800">
|
|
50
|
+
{t('freeShippingRemaining', {
|
|
51
|
+
amount: formatPrice(remaining, { currency }) as string,
|
|
52
|
+
})}
|
|
53
|
+
</p>
|
|
54
|
+
<div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
|
|
55
|
+
<div
|
|
56
|
+
className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
|
|
57
|
+
style={{ width: `${progress}%` }}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import type { ReservationInfo } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface ReservationCountdownProps {
|
|
9
|
+
reservation: ReservationInfo;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
|
|
14
|
+
const t = useTranslations('reservation');
|
|
15
|
+
const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
|
|
16
|
+
|
|
17
|
+
const calculateRemaining = useCallback(() => {
|
|
18
|
+
if (!reservation.expiresAt) return 0;
|
|
19
|
+
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
20
|
+
const nowMs = Date.now();
|
|
21
|
+
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
22
|
+
}, [reservation.expiresAt]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setRemainingSeconds(calculateRemaining());
|
|
26
|
+
|
|
27
|
+
const interval = setInterval(() => {
|
|
28
|
+
const remaining = calculateRemaining();
|
|
29
|
+
setRemainingSeconds(remaining);
|
|
30
|
+
|
|
31
|
+
if (remaining <= 0) {
|
|
32
|
+
clearInterval(interval);
|
|
33
|
+
}
|
|
34
|
+
}, 1000);
|
|
35
|
+
|
|
36
|
+
return () => clearInterval(interval);
|
|
37
|
+
}, [calculateRemaining]);
|
|
38
|
+
|
|
39
|
+
if (!reservation.hasReservation) return null;
|
|
40
|
+
|
|
41
|
+
const minutes = Math.floor(remainingSeconds / 60);
|
|
42
|
+
const seconds = remainingSeconds % 60;
|
|
43
|
+
const isExpired = remainingSeconds <= 0;
|
|
44
|
+
const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
|
|
45
|
+
|
|
46
|
+
const displayMessage = reservation.countdownMessage
|
|
47
|
+
? reservation.countdownMessage.replace(
|
|
48
|
+
'{time}',
|
|
49
|
+
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
50
|
+
)
|
|
51
|
+
: null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
className={cn(
|
|
56
|
+
'flex items-center gap-3 rounded-none px-4 py-3 text-sm',
|
|
57
|
+
isExpired
|
|
58
|
+
? 'bg-destructive/10 border-destructive/20 text-destructive border'
|
|
59
|
+
: isUrgent
|
|
60
|
+
? 'border border-orange-200 bg-orange-50 text-orange-800 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300'
|
|
61
|
+
: 'bg-primary/5 border-primary/20 text-foreground border',
|
|
62
|
+
className
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<svg
|
|
66
|
+
className={cn(
|
|
67
|
+
'h-5 w-5 flex-shrink-0',
|
|
68
|
+
isExpired
|
|
69
|
+
? 'text-destructive'
|
|
70
|
+
: isUrgent
|
|
71
|
+
? 'text-orange-600 dark:text-orange-400'
|
|
72
|
+
: 'text-primary'
|
|
73
|
+
)}
|
|
74
|
+
fill="none"
|
|
75
|
+
viewBox="0 0 24 24"
|
|
76
|
+
stroke="currentColor"
|
|
77
|
+
>
|
|
78
|
+
<path
|
|
79
|
+
strokeLinecap="round"
|
|
80
|
+
strokeLinejoin="round"
|
|
81
|
+
strokeWidth={2}
|
|
82
|
+
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
83
|
+
/>
|
|
84
|
+
</svg>
|
|
85
|
+
|
|
86
|
+
<div className="flex-1">
|
|
87
|
+
{isExpired ? (
|
|
88
|
+
<p className="font-medium">{t('expired')}</p>
|
|
89
|
+
) : displayMessage ? (
|
|
90
|
+
<p>{displayMessage}</p>
|
|
91
|
+
) : (
|
|
92
|
+
<p>
|
|
93
|
+
{isUrgent ? `${t('hurry')} ` : ''}
|
|
94
|
+
{t('reservedFor')}{' '}
|
|
95
|
+
<span className="font-semibold tabular-nums">
|
|
96
|
+
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
97
|
+
</span>
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
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
|
+
}
|