create-brainerce-store 1.74.0 → 1.76.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.
@@ -1,67 +1,74 @@
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
- }
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
+ import { clearReferralCookie } from '@/core/lib/referral';
11
+
12
+ export default function RegisterPage() {
13
+ const router = useRouter();
14
+ const auth = useAuth();
15
+ const t = useTranslations('auth');
16
+ const [error, setError] = useState<string | null>(null);
17
+
18
+ async function handleRegister(data: {
19
+ firstName: string;
20
+ lastName: string;
21
+ email: string;
22
+ password: string;
23
+ acceptsMarketing: boolean;
24
+ /** Month and day only, never a year. Present together or not at all. */
25
+ birthMonth?: number;
26
+ birthDay?: number;
27
+ /** Referral share code captured from a `?ref=` link, when there was one. */
28
+ referralCode?: string;
29
+ }) {
30
+ try {
31
+ setError(null);
32
+ const result = await proxyRegister(data);
33
+
34
+ // The code has been spent. Drop it so the next account created in this
35
+ // browser does not silently credit the same referrer again.
36
+ if (data.referralCode) clearReferralCookie();
37
+
38
+ if (result.requiresVerification) {
39
+ // Cookie already set by proxy; verify-email uses it for auth
40
+ router.push('/verify-email');
41
+ return;
42
+ }
43
+
44
+ // Cookie was set by the proxy; refresh auth state
45
+ await auth.login();
46
+ router.push('/');
47
+ } catch (err) {
48
+ const message = err instanceof Error ? err.message : 'Registration failed. Please try again.';
49
+ setError(message);
50
+ }
51
+ }
52
+
53
+ return (
54
+ <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
55
+ <div className="w-full max-w-md space-y-6">
56
+ <div className="text-center">
57
+ <h1 className="text-foreground text-2xl font-bold">{t('createAccountTitle')}</h1>
58
+ <p className="text-muted-foreground mt-1 text-sm">{t('joinSubtitle')}</p>
59
+ </div>
60
+
61
+ <RegisterForm onSubmit={handleRegister} error={error} />
62
+
63
+ <OAuthButtons />
64
+
65
+ <p className="text-muted-foreground text-center text-sm">
66
+ {t('alreadyHaveAccount')}{' '}
67
+ <Link href="/login" className="text-primary font-medium hover:underline">
68
+ {t('signIn')}
69
+ </Link>
70
+ </p>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
@@ -3,6 +3,25 @@
3
3
  /**
4
4
  * Loyalty — points balance, tier progress, and the reward catalogue.
5
5
  *
6
+ * ⛔ THE HOSTED WIDGET COMES FIRST. `getLoyaltyWidgetSession()` returns an
7
+ * `embedUrl` that drops straight into an `<iframe src>`, and when it resolves
8
+ * this panel renders that instead of the hand-built UI below. The hosted widget
9
+ * is maintained by the platform: new loyalty features (badges, membership,
10
+ * referrals) appear in it without anyone touching this file, and it cannot
11
+ * drift out of step with the API. Reach for the hand-built panel only when you
12
+ * need a look the widget cannot give you.
13
+ *
14
+ * The `embedUrl` carries a scoped ~15-minute session token, never the real
15
+ * customerToken. It is minted on mount, so any navigation that remounts this
16
+ * panel refreshes it; a page left open past the expiry shows the widget's own
17
+ * expired state, and a reload fixes it.
18
+ *
19
+ * ⛔ THE HAND-BUILT PANEL BELOW IS THE FALLBACK, NOT DEAD CODE. The session call
20
+ * fails on an older backend, on a network blip, and wherever the widget is not
21
+ * provisioned. Everything from here down is what the shopper sees then, so it
22
+ * has to keep working. Do not delete it because the widget renders on your
23
+ * machine.
24
+ *
6
25
  * ⛔ REDEEMING DOES NOT DISCOUNT ANYTHING BY ITSELF. `redeemLoyaltyReward()`
7
26
  * spends the points and mints a ONE-TIME COUPON CODE, which the shopper still
8
27
  * has to apply at checkout. If the panel says "redeemed!" and stops there, the
@@ -16,11 +35,17 @@
16
35
  * than an absent one. Build the panel anyway: the merchant turns the program on
17
36
  * from the dashboard without touching this code.
18
37
  *
19
- * ⛔ `pointsBalance` EXCLUDES PENDING POINTS. Points from a recent order sit
20
- * pending for a merchant-set number of days before they can be spent, so a
21
- * shopper who just bought something sees a balance that does not include it.
22
- * That is correct, and it is why the copy says "ready to spend" rather than
23
- * "earned".
38
+ * ⛔ `pointsBalance` EXCLUDES PENDING POINTS SO YOU MUST RENDER
39
+ * `pendingPoints` TOO. Points from a recent order sit pending for a
40
+ * merchant-set number of days (`program.pendingDays`) before they can be
41
+ * spent, so a shopper who just bought something sees a balance that does not
42
+ * include them. Showing only `pointsBalance` therefore answers "I just
43
+ * ordered, where are my points?" with a bare 0, which reads as a broken
44
+ * programme rather than a waiting period — the single most common support
45
+ * question this panel gets. Show the pending line whenever `pendingPoints > 0`,
46
+ * dated with `pendingPointsConfirmAt` (the night they actually confirm, not the
47
+ * window's end). The balance copy still says "ready to spend" rather than
48
+ * "earned", because that is precisely what distinguishes the two numbers.
24
49
  *
25
50
  * ⛔ A REWARD CAN BE VISIBLE AND UNREDEEMABLE. Two independent gates:
26
51
  * `pointsCost` above the balance, and `minTierLevel` above the shopper's tier.
@@ -57,6 +82,7 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
57
82
  const t = useTranslations('loyalty');
58
83
  const [status, setStatus] = useState<LoyaltyStatus | null>(null);
59
84
  const [rewards, setRewards] = useState<LoyaltyReward[]>([]);
85
+ const [widgetUrl, setWidgetUrl] = useState<string | null>(null);
60
86
  const [loading, setLoading] = useState(true);
61
87
  const [redeemingId, setRedeemingId] = useState<string | null>(null);
62
88
  const [redeemed, setRedeemed] = useState<RedeemedCoupon | null>(null);
@@ -68,14 +94,19 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
68
94
  async function load() {
69
95
  const client = getClient();
70
96
  // Settled, not all: a store with rewards configured and none active
71
- // still has a balance worth showing, and vice versa.
72
- const [statusResult, rewardsResult] = await Promise.allSettled([
97
+ // still has a balance worth showing, and vice versa. The widget session
98
+ // rides along in the same batch rather than a second round trip, and its
99
+ // failure is the ordinary case on a backend that does not serve it,
100
+ // which is exactly why `allSettled` and not `all`.
101
+ const [statusResult, rewardsResult, widgetResult] = await Promise.allSettled([
73
102
  client.getLoyaltyStatus(),
74
103
  client.getAvailableRewards(),
104
+ client.getLoyaltyWidgetSession(),
75
105
  ]);
76
106
  if (cancelled) return;
77
107
  if (statusResult.status === 'fulfilled') setStatus(statusResult.value);
78
108
  if (rewardsResult.status === 'fulfilled') setRewards(rewardsResult.value);
109
+ if (widgetResult.status === 'fulfilled') setWidgetUrl(widgetResult.value.embedUrl);
79
110
  setLoading(false);
80
111
  }
81
112
 
@@ -87,8 +118,28 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
87
118
 
88
119
  // No program on this store, or the status call failed: render nothing at all.
89
120
  // An empty card is worse than no card.
121
+ //
122
+ // ⛔ This guard stays FIRST, ahead of the widget branch below. A store with no
123
+ // loyalty program must render nothing, not an iframe that loads a panel about
124
+ // a program that does not exist.
90
125
  if (loading || !status?.program) return null;
91
126
 
127
+ // The hosted widget, when the platform minted a session for it. Everything
128
+ // below this branch is the fallback.
129
+ if (widgetUrl) {
130
+ return (
131
+ <section className={cn('border-border rounded-lg border p-6', className)}>
132
+ <h2 className="text-foreground text-lg font-semibold">{t('title')}</h2>
133
+ <iframe
134
+ src={widgetUrl}
135
+ title={t('widgetTitle')}
136
+ loading="lazy"
137
+ className="mt-4 h-[420px] w-full rounded border-0"
138
+ />
139
+ </section>
140
+ );
141
+ }
142
+
92
143
  const pointsName = status.program.pointsName || t('pointsFallback');
93
144
  const earningPaused = status.program.status !== 'ACTIVE';
94
145
  const tierLevel = status.tier?.level ?? 0;
@@ -123,6 +174,27 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
123
174
  {earningPaused ? t('earningPaused') : t('readyToSpend')}
124
175
  </p>
125
176
 
177
+ {/* Points from a recent order live here, not in the balance above. Without
178
+ this line a shopper who just bought something sees a 0 and no reason
179
+ for it. Absent entirely at zero — an empty "0 pending" is noise. */}
180
+ {status.pendingPoints > 0 && (
181
+ <p className="text-muted-foreground mt-2 text-sm">
182
+ {status.pendingPointsConfirmAt
183
+ ? t('pendingWithDate', {
184
+ points: status.pendingPoints.toLocaleString(),
185
+ pointsName,
186
+ date: new Date(status.pendingPointsConfirmAt).toLocaleDateString(undefined, {
187
+ day: 'numeric',
188
+ month: 'long',
189
+ }),
190
+ })
191
+ : t('pending', {
192
+ points: status.pendingPoints.toLocaleString(),
193
+ pointsName,
194
+ })}
195
+ </p>
196
+ )}
197
+
126
198
  {/* Tier progress. Rendered only when the store configured tiers — the
127
199
  whole block is absent otherwise, rather than a full bar with no name. */}
128
200
  {status.tier && (
@@ -417,6 +417,40 @@ function OrderFinancialSummary({ order, currency }: { order: Order; currency: st
417
417
  {formatPrice(parseFloat(totalAmount), { currency }) as string}
418
418
  </span>
419
419
  </div>
420
+
421
+ {/* Order history is the record a customer checks a card statement
422
+ against, months later. A total with no gift-card line is the one that
423
+ makes them think they were charged twice. */}
424
+ {order.tenders && order.tenders.length > 0 && (
425
+ <>
426
+ {order.tenders.map((tender) => (
427
+ <div key={tender.id} className="flex items-center justify-between">
428
+ <span className="text-muted-foreground">
429
+ {tender.giftCard?.codeLast4
430
+ ? `${tc('giftCard')} ····${tender.giftCard.codeLast4}`
431
+ : tc('giftCard')}
432
+ </span>
433
+ <span className="text-primary">
434
+ -{formatPrice(parseFloat(tender.amountBase), { currency }) as string}
435
+ </span>
436
+ </div>
437
+ ))}
438
+ <div className="border-border flex items-center justify-between border-t pt-1">
439
+ <span className="text-foreground font-medium">{tc('amountCharged')}</span>
440
+ <span className="text-foreground font-semibold">
441
+ {
442
+ formatPrice(
443
+ order.tenders.reduce(
444
+ (due, tn) => due - parseFloat(tn.amountBase),
445
+ parseFloat(totalAmount)
446
+ ),
447
+ { currency }
448
+ ) as string
449
+ }
450
+ </span>
451
+ </div>
452
+ </>
453
+ )}
420
454
  </div>
421
455
  );
422
456
  }