feat: add referral code persistence across all auth methods

Mirrors campaign.ts localStorage pattern to capture ?ref= from URL and pass
referral_code to backend during Telegram Widget, OAuth, email login, and
Mini App authentication. Fixes redirect loop when email auth is disabled.
This commit is contained in:
Fringg
2026-02-18 23:59:32 +03:00
parent 271a005e87
commit 2b2ead837c
5 changed files with 129 additions and 15 deletions

View File

@@ -3,10 +3,15 @@ import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User
export const authApi = { export const authApi = {
// Telegram WebApp authentication // Telegram WebApp authentication
loginTelegram: async (initData: string, campaignSlug?: string | null): Promise<AuthResponse> => { loginTelegram: async (
initData: string,
campaignSlug?: string | null,
referralCode?: string | null,
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', { const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
init_data: initData, init_data: initData,
campaign_slug: campaignSlug || undefined, campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
}); });
return response.data; return response.data;
}, },
@@ -23,10 +28,12 @@ export const authApi = {
hash: string; hash: string;
}, },
campaignSlug?: string | null, campaignSlug?: string | null,
referralCode?: string | null,
): Promise<AuthResponse> => { ): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', { const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
...data, ...data,
campaign_slug: campaignSlug || undefined, campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
}); });
return response.data; return response.data;
}, },
@@ -36,11 +43,13 @@ export const authApi = {
email: string, email: string,
password: string, password: string,
campaignSlug?: string | null, campaignSlug?: string | null,
referralCode?: string | null,
): Promise<AuthResponse> => { ): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', { const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', {
email, email,
password, password,
campaign_slug: campaignSlug || undefined, campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
}); });
return response.data; return response.data;
}, },
@@ -166,10 +175,16 @@ export const authApi = {
code: string, code: string,
state: string, state: string,
campaignSlug?: string | null, campaignSlug?: string | null,
referralCode?: string | null,
): Promise<AuthResponse> => { ): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>( const response = await apiClient.post<AuthResponse>(
`/cabinet/auth/oauth/${encodeURIComponent(provider)}/callback`, `/cabinet/auth/oauth/${encodeURIComponent(provider)}/callback`,
{ code, state, campaign_slug: campaignSlug || undefined }, {
code,
state,
campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
},
); );
return response.data; return response.data;
}, },

View File

@@ -3,9 +3,13 @@ import { useTranslation } from 'react-i18next';
interface TelegramLoginButtonProps { interface TelegramLoginButtonProps {
botUsername: string; botUsername: string;
referralCode?: string;
} }
export default function TelegramLoginButton({ botUsername }: TelegramLoginButtonProps) { export default function TelegramLoginButton({
botUsername,
referralCode,
}: TelegramLoginButtonProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -51,7 +55,11 @@ export default function TelegramLoginButton({ botUsername }: TelegramLoginButton
<div className="text-center"> <div className="text-center">
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p> <p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
<a <a
href={`https://t.me/${botUsername}`} href={
referralCode
? `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`
: `https://t.me/${botUsername}`
}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-telegram-blue inline-flex items-center text-sm hover:underline" className="text-telegram-blue inline-flex items-center text-sm hover:underline"

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router'; import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
@@ -20,12 +20,12 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton'; import TelegramLoginButton from '../components/TelegramLoginButton';
import OAuthProviderIcon from '../components/OAuthProviderIcon'; import OAuthProviderIcon from '../components/OAuthProviderIcon';
import { saveOAuthState } from './OAuthCallback'; import { saveOAuthState } from './OAuthCallback';
import { consumeReferralCode, getPendingReferralCode } from '../utils/referral';
export default function Login() { export default function Login() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams();
const { const {
isAuthenticated, isAuthenticated,
isLoading: isAuthInitializing, isLoading: isAuthInitializing,
@@ -34,8 +34,8 @@ export default function Login() {
registerWithEmail, registerWithEmail,
} = useAuthStore(); } = useAuthStore();
// Extract referral code from URL // Get referral code from localStorage (captured from ?ref= param at module level in auth store)
const referralCode = searchParams.get('ref') || ''; const referralCode = getPendingReferralCode() || '';
const [authMode, setAuthMode] = useState<'login' | 'register'>(() => const [authMode, setAuthMode] = useState<'login' | 'register'>(() =>
referralCode ? 'register' : 'login', referralCode ? 'register' : 'login',
@@ -140,6 +140,7 @@ export default function Login() {
// If email auth is disabled but user came with ref param, redirect to bot // If email auth is disabled but user came with ref param, redirect to bot
useEffect(() => { useEffect(() => {
if (referralCode && emailAuthConfig?.enabled === false && botUsername) { if (referralCode && emailAuthConfig?.enabled === false && botUsername) {
consumeReferralCode();
window.location.href = `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`; window.location.href = `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`;
} }
}, [referralCode, emailAuthConfig, botUsername]); }, [referralCode, emailAuthConfig, botUsername]);
@@ -474,7 +475,10 @@ export default function Login() {
</p> </p>
</div> </div>
) : ( ) : (
<TelegramLoginButton botUsername={botUsername} /> <TelegramLoginButton
botUsername={botUsername}
referralCode={referralCode || undefined}
/>
)} )}
</div> </div>

View File

@@ -4,6 +4,7 @@ import type { CampaignBonusInfo, RegisterResponse, User } from '../types';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { captureCampaignFromUrl, consumeCampaignSlug } from '../utils/campaign'; import { captureCampaignFromUrl, consumeCampaignSlug } from '../utils/campaign';
import { captureReferralFromUrl, consumeReferralCode } from '../utils/referral';
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token'; import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token';
export interface TelegramWidgetData { export interface TelegramWidgetData {
@@ -242,7 +243,8 @@ export const useAuthStore = create<AuthState>()(
loginWithTelegram: async (initData) => { loginWithTelegram: async (initData) => {
const campaignSlug = consumeCampaignSlug(); const campaignSlug = consumeCampaignSlug();
const response = await authApi.loginTelegram(initData, campaignSlug); const referralCode = consumeReferralCode();
const response = await authApi.loginTelegram(initData, campaignSlug, referralCode);
tokenStorage.setTokens(response.access_token, response.refresh_token); tokenStorage.setTokens(response.access_token, response.refresh_token);
set({ set({
accessToken: response.access_token, accessToken: response.access_token,
@@ -256,7 +258,8 @@ export const useAuthStore = create<AuthState>()(
loginWithTelegramWidget: async (data) => { loginWithTelegramWidget: async (data) => {
const campaignSlug = consumeCampaignSlug(); const campaignSlug = consumeCampaignSlug();
const response = await authApi.loginTelegramWidget(data, campaignSlug); const referralCode = consumeReferralCode();
const response = await authApi.loginTelegramWidget(data, campaignSlug, referralCode);
tokenStorage.setTokens(response.access_token, response.refresh_token); tokenStorage.setTokens(response.access_token, response.refresh_token);
set({ set({
accessToken: response.access_token, accessToken: response.access_token,
@@ -270,7 +273,8 @@ export const useAuthStore = create<AuthState>()(
loginWithEmail: async (email, password) => { loginWithEmail: async (email, password) => {
const campaignSlug = consumeCampaignSlug(); const campaignSlug = consumeCampaignSlug();
const response = await authApi.loginEmail(email, password, campaignSlug); const referralCode = consumeReferralCode();
const response = await authApi.loginEmail(email, password, campaignSlug, referralCode);
tokenStorage.setTokens(response.access_token, response.refresh_token); tokenStorage.setTokens(response.access_token, response.refresh_token);
set({ set({
accessToken: response.access_token, accessToken: response.access_token,
@@ -284,7 +288,14 @@ export const useAuthStore = create<AuthState>()(
loginWithOAuth: async (provider, code, state) => { loginWithOAuth: async (provider, code, state) => {
const campaignSlug = consumeCampaignSlug(); const campaignSlug = consumeCampaignSlug();
const response = await authApi.oauthCallback(provider, code, state, campaignSlug); const referralCode = consumeReferralCode();
const response = await authApi.oauthCallback(
provider,
code,
state,
campaignSlug,
referralCode,
);
tokenStorage.setTokens(response.access_token, response.refresh_token); tokenStorage.setTokens(response.access_token, response.refresh_token);
set({ set({
accessToken: response.access_token, accessToken: response.access_token,
@@ -300,12 +311,13 @@ export const useAuthStore = create<AuthState>()(
// Registration now returns message, not tokens // Registration now returns message, not tokens
// User must verify email before they can login // User must verify email before they can login
// Campaign slug stays in localStorage — consumed during verify_email step // Campaign slug stays in localStorage — consumed during verify_email step
const code = referralCode || consumeReferralCode() || undefined;
const response = await authApi.registerEmailStandalone({ const response = await authApi.registerEmailStandalone({
email, email,
password, password,
first_name: firstName, first_name: firstName,
language: navigator.language.split('-')[0] || 'ru', language: navigator.language.split('-')[0] || 'ru',
referral_code: referralCode, referral_code: code,
}); });
return response; return response;
}, },
@@ -321,8 +333,9 @@ export const useAuthStore = create<AuthState>()(
), ),
); );
// Capture campaign slug from URL before auth initialization // Capture campaign slug and referral code from URL before auth initialization
captureCampaignFromUrl(); captureCampaignFromUrl();
captureReferralFromUrl();
// Initialize auth on app load // Initialize auth on app load
useAuthStore.getState().initialize(); useAuthStore.getState().initialize();

74
src/utils/referral.ts Normal file
View File

@@ -0,0 +1,74 @@
const REFERRAL_KEY = 'referral_code';
const REFERRAL_TTL_KEY = 'referral_code_ttl';
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const CODE_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
/**
* Get valid referral code from localStorage, clearing expired entries.
*/
function getValidCode(): string | null {
try {
const code = localStorage.getItem(REFERRAL_KEY);
if (!code) return null;
const ttl = localStorage.getItem(REFERRAL_TTL_KEY);
if (!ttl || Number.isNaN(Number(ttl)) || Date.now() > Number(ttl)) {
localStorage.removeItem(REFERRAL_KEY);
localStorage.removeItem(REFERRAL_TTL_KEY);
return null;
}
return code;
} catch {
return null;
}
}
function clearCode(): void {
try {
localStorage.removeItem(REFERRAL_KEY);
localStorage.removeItem(REFERRAL_TTL_KEY);
} catch {
// localStorage unavailable
}
}
/**
* Capture referral code from URL query param (?ref=), store in localStorage with TTL,
* and clean the URL.
*/
export function captureReferralFromUrl(): void {
try {
const params = new URLSearchParams(window.location.search);
const code = params.get('ref');
if (!code || !CODE_PATTERN.test(code)) return;
localStorage.setItem(REFERRAL_KEY, code);
localStorage.setItem(REFERRAL_TTL_KEY, String(Date.now() + TTL_MS));
// Clean URL
params.delete('ref');
const newSearch = params.toString();
const newUrl =
window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
window.history.replaceState(null, '', newUrl);
} catch {
// localStorage or history API unavailable
}
}
/**
* Consume (get + clear) the stored referral code. One-time use during auth.
*/
export function consumeReferralCode(): string | null {
const code = getValidCode();
if (code) clearCode();
return code;
}
/**
* Read stored referral code without clearing it (for UI display).
*/
export function getPendingReferralCode(): string | null {
return getValidCode();
}