create-brainerce-store 1.62.0 → 1.63.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/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/index.js +5 -2
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +262 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -106
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +10 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -44
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -175
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +179 -179
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +9 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +10 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { Tag } from 'lucide-react';
|
|
5
|
-
import type { Cart } from 'brainerce';
|
|
6
|
-
import { getClient } from '@/core/lib/brainerce';
|
|
7
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
8
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
-
import { Button } from '@/components/ui/button';
|
|
10
|
-
import { Input } from '@/components/ui/input';
|
|
11
|
-
import { cn } from '@/core/lib/utils';
|
|
12
|
-
|
|
13
|
-
interface CouponInputProps {
|
|
14
|
-
cart: Cart;
|
|
15
|
-
/** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
|
|
16
|
-
* applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
|
|
17
|
-
checkoutId?: string;
|
|
18
|
-
onUpdate: () => void;
|
|
19
|
-
className?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
|
|
23
|
-
const t = useTranslations('coupon');
|
|
24
|
-
const tc = useTranslations('common');
|
|
25
|
-
const [code, setCode] = useState('');
|
|
26
|
-
const [applying, setApplying] = useState(false);
|
|
27
|
-
const [removing, setRemoving] = useState(false);
|
|
28
|
-
const [error, setError] = useState<string | null>(null);
|
|
29
|
-
|
|
30
|
-
const appliedCoupon = cart.couponCode || null;
|
|
31
|
-
|
|
32
|
-
async function handleApply() {
|
|
33
|
-
const trimmed = code.trim();
|
|
34
|
-
if (!trimmed || applying) return;
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
setApplying(true);
|
|
38
|
-
setError(null);
|
|
39
|
-
const client = getClient();
|
|
40
|
-
if (checkoutId) {
|
|
41
|
-
await client.applyCheckoutCoupon(checkoutId, trimmed);
|
|
42
|
-
} else {
|
|
43
|
-
await client.applyCoupon(cart.id, trimmed);
|
|
44
|
-
}
|
|
45
|
-
setCode('');
|
|
46
|
-
onUpdate();
|
|
47
|
-
} catch (err) {
|
|
48
|
-
const message = err instanceof Error ? err.message : t('invalidCode');
|
|
49
|
-
setError(message);
|
|
50
|
-
} finally {
|
|
51
|
-
setApplying(false);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function handleRemove() {
|
|
56
|
-
if (removing) return;
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
setRemoving(true);
|
|
60
|
-
setError(null);
|
|
61
|
-
const client = getClient();
|
|
62
|
-
if (checkoutId) {
|
|
63
|
-
await client.removeCheckoutCoupon(checkoutId);
|
|
64
|
-
} else {
|
|
65
|
-
await client.removeCoupon(cart.id);
|
|
66
|
-
}
|
|
67
|
-
onUpdate();
|
|
68
|
-
} catch (err) {
|
|
69
|
-
console.error('Failed to remove coupon:', err);
|
|
70
|
-
} finally {
|
|
71
|
-
setRemoving(false);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Show applied coupon
|
|
76
|
-
if (appliedCoupon) {
|
|
77
|
-
return (
|
|
78
|
-
<div className={cn('space-y-2', className)}>
|
|
79
|
-
<div className="bg-muted flex items-center justify-between rounded px-3 py-2">
|
|
80
|
-
<div className="flex items-center gap-2">
|
|
81
|
-
<Tag className="text-primary h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
|
82
|
-
<span className="text-foreground text-sm font-medium">{appliedCoupon}</span>
|
|
83
|
-
</div>
|
|
84
|
-
<button
|
|
85
|
-
type="button"
|
|
86
|
-
onClick={handleRemove}
|
|
87
|
-
disabled={removing}
|
|
88
|
-
className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
|
|
89
|
-
>
|
|
90
|
-
{removing ? tc('removing') : tc('remove')}
|
|
91
|
-
</button>
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return (
|
|
98
|
-
<div className={cn('space-y-2', className)}>
|
|
99
|
-
<div className="flex gap-2">
|
|
100
|
-
<Input
|
|
101
|
-
type="text"
|
|
102
|
-
value={code}
|
|
103
|
-
onChange={(e) => {
|
|
104
|
-
setCode(e.target.value);
|
|
105
|
-
if (error) setError(null);
|
|
106
|
-
}}
|
|
107
|
-
onKeyDown={(e) => {
|
|
108
|
-
if (e.key === 'Enter') {
|
|
109
|
-
e.preventDefault();
|
|
110
|
-
handleApply();
|
|
111
|
-
}
|
|
112
|
-
}}
|
|
113
|
-
placeholder={t('placeholder')}
|
|
114
|
-
className={cn('h-9 flex-1 rounded', error && 'border-destructive')}
|
|
115
|
-
/>
|
|
116
|
-
<Button
|
|
117
|
-
type="button"
|
|
118
|
-
variant="outline"
|
|
119
|
-
onClick={handleApply}
|
|
120
|
-
disabled={applying || !code.trim()}
|
|
121
|
-
className="h-9 rounded px-4 hover:bg-muted"
|
|
122
|
-
>
|
|
123
|
-
{applying ? (
|
|
124
|
-
<LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
|
|
125
|
-
) : (
|
|
126
|
-
tc('apply')
|
|
127
|
-
)}
|
|
128
|
-
</Button>
|
|
129
|
-
</div>
|
|
130
|
-
|
|
131
|
-
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
132
|
-
</div>
|
|
133
|
-
);
|
|
134
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Tag } from 'lucide-react';
|
|
5
|
+
import type { Cart } from 'brainerce';
|
|
6
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
7
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
8
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
+
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { Input } from '@/components/ui/input';
|
|
11
|
+
import { cn } from '@/core/lib/utils';
|
|
12
|
+
|
|
13
|
+
interface CouponInputProps {
|
|
14
|
+
cart: Cart;
|
|
15
|
+
/** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
|
|
16
|
+
* applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
|
|
17
|
+
checkoutId?: string;
|
|
18
|
+
onUpdate: () => void;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
|
|
23
|
+
const t = useTranslations('coupon');
|
|
24
|
+
const tc = useTranslations('common');
|
|
25
|
+
const [code, setCode] = useState('');
|
|
26
|
+
const [applying, setApplying] = useState(false);
|
|
27
|
+
const [removing, setRemoving] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
const appliedCoupon = cart.couponCode || null;
|
|
31
|
+
|
|
32
|
+
async function handleApply() {
|
|
33
|
+
const trimmed = code.trim();
|
|
34
|
+
if (!trimmed || applying) return;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
setApplying(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
const client = getClient();
|
|
40
|
+
if (checkoutId) {
|
|
41
|
+
await client.applyCheckoutCoupon(checkoutId, trimmed);
|
|
42
|
+
} else {
|
|
43
|
+
await client.applyCoupon(cart.id, trimmed);
|
|
44
|
+
}
|
|
45
|
+
setCode('');
|
|
46
|
+
onUpdate();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const message = err instanceof Error ? err.message : t('invalidCode');
|
|
49
|
+
setError(message);
|
|
50
|
+
} finally {
|
|
51
|
+
setApplying(false);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function handleRemove() {
|
|
56
|
+
if (removing) return;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
setRemoving(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
const client = getClient();
|
|
62
|
+
if (checkoutId) {
|
|
63
|
+
await client.removeCheckoutCoupon(checkoutId);
|
|
64
|
+
} else {
|
|
65
|
+
await client.removeCoupon(cart.id);
|
|
66
|
+
}
|
|
67
|
+
onUpdate();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error('Failed to remove coupon:', err);
|
|
70
|
+
} finally {
|
|
71
|
+
setRemoving(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Show applied coupon
|
|
76
|
+
if (appliedCoupon) {
|
|
77
|
+
return (
|
|
78
|
+
<div className={cn('space-y-2', className)}>
|
|
79
|
+
<div className="bg-muted flex items-center justify-between rounded px-3 py-2">
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
81
|
+
<Tag className="text-primary h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
|
82
|
+
<span className="text-foreground text-sm font-medium">{appliedCoupon}</span>
|
|
83
|
+
</div>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={handleRemove}
|
|
87
|
+
disabled={removing}
|
|
88
|
+
className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
|
|
89
|
+
>
|
|
90
|
+
{removing ? tc('removing') : tc('remove')}
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className={cn('space-y-2', className)}>
|
|
99
|
+
<div className="flex gap-2">
|
|
100
|
+
<Input
|
|
101
|
+
type="text"
|
|
102
|
+
value={code}
|
|
103
|
+
onChange={(e) => {
|
|
104
|
+
setCode(e.target.value);
|
|
105
|
+
if (error) setError(null);
|
|
106
|
+
}}
|
|
107
|
+
onKeyDown={(e) => {
|
|
108
|
+
if (e.key === 'Enter') {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
handleApply();
|
|
111
|
+
}
|
|
112
|
+
}}
|
|
113
|
+
placeholder={t('placeholder')}
|
|
114
|
+
className={cn('h-9 flex-1 rounded', error && 'border-destructive')}
|
|
115
|
+
/>
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
variant="outline"
|
|
119
|
+
onClick={handleApply}
|
|
120
|
+
disabled={applying || !code.trim()}
|
|
121
|
+
className="h-9 rounded px-4 hover:bg-muted"
|
|
122
|
+
>
|
|
123
|
+
{applying ? (
|
|
124
|
+
<LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
|
|
125
|
+
) : (
|
|
126
|
+
tc('apply')
|
|
127
|
+
)}
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Check } from 'lucide-react';
|
|
4
|
-
import { formatPrice } from 'brainerce';
|
|
5
|
-
import { useStoreInfo, useCart } from '@/core/providers/store-provider';
|
|
6
|
-
import { useCurrency } from '@/core/lib/use-currency';
|
|
7
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
8
|
-
import { cn } from '@/core/lib/utils';
|
|
9
|
-
|
|
10
|
-
interface FreeShippingBarProps {
|
|
11
|
-
className?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function FreeShippingBar({ className }: FreeShippingBarProps) {
|
|
15
|
-
// Hooks must be called unconditionally and in the same order on every
|
|
16
|
-
// render — keep all of them above any early `return null` branch.
|
|
17
|
-
const t = useTranslations('cart');
|
|
18
|
-
const { storeInfo } = useStoreInfo();
|
|
19
|
-
const { totals } = useCart();
|
|
20
|
-
const currency = useCurrency();
|
|
21
|
-
|
|
22
|
-
const upsell = storeInfo?.upsell;
|
|
23
|
-
const threshold = upsell?.freeShippingThreshold;
|
|
24
|
-
const enabled = upsell?.freeShippingBarEnabled !== false;
|
|
25
|
-
|
|
26
|
-
// Don't render if disabled or no threshold configured
|
|
27
|
-
if (!enabled || !threshold || threshold <= 0) return null;
|
|
28
|
-
|
|
29
|
-
const subtotal = totals.subtotal;
|
|
30
|
-
const remaining = Math.max(0, threshold - subtotal);
|
|
31
|
-
const progress = Math.min(100, (subtotal / threshold) * 100);
|
|
32
|
-
const qualified = remaining <= 0;
|
|
33
|
-
|
|
34
|
-
// Don't show if already qualified
|
|
35
|
-
if (qualified) {
|
|
36
|
-
return (
|
|
37
|
-
<div className={cn('rounded-lg border border-green-200 bg-green-50 p-3', className)}>
|
|
38
|
-
<div className="flex items-center gap-2 text-sm font-medium text-green-700">
|
|
39
|
-
<Check className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
40
|
-
{t('freeShippingQualified')}
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<div className={cn('rounded-lg border border-amber-200 bg-amber-50 p-3', className)}>
|
|
48
|
-
<p className="mb-2 text-sm text-amber-800">
|
|
49
|
-
{t('freeShippingRemaining', {
|
|
50
|
-
amount: formatPrice(remaining, { currency }) as string,
|
|
51
|
-
})}
|
|
52
|
-
</p>
|
|
53
|
-
<div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
|
|
54
|
-
<div
|
|
55
|
-
className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
|
|
56
|
-
style={{ width: `${progress}%` }}
|
|
57
|
-
/>
|
|
58
|
-
</div>
|
|
59
|
-
</div>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import { formatPrice } from 'brainerce';
|
|
5
|
+
import { useStoreInfo, useCart } from '@/core/providers/store-provider';
|
|
6
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
7
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
8
|
+
import { cn } from '@/core/lib/utils';
|
|
9
|
+
|
|
10
|
+
interface FreeShippingBarProps {
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function FreeShippingBar({ className }: FreeShippingBarProps) {
|
|
15
|
+
// Hooks must be called unconditionally and in the same order on every
|
|
16
|
+
// render — keep all of them above any early `return null` branch.
|
|
17
|
+
const t = useTranslations('cart');
|
|
18
|
+
const { storeInfo } = useStoreInfo();
|
|
19
|
+
const { totals } = useCart();
|
|
20
|
+
const currency = useCurrency();
|
|
21
|
+
|
|
22
|
+
const upsell = storeInfo?.upsell;
|
|
23
|
+
const threshold = upsell?.freeShippingThreshold;
|
|
24
|
+
const enabled = upsell?.freeShippingBarEnabled !== false;
|
|
25
|
+
|
|
26
|
+
// Don't render if disabled or no threshold configured
|
|
27
|
+
if (!enabled || !threshold || threshold <= 0) return null;
|
|
28
|
+
|
|
29
|
+
const subtotal = totals.subtotal;
|
|
30
|
+
const remaining = Math.max(0, threshold - subtotal);
|
|
31
|
+
const progress = Math.min(100, (subtotal / threshold) * 100);
|
|
32
|
+
const qualified = remaining <= 0;
|
|
33
|
+
|
|
34
|
+
// Don't show if already qualified
|
|
35
|
+
if (qualified) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={cn('rounded-lg border border-green-200 bg-green-50 p-3', className)}>
|
|
38
|
+
<div className="flex items-center gap-2 text-sm font-medium text-green-700">
|
|
39
|
+
<Check className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
40
|
+
{t('freeShippingQualified')}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={cn('rounded-lg border border-amber-200 bg-amber-50 p-3', className)}>
|
|
48
|
+
<p className="mb-2 text-sm text-amber-800">
|
|
49
|
+
{t('freeShippingRemaining', {
|
|
50
|
+
amount: formatPrice(remaining, { currency }) as string,
|
|
51
|
+
})}
|
|
52
|
+
</p>
|
|
53
|
+
<div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
|
|
54
|
+
<div
|
|
55
|
+
className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
|
|
56
|
+
style={{ width: `${progress}%` }}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -1,95 +1,95 @@
|
|
|
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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { DiscountBanner } from 'brainerce';
|
|
4
|
-
|
|
5
|
-
/** Store-wide discount banner strip shown at the very top of the homepage. */
|
|
6
|
-
export function DiscountBannerStrip({ banners }: { banners: DiscountBanner[] }) {
|
|
7
|
-
if (banners.length === 0) return null;
|
|
8
|
-
|
|
9
|
-
return (
|
|
10
|
-
<div className="bg-primary text-primary-foreground">
|
|
11
|
-
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
|
|
12
|
-
<div className="flex items-center justify-center gap-4 overflow-x-auto text-sm font-medium">
|
|
13
|
-
{banners.map((banner) => (
|
|
14
|
-
<span key={banner.ruleId}>{banner.text}</span>
|
|
15
|
-
))}
|
|
16
|
-
</div>
|
|
17
|
-
</div>
|
|
18
|
-
</div>
|
|
19
|
-
);
|
|
20
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { DiscountBanner } from 'brainerce';
|
|
4
|
+
|
|
5
|
+
/** Store-wide discount banner strip shown at the very top of the homepage. */
|
|
6
|
+
export function DiscountBannerStrip({ banners }: { banners: DiscountBanner[] }) {
|
|
7
|
+
if (banners.length === 0) return null;
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="bg-primary text-primary-foreground">
|
|
11
|
+
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
|
|
12
|
+
<div className="flex items-center justify-center gap-4 overflow-x-auto text-sm font-medium">
|
|
13
|
+
{banners.map((banner) => (
|
|
14
|
+
<span key={banner.ruleId}>{banner.text}</span>
|
|
15
|
+
))}
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|