create-brainerce-store 1.78.0 → 1.80.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 +27 -2
- package/messages/en.json +12 -3
- package/messages/he.json +12 -3
- package/package.json +10 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AGENTS.md.ejs +23 -5
- package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
- package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
- package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
- package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
- package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
- package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
- package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
- package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
- package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
|
@@ -1,101 +1,110 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Link } from '@/core/lib/navigation';
|
|
4
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
-
import type { ProductRecommendation } from 'brainerce';
|
|
6
|
-
import { PriceDisplay } from '@/ui/product/price-display';
|
|
7
|
-
import { cn } from '@/core/lib/utils';
|
|
8
|
-
|
|
9
|
-
interface RecommendationCardProps {
|
|
10
|
-
item: ProductRecommendation;
|
|
11
|
-
className?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function RecommendationCard({ item, className }: RecommendationCardProps) {
|
|
15
|
-
const firstImage = item.images?.[0];
|
|
16
|
-
const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
|
|
17
|
-
const slug = item.slug || item.id;
|
|
18
|
-
// ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
|
|
19
|
-
// trimmed shape that carries `basePrice` / `salePrice` only: the backend
|
|
20
|
-
// attaches no displayPrice/displayCurrency to it, so there is no converted
|
|
21
|
-
// amount to render and converting here would mean inventing an FX rate.
|
|
22
|
-
// These tiles stay in the store currency on a multi-region store. Do not
|
|
23
|
-
// "fix" it with a client-side conversion.
|
|
24
|
-
const basePrice = parseFloat(item.basePrice);
|
|
25
|
-
const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
|
|
26
|
-
const isOnSale = salePrice != null && salePrice < basePrice;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Link } from '@/core/lib/navigation';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { ProductRecommendation } from 'brainerce';
|
|
6
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface RecommendationCardProps {
|
|
10
|
+
item: ProductRecommendation;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function RecommendationCard({ item, className }: RecommendationCardProps) {
|
|
15
|
+
const firstImage = item.images?.[0];
|
|
16
|
+
const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
|
|
17
|
+
const slug = item.slug || item.id;
|
|
18
|
+
// ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
|
|
19
|
+
// trimmed shape that carries `basePrice` / `salePrice` only: the backend
|
|
20
|
+
// attaches no displayPrice/displayCurrency to it, so there is no converted
|
|
21
|
+
// amount to render and converting here would mean inventing an FX rate.
|
|
22
|
+
// These tiles stay in the store currency on a multi-region store. Do not
|
|
23
|
+
// "fix" it with a client-side conversion.
|
|
24
|
+
const basePrice = parseFloat(item.basePrice);
|
|
25
|
+
const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
|
|
26
|
+
const isOnSale = salePrice != null && salePrice < basePrice;
|
|
27
|
+
// ⛔ No price on a KIT tile. The recommendations endpoint does not resolve
|
|
28
|
+
// kits, so `item.basePrice` here is the kit's stored PLACEHOLDER — right only
|
|
29
|
+
// for FIXED pricing and wrong for every SUM / SUM_MINUS_PERCENT kit. Showing
|
|
30
|
+
// it would quote a price the product page then contradicts. The tile still
|
|
31
|
+
// links through, and the product page reads the resolved price from the API.
|
|
32
|
+
// Do NOT recompute a kit price on the client.
|
|
33
|
+
const priceIsUnresolved = item.type === 'KIT';
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Link
|
|
37
|
+
href={`/products/${slug}`}
|
|
38
|
+
className={cn('group card card-hover block overflow-hidden', className)}
|
|
39
|
+
>
|
|
40
|
+
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
41
|
+
<span className="relative block aspect-square overflow-hidden bg-secondary">
|
|
42
|
+
{imageUrl ? (
|
|
43
|
+
<Image
|
|
44
|
+
src={imageUrl}
|
|
45
|
+
alt={item.name}
|
|
46
|
+
fill
|
|
47
|
+
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
|
48
|
+
className="img-zoom object-cover"
|
|
49
|
+
/>
|
|
50
|
+
) : null}
|
|
51
|
+
</span>
|
|
52
|
+
<span className="block space-y-1 p-4">
|
|
53
|
+
<span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
|
|
54
|
+
{item.name}
|
|
55
|
+
</span>
|
|
56
|
+
{priceIsUnresolved ? null : (
|
|
57
|
+
<PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
|
|
58
|
+
)}
|
|
59
|
+
</span>
|
|
60
|
+
</Link>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RecommendationSectionProps {
|
|
65
|
+
title: string;
|
|
66
|
+
items: ProductRecommendation[];
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Recommendation strip on the product page (upsells / related). */
|
|
71
|
+
export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
|
|
72
|
+
if (items.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section className={cn('border-t pt-10', className)}>
|
|
76
|
+
<h2 className="mb-6 text-2xl">{title}</h2>
|
|
77
|
+
<div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
|
|
78
|
+
{items.slice(0, 4).map((item) => (
|
|
79
|
+
<RecommendationCard key={item.id} item={item} />
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</section>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CartRecommendationSectionProps {
|
|
87
|
+
title: string;
|
|
88
|
+
items: ProductRecommendation[];
|
|
89
|
+
className?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Cross-sell strip on the cart page ("you might also need"). */
|
|
93
|
+
export function CartRecommendationSection({
|
|
94
|
+
title,
|
|
95
|
+
items,
|
|
96
|
+
className,
|
|
97
|
+
}: CartRecommendationSectionProps) {
|
|
98
|
+
if (items.length === 0) return null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<section className={cn('border-t pt-10', className)}>
|
|
102
|
+
<h2 className="mb-6 text-2xl">{title}</h2>
|
|
103
|
+
<div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
|
|
104
|
+
{items.slice(0, 4).map((item) => (
|
|
105
|
+
<RecommendationCard key={item.id} item={item} />
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
</section>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -5,9 +5,20 @@ import { cn } from '@/core/lib/utils';
|
|
|
5
5
|
import { useTranslations } from '@/core/lib/translations';
|
|
6
6
|
import { useStoreCapabilities } from '@/core/providers/store-provider';
|
|
7
7
|
import { resolveLowStockThreshold } from '@/core/lib/capabilities';
|
|
8
|
+
import { kitInventory } from '@/core/lib/kit';
|
|
8
9
|
|
|
9
10
|
interface StockBadgeProps {
|
|
10
11
|
inventory: InventoryInfo | null | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* KIT stock — pass `product.kitAvailable` whenever the product may be a kit.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ A KIT has NO `inventory` block, so passing `inventory` alone sent every
|
|
16
|
+
* kit down the `!inventory` branch below and printed a red "Out of stock" on
|
|
17
|
+
* every card, category page, search result and product page — while the buy
|
|
18
|
+
* button beside it stayed enabled. `undefined` here means "not a kit";
|
|
19
|
+
* `null` means "unlimited". They are not interchangeable.
|
|
20
|
+
*/
|
|
21
|
+
kitAvailable?: number | null;
|
|
11
22
|
/**
|
|
12
23
|
* Override the threshold. Leave it unset — the merchant's configured value
|
|
13
24
|
* comes from the store's capabilities, and a hardcoded number here shows the
|
|
@@ -23,14 +34,21 @@ type StockState = 'in' | 'low' | 'out';
|
|
|
23
34
|
* Stock status pill (in stock / low stock / out of stock) with a colored
|
|
24
35
|
* status dot; derives the label from the product/variant `inventory` prop.
|
|
25
36
|
*/
|
|
26
|
-
export function StockBadge({
|
|
37
|
+
export function StockBadge({
|
|
38
|
+
inventory,
|
|
39
|
+
kitAvailable,
|
|
40
|
+
lowStockThreshold,
|
|
41
|
+
className,
|
|
42
|
+
}: StockBadgeProps) {
|
|
27
43
|
const t = useTranslations('productDetail');
|
|
28
44
|
// Merchant-configured, from the channel's capabilities. Falls back to the
|
|
29
45
|
// platform default while the fetch is in flight or if it failed, and resolves
|
|
30
46
|
// to 0 (nothing is ever low) when the merchant turned low-stock warnings off.
|
|
31
47
|
const { capabilities } = useStoreCapabilities();
|
|
48
|
+
// A kit reports from `kitAvailable`; everything else from its inventory row.
|
|
49
|
+
const stock = kitAvailable !== undefined ? kitInventory(kitAvailable) : inventory;
|
|
32
50
|
const { label, state } = getStock(
|
|
33
|
-
|
|
51
|
+
stock,
|
|
34
52
|
lowStockThreshold ?? resolveLowStockThreshold(capabilities),
|
|
35
53
|
t
|
|
36
54
|
);
|
|
@@ -65,6 +83,8 @@ function getStock(
|
|
|
65
83
|
lowStockThreshold: number,
|
|
66
84
|
t: (key: string) => string
|
|
67
85
|
): { label: string; state: StockState } {
|
|
86
|
+
// No stock signal at all. A KIT reaches here only when the caller forgot to
|
|
87
|
+
// pass `kitAvailable` — see the prop comment above.
|
|
68
88
|
if (!inventory) return { label: t('outOfStock'), state: 'out' };
|
|
69
89
|
|
|
70
90
|
const { trackingMode, inStock, available } = inventory;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import type { Checkout } from 'brainerce';
|
|
5
|
+
import { BrainerceError } from 'brainerce';
|
|
5
6
|
import { getClient } from '@/core/lib/brainerce';
|
|
6
7
|
import { useTranslations } from '@/core/lib/translations';
|
|
7
8
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
@@ -11,6 +12,11 @@ interface GiftCardInputProps {
|
|
|
11
12
|
checkoutId: string;
|
|
12
13
|
/** Cards already on this checkout. Each is removed by its own tenderId. */
|
|
13
14
|
tenders: NonNullable<Checkout['tenders']>;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the checkout can still take a card. False once a payment intent
|
|
17
|
+
* exists — the server refuses every tender change from then on.
|
|
18
|
+
*/
|
|
19
|
+
locked: boolean;
|
|
14
20
|
/** Formats an amount in the checkout's currency. */
|
|
15
21
|
formatAmount: (value: string) => string;
|
|
16
22
|
onUpdate: () => void;
|
|
@@ -35,14 +41,22 @@ interface GiftCardInputProps {
|
|
|
35
41
|
*
|
|
36
42
|
* Refusals are deliberately identical for "no such code", "expired", "already
|
|
37
43
|
* spent" and "wrong currency": anything that tells them apart is an oracle
|
|
38
|
-
* someone walks the code space against. Show
|
|
39
|
-
* a more
|
|
44
|
+
* someone walks the code space against. Show THIS store's one message for all
|
|
45
|
+
* of them — never the server's text, which is English and could gain a more
|
|
46
|
+
* helpful sentence later.
|
|
47
|
+
*
|
|
48
|
+
* Two failures are not about the card and must not read as if they were: a 5xx
|
|
49
|
+
* or dropped connection, and a checkout already locked for payment. When
|
|
50
|
+
* `locked` is true, do not render the field at all — the server refuses both
|
|
51
|
+
* applying and removing, changing shipping does not unlock it, and a box that
|
|
52
|
+
* can never work is worse than no box.
|
|
40
53
|
*
|
|
41
54
|
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
42
55
|
*/
|
|
43
56
|
export function GiftCardInput({
|
|
44
57
|
checkoutId,
|
|
45
58
|
tenders,
|
|
59
|
+
locked,
|
|
46
60
|
formatAmount,
|
|
47
61
|
onUpdate,
|
|
48
62
|
className,
|
|
@@ -54,6 +68,13 @@ export function GiftCardInput({
|
|
|
54
68
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
|
55
69
|
const [error, setError] = useState<string | null>(null);
|
|
56
70
|
|
|
71
|
+
/** `CHECKOUT_LOCKED` is the one 4xx that says nothing about the card. */
|
|
72
|
+
function isCheckoutLocked(err: unknown): boolean {
|
|
73
|
+
if (!(err instanceof BrainerceError)) return false;
|
|
74
|
+
const details = err.details as { code?: unknown } | null | undefined;
|
|
75
|
+
return details?.code === 'CHECKOUT_LOCKED';
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
async function handleApply() {
|
|
58
79
|
const trimmed = code.trim();
|
|
59
80
|
if (!trimmed || applying) return;
|
|
@@ -67,7 +88,10 @@ export function GiftCardInput({
|
|
|
67
88
|
setCode('');
|
|
68
89
|
onUpdate();
|
|
69
90
|
} catch (err) {
|
|
70
|
-
|
|
91
|
+
const refused = err instanceof BrainerceError && err.statusCode < 500;
|
|
92
|
+
setError(
|
|
93
|
+
isCheckoutLocked(err) ? t('locked') : refused ? t('invalidCode') : t('applyFailed')
|
|
94
|
+
);
|
|
71
95
|
} finally {
|
|
72
96
|
setApplying(false);
|
|
73
97
|
}
|
|
@@ -82,7 +106,7 @@ export function GiftCardInput({
|
|
|
82
106
|
await getClient().removeGiftCard(checkoutId, tenderId);
|
|
83
107
|
onUpdate();
|
|
84
108
|
} catch (err) {
|
|
85
|
-
setError(err
|
|
109
|
+
setError(isCheckoutLocked(err) ? t('locked') : t('removeFailed'));
|
|
86
110
|
} finally {
|
|
87
111
|
setRemovingId(null);
|
|
88
112
|
}
|
|
@@ -98,18 +122,23 @@ export function GiftCardInput({
|
|
|
98
122
|
₪50 order applies ₪50 and keeps the rest for next time —
|
|
99
123
|
showing the balance here would look like a lost ₪150. */}
|
|
100
124
|
<span>{t('applied', { amount: formatAmount(tender.amountApplied) })}</span>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
125
|
+
{!locked && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => handleRemove(tender.tenderId)}
|
|
129
|
+
disabled={removingId === tender.tenderId}
|
|
130
|
+
>
|
|
131
|
+
{removingId === tender.tenderId ? tc('removing') : tc('remove')}
|
|
132
|
+
</button>
|
|
133
|
+
)}
|
|
108
134
|
</li>
|
|
109
135
|
))}
|
|
110
136
|
</ul>
|
|
111
137
|
)}
|
|
112
138
|
|
|
139
|
+
{locked ? (
|
|
140
|
+
tenders.length > 0 ? <p>{t('lockedHint')}</p> : null
|
|
141
|
+
) : (
|
|
113
142
|
<div className="flex gap-2">
|
|
114
143
|
<input
|
|
115
144
|
type="text"
|
|
@@ -135,6 +164,7 @@ export function GiftCardInput({
|
|
|
135
164
|
{applying ? <LoadingSpinner size="sm" /> : tc('apply')}
|
|
136
165
|
</button>
|
|
137
166
|
</div>
|
|
167
|
+
)}
|
|
138
168
|
|
|
139
169
|
{error && <p role="alert">{error}</p>}
|
|
140
170
|
</div>
|
|
@@ -17,9 +17,18 @@
|
|
|
17
17
|
*
|
|
18
18
|
* ⛔ KEEP THE HONEYPOT. It is the bot filter; deleting the input silently opens
|
|
19
19
|
* the form up.
|
|
20
|
+
*
|
|
21
|
+
* **The welcome offer.** `marketing.getBenefit()` returns what the merchant
|
|
22
|
+
* offers people who confirm, or `null` when they offer nothing. Show its terms
|
|
23
|
+
* beside the field. ⛔ NEVER RENDER A COUPON CODE — none exists here or in the
|
|
24
|
+
* success state; it is minted by the confirmation click and emailed to the
|
|
25
|
+
* recipient, which is what stops a forwarded link handing the discount to
|
|
26
|
+
* someone who never asked. There is no eligibility check to call either: a
|
|
27
|
+
* per-address answer would let anyone test who already subscribed.
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
|
-
import { useMemo, useState } from 'react';
|
|
30
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
31
|
+
import type { PublicNewsletterBenefitOffer } from 'brainerce';
|
|
23
32
|
import { getClient } from '@/core/lib/brainerce';
|
|
24
33
|
import { useTranslations } from '@/core/lib/translations';
|
|
25
34
|
|
|
@@ -30,6 +39,9 @@ export function NewsletterSignup() {
|
|
|
30
39
|
const [loading, setLoading] = useState(false);
|
|
31
40
|
const [done, setDone] = useState(false);
|
|
32
41
|
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
// null covers "no offer" and "the read failed" alike. Neither deserves an
|
|
43
|
+
// error state: the form still works and simply promises nothing.
|
|
44
|
+
const [offer, setOffer] = useState<PublicNewsletterBenefitOffer | null>(null);
|
|
33
45
|
|
|
34
46
|
// <html lang> carries the active locale, and passing it decides the language
|
|
35
47
|
// of the confirmation email. Omit it and a Hebrew shopper gets English.
|
|
@@ -38,6 +50,29 @@ export function NewsletterSignup() {
|
|
|
38
50
|
return undefined;
|
|
39
51
|
}, []);
|
|
40
52
|
|
|
53
|
+
// Once per mount. The offer belongs to the store, not the visitor.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let cancelled = false;
|
|
56
|
+
getClient()
|
|
57
|
+
.marketing.getBenefit(locale)
|
|
58
|
+
.then((result) => {
|
|
59
|
+
if (!cancelled) setOffer(result);
|
|
60
|
+
})
|
|
61
|
+
.catch(() => {
|
|
62
|
+
// Silent: a failed offer read must not stop anyone subscribing.
|
|
63
|
+
});
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
};
|
|
67
|
+
}, [locale]);
|
|
68
|
+
|
|
69
|
+
const offerHeadline = offer
|
|
70
|
+
? offer.headline?.trim() ||
|
|
71
|
+
(offer.discountType === 'PERCENTAGE'
|
|
72
|
+
? t('offerPercent', { value: String(offer.discountValue) })
|
|
73
|
+
: t('offerAmount', { value: String(offer.discountValue) }))
|
|
74
|
+
: null;
|
|
75
|
+
|
|
41
76
|
async function handleSubmit(e: React.FormEvent) {
|
|
42
77
|
e.preventDefault();
|
|
43
78
|
if (loading || !email.trim()) return;
|
|
@@ -68,13 +103,9 @@ export function NewsletterSignup() {
|
|
|
68
103
|
{/* DESIGN ME — newsletter success state. Do NOT reword to "you're subscribed": the address is not on the list until the recipient clicks the emailed link. Naming the spam folder is deliberate — a filtered confirmation is the commonest reason a signup never converts, and no second copy is sent for 24 hours. */}
|
|
69
104
|
<p>{t('checkEmailTitle')}</p>
|
|
70
105
|
<p>{t('checkEmailBody')}</p>
|
|
71
|
-
{/*
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<p>{t('discountCode', { code: 'WELCOME10' })}</p>
|
|
75
|
-
The code comes from a dashboard coupon with the `customer_first_order`
|
|
76
|
-
condition; subscribing mints nothing on its own.
|
|
77
|
-
*/}
|
|
106
|
+
{/* Says where the code comes from without ever showing one: the coupon
|
|
107
|
+
is created by the confirmation click and lands in the same inbox. */}
|
|
108
|
+
{offer ? <p>{t('offerByEmail')}</p> : null}
|
|
78
109
|
</div>
|
|
79
110
|
);
|
|
80
111
|
}
|
|
@@ -85,6 +116,17 @@ export function NewsletterSignup() {
|
|
|
85
116
|
<label htmlFor="newsletter-email">{t('title')}</label>
|
|
86
117
|
<p>{t('subtitle')}</p>
|
|
87
118
|
|
|
119
|
+
{/* DESIGN ME — the welcome offer, when the merchant configured one. Keep
|
|
120
|
+
both lines: the headline is the promise and the terms are its
|
|
121
|
+
conditions, and a shopper who reads one without the other is being
|
|
122
|
+
mis-sold. Never add a coupon code here. */}
|
|
123
|
+
{offerHeadline ? (
|
|
124
|
+
<div>
|
|
125
|
+
<p>{offerHeadline}</p>
|
|
126
|
+
{offer?.terms ? <p>{offer.terms}</p> : null}
|
|
127
|
+
</div>
|
|
128
|
+
) : null}
|
|
129
|
+
|
|
88
130
|
<input
|
|
89
131
|
id="newsletter-email"
|
|
90
132
|
type="email"
|