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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Referral landing greeting: the `?ref=` half of the loyalty program.
|
|
5
|
+
*
|
|
6
|
+
* A referral link is `https://your-store.com/?ref=<code>`, so this mounts on
|
|
7
|
+
* the home page (see `src/app/page.tsx`), which is where those links land.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ `getReferralInfo()` IS THE ONE LOYALTY CALL THAT NEEDS NO CUSTOMER TOKEN.
|
|
10
|
+
* That is the entire reason this component can exist: it greets a visitor who
|
|
11
|
+
* has no account yet, which is precisely the audience a referral link is aimed
|
|
12
|
+
* at. Every other loyalty call requires a logged-in customer and would throw
|
|
13
|
+
* here.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ IT AUTO-HIDES FOUR WAYS, AND RENDERS NOTHING IN ALL OF THEM: no `?ref=` in
|
|
16
|
+
* the URL, a malformed code, the merchant has referrals switched off
|
|
17
|
+
* (`hasReferralProgram`), or the API says the code is not valid. A store with
|
|
18
|
+
* no loyalty program never sees a trace of this. Build it anyway: the merchant
|
|
19
|
+
* turns referrals on from the dashboard without touching this code.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ CAPTURING MATTERS MORE THAN GREETING. The banner is the visible half, but
|
|
22
|
+
* the useful half is `writeReferralCookie()`: almost nobody registers on the
|
|
23
|
+
* page they land on, so the code is persisted the moment it arrives and read
|
|
24
|
+
* back by the register form whenever the shopper gets round to signing up. Drop
|
|
25
|
+
* the banner in a redesign if you like; do not drop the capture, or referrers
|
|
26
|
+
* stop being credited and the failure is completely invisible.
|
|
27
|
+
*
|
|
28
|
+
* ⛔ Must be rendered inside a `<Suspense>` boundary. It uses
|
|
29
|
+
* `useSearchParams()`, which opts the whole route out of static rendering
|
|
30
|
+
* otherwise.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { useEffect, useState } from 'react';
|
|
34
|
+
import { useSearchParams } from 'next/navigation';
|
|
35
|
+
import type { ReferralInfo } from 'brainerce';
|
|
36
|
+
import { Link } from '@/core/lib/navigation';
|
|
37
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
38
|
+
import { useStoreCapabilities } from '@/core/providers/store-provider';
|
|
39
|
+
import {
|
|
40
|
+
isGreetableReferral,
|
|
41
|
+
normalizeReferralCode,
|
|
42
|
+
writeReferralCookie,
|
|
43
|
+
} from '@/core/lib/referral';
|
|
44
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
45
|
+
import { cn } from '@/core/lib/utils';
|
|
46
|
+
|
|
47
|
+
interface ReferralGreetingProps {
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ReferralGreeting({ className }: ReferralGreetingProps) {
|
|
52
|
+
const t = useTranslations('loyalty');
|
|
53
|
+
const searchParams = useSearchParams();
|
|
54
|
+
const { capabilities } = useStoreCapabilities();
|
|
55
|
+
const [info, setInfo] = useState<ReferralInfo | null>(null);
|
|
56
|
+
|
|
57
|
+
const code = normalizeReferralCode(searchParams.get('ref'));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Capabilities are fetched once at boot and are null until they land, or
|
|
61
|
+
* forever if that fetch failed. Only an explicit `false` suppresses the
|
|
62
|
+
* lookup, mirroring `canOfferStockAlert()`: an undecided flag must not
|
|
63
|
+
* swallow a real referral, and `getReferralInfo()` independently answers
|
|
64
|
+
* `{ valid: false }` when the program is off, so the feature stays correct
|
|
65
|
+
* either way.
|
|
66
|
+
*/
|
|
67
|
+
const referralsOff = capabilities?.features.hasReferralProgram === false;
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!code || referralsOff) return;
|
|
71
|
+
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
getClient()
|
|
74
|
+
.getReferralInfo(code)
|
|
75
|
+
.then((result) => {
|
|
76
|
+
if (cancelled || !result.valid) return;
|
|
77
|
+
// Persist BEFORE rendering anything. The visitor may navigate away in
|
|
78
|
+
// the same second, and the code is the part that has to survive.
|
|
79
|
+
writeReferralCookie(code);
|
|
80
|
+
setInfo(result);
|
|
81
|
+
})
|
|
82
|
+
.catch(() => {
|
|
83
|
+
// Swallowed on purpose. An unreachable API on a landing page must not
|
|
84
|
+
// break the home page, and there is nothing useful to say to a shopper
|
|
85
|
+
// who does not yet know a referral was involved.
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
cancelled = true;
|
|
90
|
+
};
|
|
91
|
+
}, [code, referralsOff]);
|
|
92
|
+
|
|
93
|
+
if (!isGreetableReferral(info)) return null;
|
|
94
|
+
|
|
95
|
+
const greeting = info.referrerFirstName
|
|
96
|
+
? t('referralGreeting', { name: info.referrerFirstName })
|
|
97
|
+
: t('referralGreetingAnonymous');
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<aside
|
|
101
|
+
className={cn(
|
|
102
|
+
'border-primary/20 bg-primary/5 mx-auto max-w-3xl rounded-lg border px-4 py-3',
|
|
103
|
+
className
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
<p className="text-foreground font-medium">{greeting}</p>
|
|
107
|
+
{info.reward && (
|
|
108
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
109
|
+
{t('referralReward', { reward: info.reward.name })}
|
|
110
|
+
</p>
|
|
111
|
+
)}
|
|
112
|
+
<Link
|
|
113
|
+
href="/register"
|
|
114
|
+
className="text-primary mt-2 inline-flex text-sm font-medium underline underline-offset-2"
|
|
115
|
+
>
|
|
116
|
+
{t('referralCta')}
|
|
117
|
+
</Link>
|
|
118
|
+
</aside>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -1,155 +1,162 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side auth helpers.
|
|
3
|
-
*
|
|
4
|
-
* Everything the SDK covers goes through an SDK method. Never hand-build a
|
|
5
|
-
* REST path: the SDK owns the URL shape, and a string built here silently
|
|
6
|
-
* rots the day a route moves. `getClient()` is configured with
|
|
7
|
-
* `baseUrl: '/api/store'` + `proxyMode`, so each call still lands on the
|
|
8
|
-
* same-origin BFF proxy, which adds the Authorization header from the
|
|
9
|
-
* httpOnly cookie and strips the token out of the response. The SDK adds the
|
|
10
|
-
* CSRF header the proxy requires on every non-GET request.
|
|
11
|
-
*
|
|
12
|
-
* Only the routes with no SDK equivalent are plain fetches: `/api/auth/me`,
|
|
13
|
-
* `/api/auth/logout` and `/api/auth/reset-password` are this app's own Next
|
|
14
|
-
* route handlers, not Brainerce endpoints.
|
|
15
|
-
*
|
|
16
|
-
* The token is managed server-side via httpOnly cookies, never exposed to JS.
|
|
17
|
-
*/
|
|
18
|
-
import { getClient } from '@/core/lib/brainerce';
|
|
19
|
-
|
|
20
|
-
const CSRF_HEADERS: Record<string, string> = {
|
|
21
|
-
'Content-Type': 'application/json',
|
|
22
|
-
'X-Requested-With': 'brainerce',
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
interface LoginResult {
|
|
26
|
-
customer: {
|
|
27
|
-
id: string;
|
|
28
|
-
email: string;
|
|
29
|
-
firstName?: string;
|
|
30
|
-
lastName?: string;
|
|
31
|
-
emailVerified: boolean;
|
|
32
|
-
};
|
|
33
|
-
expiresAt: string;
|
|
34
|
-
requiresVerification?: boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface RegisterResult {
|
|
38
|
-
customer: {
|
|
39
|
-
id: string;
|
|
40
|
-
email: string;
|
|
41
|
-
firstName?: string;
|
|
42
|
-
lastName?: string;
|
|
43
|
-
emailVerified: boolean;
|
|
44
|
-
};
|
|
45
|
-
expiresAt: string;
|
|
46
|
-
requiresVerification?: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface AuthStatus {
|
|
50
|
-
isLoggedIn: boolean;
|
|
51
|
-
customer?: {
|
|
52
|
-
id: string;
|
|
53
|
-
email: string;
|
|
54
|
-
firstName?: string;
|
|
55
|
-
lastName?: string;
|
|
56
|
-
phone?: string;
|
|
57
|
-
emailVerified: boolean;
|
|
58
|
-
};
|
|
59
|
-
error?: string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface VerifyEmailResult {
|
|
63
|
-
verified: boolean;
|
|
64
|
-
message?: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function handleResponse<T>(response: Response): Promise<T> {
|
|
68
|
-
const data = await response.json();
|
|
69
|
-
if (!response.ok) {
|
|
70
|
-
throw new Error(data.message || data.error || `Request failed (${response.status})`);
|
|
71
|
-
}
|
|
72
|
-
return data as T;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Login via the SDK, routed through the BFF proxy, which sets the httpOnly
|
|
77
|
-
* cookie on success and strips the token from the response. The narrow
|
|
78
|
-
* `LoginResult` return type is deliberate: the SDK's own response type
|
|
79
|
-
* declares a `token`, but the proxy removes it before it reaches the browser,
|
|
80
|
-
* so nothing here should read one.
|
|
81
|
-
*/
|
|
82
|
-
export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
|
|
83
|
-
return getClient().loginCustomer(email, password);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Register via the SDK, routed through the BFF proxy, which sets the httpOnly
|
|
88
|
-
* cookie on success.
|
|
89
|
-
*/
|
|
90
|
-
export async function proxyRegister(data: {
|
|
91
|
-
firstName: string;
|
|
92
|
-
lastName: string;
|
|
93
|
-
email: string;
|
|
94
|
-
password: string;
|
|
95
|
-
acceptsMarketing?: boolean;
|
|
96
|
-
/**
|
|
97
|
-
* Birthday month (1-12) and day (1-31), never a year. Powers the loyalty
|
|
98
|
-
* birthday gift. Send both or neither: one without the other is rejected
|
|
99
|
-
* with HTTP 400, and so is a day the month does not have. Required only when
|
|
100
|
-
* `getStoreInfo().requireBirthday` is true for this sales channel.
|
|
101
|
-
*/
|
|
102
|
-
birthMonth?: number;
|
|
103
|
-
birthDay?: number;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
*
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Client-side auth helpers.
|
|
3
|
+
*
|
|
4
|
+
* Everything the SDK covers goes through an SDK method. Never hand-build a
|
|
5
|
+
* REST path: the SDK owns the URL shape, and a string built here silently
|
|
6
|
+
* rots the day a route moves. `getClient()` is configured with
|
|
7
|
+
* `baseUrl: '/api/store'` + `proxyMode`, so each call still lands on the
|
|
8
|
+
* same-origin BFF proxy, which adds the Authorization header from the
|
|
9
|
+
* httpOnly cookie and strips the token out of the response. The SDK adds the
|
|
10
|
+
* CSRF header the proxy requires on every non-GET request.
|
|
11
|
+
*
|
|
12
|
+
* Only the routes with no SDK equivalent are plain fetches: `/api/auth/me`,
|
|
13
|
+
* `/api/auth/logout` and `/api/auth/reset-password` are this app's own Next
|
|
14
|
+
* route handlers, not Brainerce endpoints.
|
|
15
|
+
*
|
|
16
|
+
* The token is managed server-side via httpOnly cookies, never exposed to JS.
|
|
17
|
+
*/
|
|
18
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
19
|
+
|
|
20
|
+
const CSRF_HEADERS: Record<string, string> = {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'X-Requested-With': 'brainerce',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface LoginResult {
|
|
26
|
+
customer: {
|
|
27
|
+
id: string;
|
|
28
|
+
email: string;
|
|
29
|
+
firstName?: string;
|
|
30
|
+
lastName?: string;
|
|
31
|
+
emailVerified: boolean;
|
|
32
|
+
};
|
|
33
|
+
expiresAt: string;
|
|
34
|
+
requiresVerification?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RegisterResult {
|
|
38
|
+
customer: {
|
|
39
|
+
id: string;
|
|
40
|
+
email: string;
|
|
41
|
+
firstName?: string;
|
|
42
|
+
lastName?: string;
|
|
43
|
+
emailVerified: boolean;
|
|
44
|
+
};
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
requiresVerification?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AuthStatus {
|
|
50
|
+
isLoggedIn: boolean;
|
|
51
|
+
customer?: {
|
|
52
|
+
id: string;
|
|
53
|
+
email: string;
|
|
54
|
+
firstName?: string;
|
|
55
|
+
lastName?: string;
|
|
56
|
+
phone?: string;
|
|
57
|
+
emailVerified: boolean;
|
|
58
|
+
};
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface VerifyEmailResult {
|
|
63
|
+
verified: boolean;
|
|
64
|
+
message?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function handleResponse<T>(response: Response): Promise<T> {
|
|
68
|
+
const data = await response.json();
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(data.message || data.error || `Request failed (${response.status})`);
|
|
71
|
+
}
|
|
72
|
+
return data as T;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Login via the SDK, routed through the BFF proxy, which sets the httpOnly
|
|
77
|
+
* cookie on success and strips the token from the response. The narrow
|
|
78
|
+
* `LoginResult` return type is deliberate: the SDK's own response type
|
|
79
|
+
* declares a `token`, but the proxy removes it before it reaches the browser,
|
|
80
|
+
* so nothing here should read one.
|
|
81
|
+
*/
|
|
82
|
+
export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
|
|
83
|
+
return getClient().loginCustomer(email, password);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Register via the SDK, routed through the BFF proxy, which sets the httpOnly
|
|
88
|
+
* cookie on success.
|
|
89
|
+
*/
|
|
90
|
+
export async function proxyRegister(data: {
|
|
91
|
+
firstName: string;
|
|
92
|
+
lastName: string;
|
|
93
|
+
email: string;
|
|
94
|
+
password: string;
|
|
95
|
+
acceptsMarketing?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Birthday month (1-12) and day (1-31), never a year. Powers the loyalty
|
|
98
|
+
* birthday gift. Send both or neither: one without the other is rejected
|
|
99
|
+
* with HTTP 400, and so is a day the month does not have. Required only when
|
|
100
|
+
* `getStoreInfo().requireBirthday` is true for this sales channel.
|
|
101
|
+
*/
|
|
102
|
+
birthMonth?: number;
|
|
103
|
+
birthDay?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Loyalty referral share code, captured from a `?ref=` link on the way in and
|
|
106
|
+
* read back out of the cookie by the register form. Validated asynchronously
|
|
107
|
+
* after registration: a stale or unknown code never fails the registration
|
|
108
|
+
* itself, so it is always safe to send whatever was captured.
|
|
109
|
+
*/
|
|
110
|
+
referralCode?: string;
|
|
111
|
+
}): Promise<RegisterResult> {
|
|
112
|
+
return getClient().registerCustomer(data);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check auth status. Reads httpOnly cookie server-side and validates with backend.
|
|
117
|
+
*/
|
|
118
|
+
export async function checkAuthStatus(): Promise<AuthStatus> {
|
|
119
|
+
const response = await fetch('/api/auth/me');
|
|
120
|
+
return response.json();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Logout. Clears httpOnly auth cookies server-side.
|
|
125
|
+
*/
|
|
126
|
+
export async function proxyLogout(): Promise<void> {
|
|
127
|
+
await fetch('/api/auth/logout', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'X-Requested-With': 'brainerce' },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Verify email via the SDK. No token argument is passed: the auth token lives
|
|
135
|
+
* in the httpOnly cookie (set during login/register) and the proxy attaches
|
|
136
|
+
* the Authorization header. The SDK skips its own token check in proxy mode.
|
|
137
|
+
*/
|
|
138
|
+
export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
|
|
139
|
+
return getClient().verifyEmail(code);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resend the verification email via the SDK. Uses the auth token from the
|
|
144
|
+
* httpOnly cookie, added by the proxy. Rate limited to 3 requests per hour.
|
|
145
|
+
*/
|
|
146
|
+
export async function proxyResendVerification(): Promise<{ message: string }> {
|
|
147
|
+
return getClient().resendVerificationEmail();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Reset password via BFF proxy.
|
|
152
|
+
* The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
|
|
153
|
+
* clicked the email link). The proxy reads it server-side — the token never reaches client JS.
|
|
154
|
+
*/
|
|
155
|
+
export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
|
|
156
|
+
const response = await fetch('/api/auth/reset-password', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: CSRF_HEADERS,
|
|
159
|
+
body: JSON.stringify({ newPassword }),
|
|
160
|
+
});
|
|
161
|
+
return handleResponse<{ message: string }>(response);
|
|
162
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLIENT-SAFE referral-code capture.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a cookie, and why it outlives the visit
|
|
5
|
+
*
|
|
6
|
+
* A referral link is `https://your-store.com/?ref=<code>` (the shape the
|
|
7
|
+
* dashboard builds around `LoyaltyStatus.referralCode`). It lands on the home
|
|
8
|
+
* page, and almost nobody registers on that first page view: they browse, they
|
|
9
|
+
* leave, they come back. If the code lives in component state it is gone the
|
|
10
|
+
* moment they click a product, the referrer never gets credited, and the
|
|
11
|
+
* shopper never gets the welcome reward they were promised. So it is persisted
|
|
12
|
+
* the instant it arrives and read back at registration, however much later.
|
|
13
|
+
*
|
|
14
|
+
* Same reasoning and same shape as `region.ts`: not `httpOnly`, because a
|
|
15
|
+
* client component both writes it and reads it, and it holds a public share
|
|
16
|
+
* code that was already sitting in the URL.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ReferralInfo } from 'brainerce';
|
|
20
|
+
|
|
21
|
+
export const REFERRAL_COOKIE = 'brainerce_referral';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thirty days. Long enough to cover browse-then-return-later, short enough that
|
|
25
|
+
* a code does not silently attach itself to an account created months later by
|
|
26
|
+
* a different person on a shared machine.
|
|
27
|
+
*/
|
|
28
|
+
const REFERRAL_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
|
29
|
+
|
|
30
|
+
/** Codes are `REF-XXXXXXXX` today; the cap leaves room for that to change. */
|
|
31
|
+
const MAX_REFERRAL_CODE_LENGTH = 64;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Accept only a plausible share code.
|
|
35
|
+
*
|
|
36
|
+
* The value comes from the query string, which anybody can write anything into.
|
|
37
|
+
* It ends up in a cookie and in a registration payload, so it is charset- and
|
|
38
|
+
* length-checked here rather than trusted: `encodeURIComponent` already stops a
|
|
39
|
+
* `;` from splitting the cookie, and this stops the rest of the nonsense from
|
|
40
|
+
* being stored at all. An unknown-but-well-formed code is not rejected here on
|
|
41
|
+
* purpose; `getReferralInfo()` is the authority on whether it is real.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeReferralCode(raw: string | null | undefined): string | null {
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
const code = raw.trim();
|
|
46
|
+
if (!code || code.length > MAX_REFERRAL_CODE_LENGTH) return null;
|
|
47
|
+
if (!/^[A-Za-z0-9_-]+$/.test(code)) return null;
|
|
48
|
+
return code;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read the captured referral code in the browser. Returns null during SSR. */
|
|
52
|
+
export function readReferralCookie(): string | null {
|
|
53
|
+
if (typeof document === 'undefined') return null;
|
|
54
|
+
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${REFERRAL_COOKIE}=([^;]*)`));
|
|
55
|
+
return match ? normalizeReferralCode(decodeURIComponent(match[1])) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Persist a captured referral code. `SameSite=Lax` so the code survives the
|
|
60
|
+
* click in from an email, a social post or a messaging app, which is where
|
|
61
|
+
* referral links actually live; `Secure` only off localhost, where there is no
|
|
62
|
+
* https to attach it to.
|
|
63
|
+
*/
|
|
64
|
+
export function writeReferralCookie(code: string): void {
|
|
65
|
+
if (typeof document === 'undefined') return;
|
|
66
|
+
const safe = normalizeReferralCode(code);
|
|
67
|
+
if (!safe) return;
|
|
68
|
+
const secure = typeof location !== 'undefined' && location.protocol === 'https:';
|
|
69
|
+
document.cookie =
|
|
70
|
+
`${REFERRAL_COOKIE}=${encodeURIComponent(safe)}; path=/; max-age=${REFERRAL_COOKIE_MAX_AGE}; SameSite=Lax` +
|
|
71
|
+
(secure ? '; Secure' : '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Drop the captured code. Called once registration succeeds: the code has been
|
|
76
|
+
* spent, and leaving it behind would attach the same referrer to the next
|
|
77
|
+
* account created in this browser.
|
|
78
|
+
*/
|
|
79
|
+
export function clearReferralCookie(): void {
|
|
80
|
+
if (typeof document === 'undefined') return;
|
|
81
|
+
document.cookie = `${REFERRAL_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a referral lookup is worth greeting the visitor about.
|
|
86
|
+
*
|
|
87
|
+
* `getReferralInfo()` answers `{ valid: false }` for an unknown code, a
|
|
88
|
+
* disabled referral program and an inactive program alike, so this is the only
|
|
89
|
+
* gate that matters after the call. A valid referral with no configured reward
|
|
90
|
+
* is still worth showing: the referrer's name is the reason the visitor
|
|
91
|
+
* clicked.
|
|
92
|
+
*/
|
|
93
|
+
export function isGreetableReferral(info: ReferralInfo | null): info is ReferralInfo {
|
|
94
|
+
return info !== null && info.valid;
|
|
95
|
+
}
|
|
@@ -63,6 +63,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
63
63
|
const [adding, setAdding] = useState(false);
|
|
64
64
|
const [added, setAdded] = useState(false);
|
|
65
65
|
|
|
66
|
+
// `!== false`, not `=== true`, and that matters for KIT products: a kit
|
|
67
|
+
// carries NO `inventory` object of its own (its availability is derived from
|
|
68
|
+
// its components), so a strict truthy check would render every kit as out of
|
|
69
|
+
// stock. A kit also falls through `isVariable` above and adds by `productId`
|
|
70
|
+
// alone, which is correct — kits take no variantId and no selections.
|
|
66
71
|
const canPurchase = product.inventory?.canPurchase !== false;
|
|
67
72
|
|
|
68
73
|
async function handleAddToCart(e: React.MouseEvent) {
|
|
@@ -63,6 +63,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
63
63
|
const [adding, setAdding] = useState(false);
|
|
64
64
|
const [added, setAdded] = useState(false);
|
|
65
65
|
|
|
66
|
+
// `!== false`, not `=== true`, and that matters for KIT products: a kit
|
|
67
|
+
// carries NO `inventory` object of its own (its availability is derived from
|
|
68
|
+
// its components), so a strict truthy check would render every kit as out of
|
|
69
|
+
// stock. A kit also falls through `isVariable` above and adds by `productId`
|
|
70
|
+
// alone, which is correct — kits take no variantId and no selections.
|
|
66
71
|
const canPurchase = product.inventory?.canPurchase !== false;
|
|
67
72
|
|
|
68
73
|
const imageWellRef = useRef<HTMLDivElement>(null);
|
|
@@ -58,6 +58,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
58
58
|
const [adding, setAdding] = useState(false);
|
|
59
59
|
const [added, setAdded] = useState(false);
|
|
60
60
|
|
|
61
|
+
// `!== false`, not `=== true`, and that matters for KIT products: a kit
|
|
62
|
+
// carries NO `inventory` object of its own (its availability is derived from
|
|
63
|
+
// its components), so a strict truthy check would render every kit as out of
|
|
64
|
+
// stock. A kit also falls through `isVariable` above and adds by `productId`
|
|
65
|
+
// alone, which is correct — kits take no variantId and no selections.
|
|
61
66
|
const canPurchase = product.inventory?.canPurchase !== false;
|
|
62
67
|
|
|
63
68
|
async function handleAddToCart(e: React.MouseEvent) {
|