create-brainerce-store 1.68.0 → 1.72.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 (50) hide show
  1. package/README.md +31 -10
  2. package/dist/index.js +197 -105
  3. package/messages/en.json +63 -3
  4. package/messages/he.json +63 -3
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/TRANSLATIONS.md +14 -7
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1017
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
  9. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
  10. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
  12. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
  13. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  14. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  15. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  16. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  17. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  18. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  19. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
  20. package/templates/nextjs/base/src/core/lib/auth.ts +155 -154
  21. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  22. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
  23. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  24. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
  25. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
  26. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
  27. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
  28. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  29. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  30. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  31. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  32. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  33. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  34. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +21 -3
  35. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
  36. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
  37. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  39. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  40. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  42. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +15 -1
  43. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
  44. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
  45. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  46. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  47. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  48. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  49. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  50. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
@@ -32,10 +32,29 @@ function OrderConfirmationContent() {
32
32
  try {
33
33
  const client = getClient();
34
34
 
35
- // Clear cart state after successful payment
36
- client.handlePaymentSuccess(checkoutId!);
35
+ // Clear cart state after successful payment. handlePaymentSuccess is
36
+ // SYNCHRONOUS: it returns the outcome rather than a promise, so there
37
+ // is nothing to await. Read the result, then refresh the cart AFTER
38
+ // it, or the header badge can be repopulated from the pre-clear cart.
39
+ //
40
+ // mode 'full' - whole cart cleared
41
+ // mode 'partial' - only the purchased items were removed (partial
42
+ // checkout); whatever is left is still for sale
43
+ // mode 'none' - nothing to clear. Usually the SDK's idempotency
44
+ // guard: React Strict Mode runs this effect twice
45
+ // in development, and a refresh of this URL hits it
46
+ // too. On a good order this is expected, so it must
47
+ // never become a warning to the shopper.
48
+ const clearResult = client.handlePaymentSuccess(checkoutId!);
37
49
  await refreshCart();
38
50
 
51
+ if (!clearResult.cleared && clearResult.mode !== 'none') {
52
+ // A clear that neither succeeded nor was skipped leaves purchased
53
+ // items in the badge. Nothing the shopper can act on, but a silent
54
+ // gap here looks exactly like a working cart.
55
+ console.warn('Cart not cleared after payment:', clearResult);
56
+ }
57
+
39
58
  // For redirect-based payment providers, the customer returns with
40
59
  // provider params in the URL — CardCom: lowprofilecode; PayPal:
41
60
  // token + PayerID. Send these to the backend for server-side
@@ -192,7 +192,7 @@ export default async function ProductDetailPage({ params }: Props) {
192
192
  <div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6 lg:px-8">
193
193
  <Breadcrumbs items={breadcrumbItems} />
194
194
  </div>
195
- <ProductClientSection product={product} />
195
+ <ProductClientSection product={product} stockAlertsEnabled={storeInfo?.stockAlertsEnabled} />
196
196
  {/* Visible Q&A + the FAQPage JSON-LD above read the same product.faq —
197
197
  the extractable format AI answer engines cite. */}
198
198
  <ProductFaqSection product={product} locale={locale} />
@@ -1,64 +1,67 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { useRouter, Link } from '@/core/lib/navigation';
5
- import { useAuth } from '@/core/providers/store-provider';
6
- import { proxyRegister } from '@/core/lib/auth';
7
- import { RegisterForm } from '@/components/auth/register-form';
8
- import { OAuthButtons } from '@/components/auth/oauth-buttons';
9
- import { useTranslations } from '@/core/lib/translations';
10
-
11
- export default function RegisterPage() {
12
- const router = useRouter();
13
- const auth = useAuth();
14
- const t = useTranslations('auth');
15
- const [error, setError] = useState<string | null>(null);
16
-
17
- async function handleRegister(data: {
18
- firstName: string;
19
- lastName: string;
20
- email: string;
21
- password: string;
22
- acceptsMarketing: boolean;
23
- }) {
24
- try {
25
- setError(null);
26
- const result = await proxyRegister(data);
27
-
28
- if (result.requiresVerification) {
29
- // Cookie already set by proxy; verify-email uses it for auth
30
- router.push('/verify-email');
31
- return;
32
- }
33
-
34
- // Cookie was set by the proxy; refresh auth state
35
- await auth.login();
36
- router.push('/');
37
- } catch (err) {
38
- const message = err instanceof Error ? err.message : 'Registration failed. Please try again.';
39
- setError(message);
40
- }
41
- }
42
-
43
- return (
44
- <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
45
- <div className="w-full max-w-md space-y-6">
46
- <div className="text-center">
47
- <h1 className="text-foreground text-2xl font-bold">{t('createAccountTitle')}</h1>
48
- <p className="text-muted-foreground mt-1 text-sm">{t('joinSubtitle')}</p>
49
- </div>
50
-
51
- <RegisterForm onSubmit={handleRegister} error={error} />
52
-
53
- <OAuthButtons />
54
-
55
- <p className="text-muted-foreground text-center text-sm">
56
- {t('alreadyHaveAccount')}{' '}
57
- <Link href="/login" className="text-primary font-medium hover:underline">
58
- {t('signIn')}
59
- </Link>
60
- </p>
61
- </div>
62
- </div>
63
- );
64
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useRouter, Link } from '@/core/lib/navigation';
5
+ import { useAuth } from '@/core/providers/store-provider';
6
+ import { proxyRegister } from '@/core/lib/auth';
7
+ import { RegisterForm } from '@/components/auth/register-form';
8
+ import { OAuthButtons } from '@/components/auth/oauth-buttons';
9
+ import { useTranslations } from '@/core/lib/translations';
10
+
11
+ export default function RegisterPage() {
12
+ const router = useRouter();
13
+ const auth = useAuth();
14
+ const t = useTranslations('auth');
15
+ const [error, setError] = useState<string | null>(null);
16
+
17
+ async function handleRegister(data: {
18
+ firstName: string;
19
+ lastName: string;
20
+ email: string;
21
+ password: string;
22
+ acceptsMarketing: boolean;
23
+ /** Month and day only, never a year. Present together or not at all. */
24
+ birthMonth?: number;
25
+ birthDay?: number;
26
+ }) {
27
+ try {
28
+ setError(null);
29
+ const result = await proxyRegister(data);
30
+
31
+ if (result.requiresVerification) {
32
+ // Cookie already set by proxy; verify-email uses it for auth
33
+ router.push('/verify-email');
34
+ return;
35
+ }
36
+
37
+ // Cookie was set by the proxy; refresh auth state
38
+ await auth.login();
39
+ router.push('/');
40
+ } catch (err) {
41
+ const message = err instanceof Error ? err.message : 'Registration failed. Please try again.';
42
+ setError(message);
43
+ }
44
+ }
45
+
46
+ return (
47
+ <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
48
+ <div className="w-full max-w-md space-y-6">
49
+ <div className="text-center">
50
+ <h1 className="text-foreground text-2xl font-bold">{t('createAccountTitle')}</h1>
51
+ <p className="text-muted-foreground mt-1 text-sm">{t('joinSubtitle')}</p>
52
+ </div>
53
+
54
+ <RegisterForm onSubmit={handleRegister} error={error} />
55
+
56
+ <OAuthButtons />
57
+
58
+ <p className="text-muted-foreground text-center text-sm">
59
+ {t('alreadyHaveAccount')}{' '}
60
+ <Link href="/login" className="text-primary font-medium hover:underline">
61
+ {t('signIn')}
62
+ </Link>
63
+ </p>
64
+ </div>
65
+ </div>
66
+ );
67
+ }
@@ -9,35 +9,29 @@ import { useCurrency } from '@/core/lib/use-currency';
9
9
  import { useTranslations } from '@/core/lib/translations';
10
10
  import { cn } from '@/core/lib/utils';
11
11
  import { OrderCustomizations } from './order-customizations';
12
- import { OrderStatusTimeline } from './order-status-timeline';
12
+ import { OrderStatusTimeline, ORDER_STATUS_LABEL_KEYS } from './order-status-timeline';
13
13
  import { OrderShippingBlock } from './order-shipping-block';
14
14
  import { OrderPaymentBlock } from './order-payment-block';
15
15
 
16
- const STATUS_CONFIG: Record<OrderStatus, { labelKey: string; className: string }> = {
17
- pending: {
18
- labelKey: 'statusPending',
19
- className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-400',
20
- },
21
- processing: {
22
- labelKey: 'statusProcessing',
23
- className: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-400',
24
- },
25
- shipped: {
26
- labelKey: 'statusShipped',
27
- className: 'bg-purple-100 text-purple-800 dark:bg-purple-950/30 dark:text-purple-400',
28
- },
29
- delivered: {
30
- labelKey: 'statusDelivered',
31
- className: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
32
- },
33
- cancelled: {
34
- labelKey: 'statusCancelled',
35
- className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400',
36
- },
37
- refunded: {
38
- labelKey: 'statusRefunded',
39
- className: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
40
- },
16
+ /**
17
+ * Badge colour per order status. The API sends the status UPPERCASE and
18
+ * verbatim, so these keys are uppercase too. Do NOT lowercase `order.status`
19
+ * before the lookup: every row would miss and render as "Pending".
20
+ * Labels come from ORDER_STATUS_LABEL_KEYS so the two stay in step.
21
+ */
22
+ const STATUS_STYLES: Record<OrderStatus, string> = {
23
+ DRAFT: 'bg-muted text-muted-foreground',
24
+ PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-400',
25
+ PROCESSING: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-400',
26
+ ON_HOLD: 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-400',
27
+ PAID: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-400',
28
+ SHIPPED: 'bg-purple-100 text-purple-800 dark:bg-purple-950/30 dark:text-purple-400',
29
+ DELIVERED: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
30
+ COMPLETED: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
31
+ FULFILLED: 'bg-teal-100 text-teal-800 dark:bg-teal-950/30 dark:text-teal-400',
32
+ CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400',
33
+ REFUNDED: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
34
+ PARTIALLY_REFUNDED: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
41
35
  };
42
36
 
43
37
  interface OrderHistoryProps {
@@ -82,8 +76,8 @@ function OrderCard({ order }: { order: Order }) {
82
76
  const t = useTranslations('account');
83
77
  const tc = useTranslations('common');
84
78
  const [expanded, setExpanded] = useState(false);
85
- const statusConfig =
86
- STATUS_CONFIG[order.status?.toLowerCase() as OrderStatus] || STATUS_CONFIG.pending;
79
+ const statusLabelKey = ORDER_STATUS_LABEL_KEYS[order.status] || 'statusPending';
80
+ const statusClassName = STATUS_STYLES[order.status] || STATUS_STYLES.PENDING;
87
81
  const currency = useCurrency(order.currency);
88
82
  const totalAmount = order.totalAmount || order.total || '0';
89
83
 
@@ -103,18 +97,10 @@ function OrderCard({ order }: { order: Order }) {
103
97
  <span
104
98
  className={cn(
105
99
  'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
106
- statusConfig.className
100
+ statusClassName
107
101
  )}
108
102
  >
109
- {t(
110
- statusConfig.labelKey as
111
- | 'statusPending'
112
- | 'statusProcessing'
113
- | 'statusShipped'
114
- | 'statusDelivered'
115
- | 'statusCancelled'
116
- | 'statusRefunded'
117
- )}
103
+ {t(statusLabelKey)}
118
104
  </span>
119
105
  </div>
120
106
  <div className="text-muted-foreground mt-1 flex items-center gap-4 text-xs">
@@ -125,7 +111,7 @@ function OrderCard({ order }: { order: Order }) {
125
111
  month: 'short',
126
112
  day: 'numeric',
127
113
  })
128
- : ''}
114
+ : ''}
129
115
  </span>
130
116
  <span>
131
117
  {order.items.length} {order.items.length === 1 ? tc('item') : tc('items')}
@@ -4,21 +4,40 @@ import type { Order, OrderStatus } from 'brainerce';
4
4
  import { useTranslations } from '@/core/lib/translations';
5
5
  import { cn } from '@/core/lib/utils';
6
6
 
7
- const STATUS_KEYS: Record<
8
- OrderStatus,
7
+ /**
8
+ * Message key for every order status the API can return.
9
+ *
10
+ * The API sends these values UPPERCASE and verbatim (`PENDING`, `ON_HOLD`,
11
+ * ...). Never lowercase them before the lookup: a lowercase key silently
12
+ * misses and every row falls through to the default label.
13
+ */
14
+ export type OrderStatusLabelKey =
15
+ | 'statusDraft'
9
16
  | 'statusPending'
10
17
  | 'statusProcessing'
18
+ | 'statusOnHold'
19
+ | 'statusPaid'
11
20
  | 'statusShipped'
12
21
  | 'statusDelivered'
22
+ | 'statusCompleted'
23
+ | 'statusFulfilled'
13
24
  | 'statusCancelled'
14
25
  | 'statusRefunded'
15
- > = {
16
- pending: 'statusPending',
17
- processing: 'statusProcessing',
18
- shipped: 'statusShipped',
19
- delivered: 'statusDelivered',
20
- cancelled: 'statusCancelled',
21
- refunded: 'statusRefunded',
26
+ | 'statusPartiallyRefunded';
27
+
28
+ export const ORDER_STATUS_LABEL_KEYS: Record<OrderStatus, OrderStatusLabelKey> = {
29
+ DRAFT: 'statusDraft',
30
+ PENDING: 'statusPending',
31
+ PROCESSING: 'statusProcessing',
32
+ ON_HOLD: 'statusOnHold',
33
+ PAID: 'statusPaid',
34
+ SHIPPED: 'statusShipped',
35
+ DELIVERED: 'statusDelivered',
36
+ COMPLETED: 'statusCompleted',
37
+ FULFILLED: 'statusFulfilled',
38
+ CANCELLED: 'statusCancelled',
39
+ REFUNDED: 'statusRefunded',
40
+ PARTIALLY_REFUNDED: 'statusPartiallyRefunded',
22
41
  };
23
42
 
24
43
  interface OrderStatusTimelineProps {
@@ -35,7 +54,7 @@ export function OrderStatusTimeline({ history, className }: OrderStatusTimelineP
35
54
  <p className="text-foreground mb-2 text-sm font-medium">{t('statusTimeline')}</p>
36
55
  <ol className="space-y-1.5">
37
56
  {history.map((entry, idx) => {
38
- const key = STATUS_KEYS[entry.status] || 'statusPending';
57
+ const key = ORDER_STATUS_LABEL_KEYS[entry.status] || 'statusPending';
39
58
  const when = new Date(entry.at);
40
59
  const whenStr = isNaN(when.getTime())
41
60
  ? entry.at
@@ -56,7 +75,7 @@ export function OrderStatusTimeline({ history, className }: OrderStatusTimelineP
56
75
  />
57
76
  <span className="text-foreground font-medium">{t(key)}</span>
58
77
  <span className="text-muted-foreground">· {whenStr}</span>
59
- {entry.note && <span className="text-muted-foreground truncate">— {entry.note}</span>}
78
+ {entry.note && <span className="text-muted-foreground truncate">({entry.note})</span>}
60
79
  </li>
61
80
  );
62
81
  })}