create-brainerce-store 1.75.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.
- package/dist/index.js +42 -8
- package/messages/en.json +8 -0
- package/messages/he.json +8 -0
- package/package.json +3 -2
- package/templates/nextjs/base/.env.local.ejs +62 -52
- package/templates/nextjs/base/AGENTS.md.ejs +189 -161
- package/templates/nextjs/base/CLAUDE.md.ejs +200 -172
- package/templates/nextjs/base/src/app/page.tsx +75 -65
- package/templates/nextjs/base/src/app/register/page.tsx +74 -67
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +79 -7
- package/templates/nextjs/base/src/components/auth/register-form.tsx +358 -326
- package/templates/nextjs/base/src/components/referral/referral-greeting.tsx +120 -0
- package/templates/nextjs/base/src/core/lib/auth.ts +162 -155
- package/templates/nextjs/base/src/core/lib/referral.ts +95 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +5 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +5 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +5 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +382 -375
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* "
|
|
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
|
-
|
|
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 && (
|