mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
74
src/utils/referral.ts
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user