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.
@@ -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
- }): Promise<RegisterResult> {
105
- return getClient().registerCustomer(data);
106
- }
107
-
108
- /**
109
- * Check auth status. Reads httpOnly cookie server-side and validates with backend.
110
- */
111
- export async function checkAuthStatus(): Promise<AuthStatus> {
112
- const response = await fetch('/api/auth/me');
113
- return response.json();
114
- }
115
-
116
- /**
117
- * Logout. Clears httpOnly auth cookies server-side.
118
- */
119
- export async function proxyLogout(): Promise<void> {
120
- await fetch('/api/auth/logout', {
121
- method: 'POST',
122
- headers: { 'X-Requested-With': 'brainerce' },
123
- });
124
- }
125
-
126
- /**
127
- * Verify email via the SDK. No token argument is passed: the auth token lives
128
- * in the httpOnly cookie (set during login/register) and the proxy attaches
129
- * the Authorization header. The SDK skips its own token check in proxy mode.
130
- */
131
- export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
132
- return getClient().verifyEmail(code);
133
- }
134
-
135
- /**
136
- * Resend the verification email via the SDK. Uses the auth token from the
137
- * httpOnly cookie, added by the proxy. Rate limited to 3 requests per hour.
138
- */
139
- export async function proxyResendVerification(): Promise<{ message: string }> {
140
- return getClient().resendVerificationEmail();
141
- }
142
-
143
- /**
144
- * Reset password via BFF proxy.
145
- * The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
146
- * clicked the email link). The proxy reads it server-side — the token never reaches client JS.
147
- */
148
- export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
149
- const response = await fetch('/api/auth/reset-password', {
150
- method: 'POST',
151
- headers: CSRF_HEADERS,
152
- body: JSON.stringify({ newPassword }),
153
- });
154
- return handleResponse<{ message: string }>(response);
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) {