create-brainerce-store 1.86.1 → 1.88.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 +9 -2
- package/messages/en.json +15 -2
- package/messages/he.json +15 -2
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -255
- package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
- package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +8 -0
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +6 -0
- package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +37 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
- package/templates/nextjs/base/src/components/google-customer-reviews-optin.tsx +126 -0
- package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
- package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
- package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -81
- package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
- package/templates/nextjs/base/src/middleware.ts.ejs +22 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
- package/templates/nextjs/base/src/app/products/layout.tsx +0 -18
|
@@ -15,7 +15,13 @@ import type {
|
|
|
15
15
|
} from 'brainerce';
|
|
16
16
|
import { formatPrice } from 'brainerce';
|
|
17
17
|
import { getClient } from '@/core/lib/brainerce';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
useStoreInfo,
|
|
20
|
+
useCart,
|
|
21
|
+
useAuth,
|
|
22
|
+
useRegion,
|
|
23
|
+
useStoreCapabilities,
|
|
24
|
+
} from '@/core/providers/store-provider';
|
|
19
25
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
20
26
|
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
21
27
|
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
@@ -31,16 +37,68 @@ import { GiftCardInput } from '@/ui/cart/gift-card-input';
|
|
|
31
37
|
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
32
38
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
33
39
|
import { useTranslations } from '@/core/lib/translations';
|
|
34
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
addToCartErrorMessageKey,
|
|
42
|
+
toAddToCartError,
|
|
43
|
+
type AddToCartError,
|
|
44
|
+
} from '@/core/lib/add-to-cart-error';
|
|
35
45
|
import { cn } from '@/core/lib/utils';
|
|
36
46
|
import { isValidCheckoutId } from '@/core/lib/safe-redirect';
|
|
37
47
|
import { trackBeginCheckout } from '@/core/lib/tracking';
|
|
38
48
|
|
|
49
|
+
// Remembers which checkout belongs to which cart for this browser tab, so a
|
|
50
|
+
// shopper who leaves /checkout and comes back (the bare link has no
|
|
51
|
+
// `?checkout_id=`) resumes the same checkout instead of re-entering everything.
|
|
52
|
+
const CHECKOUT_RESUME_PREFIX = 'brainerce:checkout:';
|
|
53
|
+
|
|
54
|
+
function readStoredCheckoutId(cartId: string): string | null {
|
|
55
|
+
try {
|
|
56
|
+
const raw = window.sessionStorage.getItem(CHECKOUT_RESUME_PREFIX + cartId);
|
|
57
|
+
return isValidCheckoutId(raw) ? raw : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function storeCheckoutId(cartId: string, checkoutId: string): void {
|
|
64
|
+
try {
|
|
65
|
+
window.sessionStorage.setItem(CHECKOUT_RESUME_PREFIX + cartId, checkoutId);
|
|
66
|
+
} catch {
|
|
67
|
+
// Private mode / blocked storage: resuming is a convenience, never required.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clearStoredCheckoutId(cartId: string): void {
|
|
72
|
+
try {
|
|
73
|
+
window.sessionStorage.removeItem(CHECKOUT_RESUME_PREFIX + cartId);
|
|
74
|
+
} catch {
|
|
75
|
+
// ignore
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A remembered checkout is only reused while it is still open AND still
|
|
81
|
+
* describes the cart: same lines, same quantities. A cart edited since would
|
|
82
|
+
* otherwise resume a checkout that charges for something else.
|
|
83
|
+
*/
|
|
84
|
+
function matchesCart(
|
|
85
|
+
prior: Checkout,
|
|
86
|
+
cart: { items: Array<{ productId: string; variantId?: string | null; quantity: number }> }
|
|
87
|
+
): boolean {
|
|
88
|
+
if (prior.status !== 'PENDING' && prior.status !== 'SHIPPING_SET') return false;
|
|
89
|
+
const key = (i: { productId: string; variantId?: string | null; quantity: number }) =>
|
|
90
|
+
`${i.productId}:${i.variantId ?? ''}:${i.quantity}`;
|
|
91
|
+
const a = prior.lineItems.map(key).sort();
|
|
92
|
+
const b = cart.items.map(key).sort();
|
|
93
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
94
|
+
}
|
|
95
|
+
|
|
39
96
|
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
|
|
40
97
|
|
|
41
98
|
function CheckoutContent() {
|
|
42
99
|
const searchParams = useSearchParams();
|
|
43
100
|
const { storeInfo } = useStoreInfo();
|
|
101
|
+
const { capabilities } = useStoreCapabilities();
|
|
44
102
|
const { cart, refreshCart } = useCart();
|
|
45
103
|
const { isLoggedIn } = useAuth();
|
|
46
104
|
// ⛔ THE call the region feature exists for. Product reads alone and the
|
|
@@ -211,9 +269,27 @@ function CheckoutContent() {
|
|
|
211
269
|
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
212
270
|
setPickupLocations(locations);
|
|
213
271
|
|
|
272
|
+
// The bare /checkout link carries no id. Look for the one this tab already
|
|
273
|
+
// opened for this very cart, and use it only while it still matches the
|
|
274
|
+
// cart. A shopper choosing a subset of lines always starts a fresh one.
|
|
275
|
+
let resumed: Checkout | null = null;
|
|
276
|
+
if (!existingCheckoutId && !selectedItemsParam) {
|
|
277
|
+
const storedId = readStoredCheckoutId(cart.id);
|
|
278
|
+
if (storedId) {
|
|
279
|
+
try {
|
|
280
|
+
const prior = await client.getCheckout(storedId);
|
|
281
|
+
if (matchesCart(prior, cart)) resumed = prior;
|
|
282
|
+
else clearStoredCheckoutId(cart.id);
|
|
283
|
+
} catch {
|
|
284
|
+
clearStoredCheckoutId(cart.id);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
214
289
|
// If returning with existing checkout ID, resume it
|
|
215
|
-
if (existingCheckoutId) {
|
|
216
|
-
const existing = await client.getCheckout(existingCheckoutId);
|
|
290
|
+
if (existingCheckoutId || resumed) {
|
|
291
|
+
const existing = resumed ?? (await client.getCheckout(existingCheckoutId as string));
|
|
292
|
+
storeCheckoutId(cart.id, existing.id);
|
|
217
293
|
setCheckout(existing);
|
|
218
294
|
|
|
219
295
|
// Preload custom field definitions and any existing values so the
|
|
@@ -279,6 +355,8 @@ function CheckoutContent() {
|
|
|
279
355
|
...(regionId ? { regionId } : {}),
|
|
280
356
|
});
|
|
281
357
|
setCheckout(newCheckout);
|
|
358
|
+
// A subset checkout belongs to those lines only, never to the whole cart.
|
|
359
|
+
if (!isStrictSubset) storeCheckoutId(cart.id, newCheckout.id);
|
|
282
360
|
|
|
283
361
|
// Put the id in the URL so a RELOAD resumes instead of starting over.
|
|
284
362
|
//
|
|
@@ -1057,7 +1135,7 @@ function CheckoutContent() {
|
|
|
1057
1135
|
*/}
|
|
1058
1136
|
{bumpError && (
|
|
1059
1137
|
<p className="text-destructive text-xs" role="alert">
|
|
1060
|
-
{
|
|
1138
|
+
{tp(addToCartErrorMessageKey(bumpError))}
|
|
1061
1139
|
</p>
|
|
1062
1140
|
)}
|
|
1063
1141
|
</div>
|
|
@@ -1077,7 +1155,12 @@ function CheckoutContent() {
|
|
|
1077
1155
|
for an order still worth the same. They look adjacent
|
|
1078
1156
|
because a shopper reaches for them at the same moment, and
|
|
1079
1157
|
they behave differently because they are different things. */}
|
|
1080
|
-
{
|
|
1158
|
+
{/* Hidden while the store has gift cards off (`hasGiftCards`), so the
|
|
1159
|
+
shopper is not offered a box that can never accept a code. A card
|
|
1160
|
+
already applied keeps the field so it can still be removed. */}
|
|
1161
|
+
{checkout?.id &&
|
|
1162
|
+
(capabilities?.features.hasGiftCards === true ||
|
|
1163
|
+
(checkout.tenders?.length ?? 0) > 0) && (
|
|
1081
1164
|
<GiftCardInput
|
|
1082
1165
|
className="mt-3"
|
|
1083
1166
|
checkoutId={checkout.id}
|
|
@@ -2,6 +2,14 @@ import type { Metadata } from 'next';
|
|
|
2
2
|
|
|
3
3
|
export const metadata: Metadata = {
|
|
4
4
|
robots: { index: false, follow: false },
|
|
5
|
+
// Static, server-rendered marker for Brainerce's Google Customer Reviews
|
|
6
|
+
// readiness check (Apps → Google → Readiness tab): it fetches this route
|
|
7
|
+
// with a plain HTTP GET, no JS execution, so the signal that the survey
|
|
8
|
+
// component is wired up has to live in server-rendered HTML, not in
|
|
9
|
+
// anything the client component below renders after mount. Keep this in
|
|
10
|
+
// place whenever `<GoogleCustomerReviewsOptIn />` is used on this route —
|
|
11
|
+
// removing one without the other makes the readiness check lie.
|
|
12
|
+
other: { 'brainerce-google-customer-reviews': '1' },
|
|
5
13
|
};
|
|
6
14
|
|
|
7
15
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
@@ -13,6 +13,7 @@ import { OrderCustomizations } from '@/components/account/order-customizations';
|
|
|
13
13
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
14
14
|
import { useTranslations } from '@/core/lib/translations';
|
|
15
15
|
import { trackPurchase } from '@/core/lib/tracking';
|
|
16
|
+
import { GoogleCustomerReviewsOptIn } from '@/components/google-customer-reviews-optin';
|
|
16
17
|
|
|
17
18
|
function OrderConfirmationContent() {
|
|
18
19
|
const searchParams = useSearchParams();
|
|
@@ -203,6 +204,11 @@ function OrderConfirmationContent() {
|
|
|
203
204
|
<ConfirmationDownloads orderId={result.status.orderId} checkoutId={checkoutId!} />
|
|
204
205
|
)}
|
|
205
206
|
|
|
207
|
+
{/* Renders no visible markup. Self-gates entirely on whether the
|
|
208
|
+
merchant enabled Google Customer Reviews and this order has the
|
|
209
|
+
data the survey needs — see the component's own doc comment. */}
|
|
210
|
+
<GoogleCustomerReviewsOptIn checkoutId={checkoutId!} />
|
|
211
|
+
|
|
206
212
|
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
207
213
|
<Link
|
|
208
214
|
href="/products"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = {
|
|
4
|
+
title: <%- language === 'he' ? "'מוצרים'" : "'Products'" %>,
|
|
5
|
+
description: <%- language === 'he' ? "'כל קטלוג המוצרים שלנו.'" : "'Browse our full collection of products.'" %>,
|
|
6
|
+
alternates: {
|
|
7
|
+
canonical: '/products',
|
|
8
|
+
},
|
|
9
|
+
openGraph: {
|
|
10
|
+
title: <%- language === 'he' ? "'מוצרים'" : "'Products'" %>,
|
|
11
|
+
description: <%- language === 'he' ? "'כל קטלוג המוצרים שלנו.'" : "'Browse our full collection of products.'" %>,
|
|
12
|
+
type: 'website',
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
|
|
17
|
+
return <>{children}</>;
|
|
18
|
+
}
|
|
@@ -87,6 +87,7 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
|
|
|
87
87
|
const [redeemingId, setRedeemingId] = useState<string | null>(null);
|
|
88
88
|
const [redeemed, setRedeemed] = useState<RedeemedCoupon | null>(null);
|
|
89
89
|
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
const [linkCopied, setLinkCopied] = useState(false);
|
|
90
91
|
|
|
91
92
|
useEffect(() => {
|
|
92
93
|
let cancelled = false;
|
|
@@ -144,6 +145,18 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
|
|
|
144
145
|
const earningPaused = status.program.status !== 'ACTIVE';
|
|
145
146
|
const tierLevel = status.tier?.level ?? 0;
|
|
146
147
|
|
|
148
|
+
async function copyReferralLink(code: string) {
|
|
149
|
+
const link = `${window.location.origin}/?ref=${code}`;
|
|
150
|
+
try {
|
|
151
|
+
await navigator.clipboard.writeText(link);
|
|
152
|
+
setLinkCopied(true);
|
|
153
|
+
setTimeout(() => setLinkCopied(false), 2000);
|
|
154
|
+
} catch {
|
|
155
|
+
// Clipboard access denied (permissions, insecure context) — the code is
|
|
156
|
+
// still visible on screen for the shopper to select and copy by hand.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
147
160
|
async function redeem(reward: LoyaltyReward) {
|
|
148
161
|
setRedeemingId(reward.id);
|
|
149
162
|
setError(null);
|
|
@@ -229,6 +242,30 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
|
|
|
229
242
|
</div>
|
|
230
243
|
)}
|
|
231
244
|
|
|
245
|
+
{/* Referral share code. Null when the store has referrals disabled or
|
|
246
|
+
this customer isn't enrolled yet — absent entirely rather than an
|
|
247
|
+
empty block, same pattern as the tier/rewards sections above. */}
|
|
248
|
+
{status.referralCode && (
|
|
249
|
+
<div className="border-border mt-6 border-t pt-5">
|
|
250
|
+
<h3 className="text-foreground mb-1 text-sm font-semibold">
|
|
251
|
+
{t('referralSectionTitle')}
|
|
252
|
+
</h3>
|
|
253
|
+
<p className="text-muted-foreground text-sm">{t('referralShareHint')}</p>
|
|
254
|
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
255
|
+
<code className="bg-muted text-foreground rounded px-2 py-1 font-mono text-sm">
|
|
256
|
+
{status.referralCode}
|
|
257
|
+
</code>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={() => copyReferralLink(status.referralCode as string)}
|
|
261
|
+
className="border-border rounded border px-3 py-1.5 text-sm font-medium transition-opacity hover:opacity-80"
|
|
262
|
+
>
|
|
263
|
+
{linkCopied ? t('referralLinkCopied') : t('referralCopyLink')}
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
232
269
|
{/* Rewards. An empty catalogue is a normal state — the merchant has not
|
|
233
270
|
created any yet — so the list simply does not render. */}
|
|
234
271
|
{rewards.length > 0 && (
|