create-brainerce-store 1.87.0 → 1.88.1

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 (41) hide show
  1. package/dist/index.js +9 -2
  2. package/messages/en.json +10 -1
  3. package/messages/he.json +10 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.eslintrc.json +1 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +224 -221
  7. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -296
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
  9. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
  10. package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
  11. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
  12. package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
  13. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
  14. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -121
  15. package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
  16. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
  17. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
  18. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
  19. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
  20. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
  21. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
  22. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
  23. package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
  24. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
  25. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
  26. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
  27. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
  28. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
  29. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
  30. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
  31. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
  32. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
  33. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
  34. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
  35. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
  36. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
  37. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  38. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
  39. package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
  40. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
  41. 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}
@@ -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
+ }