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.
Files changed (44) hide show
  1. package/dist/index.js +9 -2
  2. package/messages/en.json +15 -2
  3. package/messages/he.json +15 -2
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -255
  6. package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
  7. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +8 -0
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +6 -0
  9. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
  10. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +37 -0
  11. package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
  12. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
  13. package/templates/nextjs/base/src/components/google-customer-reviews-optin.tsx +126 -0
  14. package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
  15. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
  16. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -81
  17. package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
  18. package/templates/nextjs/base/src/middleware.ts.ejs +22 -0
  19. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
  20. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
  21. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
  22. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
  23. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
  24. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
  25. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
  26. package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
  27. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
  28. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
  29. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
  30. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
  31. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
  32. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
  33. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
  34. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
  35. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
  36. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
  37. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
  38. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
  39. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
  40. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  41. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
  42. package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
  43. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
  44. 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 { useStoreInfo, useCart, useAuth, useRegion } from '@/core/providers/store-provider';
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 { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
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
- {bumpError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
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
- {checkout?.id && (
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 && (