mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add web campaign links — capture, auth integration, bonus UI
- Add campaign.ts utility (capture from URL, localStorage with TTL, validation) - Pass campaign_slug in all auth API methods (telegram, widget, email, oauth) - Consume slug in auth store login methods, set pendingCampaignBonus state - Add CampaignBonusNotifier component (toast on bonus, mounted in AppShell) - Show web_link alongside bot deep_link in AdminCampaignStats - Add CampaignBonusInfo type with union bonus_type - Fix VerifyEmail: ref guard against StrictMode double-fire, setTimeout cleanup - Fix AdminCampaignStats: setTimeout refs with cleanup, i18n-aware locale - Wrap all localStorage access in try/catch (Safari private browsing safety) - Add campaignBonus translations (ru/en/fa/zh)
This commit is contained in:
@@ -3,32 +3,44 @@ import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User
|
||||
|
||||
export const authApi = {
|
||||
// Telegram WebApp authentication
|
||||
loginTelegram: async (initData: string): Promise<AuthResponse> => {
|
||||
loginTelegram: async (initData: string, campaignSlug?: string | null): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
||||
init_data: initData,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Telegram Login Widget authentication
|
||||
loginTelegramWidget: async (data: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
}): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data);
|
||||
loginTelegramWidget: async (
|
||||
data: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
},
|
||||
campaignSlug?: string | null,
|
||||
): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
|
||||
...data,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Email login
|
||||
loginEmail: async (email: string, password: string): Promise<AuthResponse> => {
|
||||
loginEmail: async (
|
||||
email: string,
|
||||
password: string,
|
||||
campaignSlug?: string | null,
|
||||
): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', {
|
||||
email,
|
||||
password,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -62,8 +74,11 @@ export const authApi = {
|
||||
},
|
||||
|
||||
// Verify email and get auth tokens
|
||||
verifyEmail: async (token: string): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token });
|
||||
verifyEmail: async (token: string, campaignSlug?: string | null): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', {
|
||||
token,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -146,10 +161,15 @@ export const authApi = {
|
||||
},
|
||||
|
||||
// OAuth: callback (exchange code for tokens)
|
||||
oauthCallback: async (provider: string, code: string, state: string): Promise<AuthResponse> => {
|
||||
oauthCallback: async (
|
||||
provider: string,
|
||||
code: string,
|
||||
state: string,
|
||||
campaignSlug?: string | null,
|
||||
): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
`/cabinet/auth/oauth/${encodeURIComponent(provider)}/callback`,
|
||||
{ code, state },
|
||||
{ code, state, campaign_slug: campaignSlug || undefined },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface CampaignDetail {
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
deep_link: string | null;
|
||||
web_link: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignCreateRequest {
|
||||
@@ -104,6 +105,7 @@ export interface CampaignStatistics {
|
||||
conversion_rate: number;
|
||||
trial_conversion_rate: number;
|
||||
deep_link: string | null;
|
||||
web_link: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignRegistrationItem {
|
||||
|
||||
46
src/components/CampaignBonusNotifier.tsx
Normal file
46
src/components/CampaignBonusNotifier.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useToast } from './Toast';
|
||||
|
||||
export default function CampaignBonusNotifier() {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const bonus = useAuthStore((s) => s.pendingCampaignBonus);
|
||||
const clearBonus = useAuthStore((s) => s.clearCampaignBonus);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bonus) return;
|
||||
|
||||
let message: string | null = null;
|
||||
if (bonus.bonus_type === 'balance' && bonus.balance_kopeks > 0) {
|
||||
message = t('campaignBonus.balance', {
|
||||
amount: (bonus.balance_kopeks / 100).toFixed(0),
|
||||
name: bonus.campaign_name,
|
||||
});
|
||||
} else if (bonus.bonus_type === 'subscription' && bonus.subscription_days) {
|
||||
message = t('campaignBonus.subscription', {
|
||||
days: bonus.subscription_days,
|
||||
name: bonus.campaign_name,
|
||||
});
|
||||
} else if (bonus.bonus_type === 'tariff' && bonus.tariff_name) {
|
||||
message = t('campaignBonus.tariff', {
|
||||
tariff: bonus.tariff_name,
|
||||
name: bonus.campaign_name,
|
||||
});
|
||||
}
|
||||
|
||||
if (message) {
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: t('campaignBonus.title'),
|
||||
message,
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
|
||||
clearBonus();
|
||||
}, [bonus, clearBonus, showToast, t]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { cn } from '@/lib/utils';
|
||||
import { UI } from '@/config/constants';
|
||||
|
||||
import WebSocketNotifications from '@/components/WebSocketNotifications';
|
||||
import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
|
||||
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
||||
@@ -281,6 +282,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
{/* Global components */}
|
||||
<WebSocketNotifications />
|
||||
<CampaignBonusNotifier />
|
||||
<SuccessNotificationModal />
|
||||
|
||||
{/* Desktop Header */}
|
||||
|
||||
@@ -70,6 +70,12 @@
|
||||
"newUserReply": "User replied in ticket: {{title}}",
|
||||
"newUserReplyTitle": "User Reply"
|
||||
},
|
||||
"campaignBonus": {
|
||||
"title": "Bonus activated!",
|
||||
"balance": "You received {{amount}} RUB from campaign \"{{name}}\"",
|
||||
"subscription": "You received a {{days}}-day subscription from campaign \"{{name}}\"",
|
||||
"tariff": "You received tariff \"{{tariff}}\" from campaign \"{{name}}\""
|
||||
},
|
||||
"wsNotifications": {
|
||||
"balance": {
|
||||
"topupTitle": "Balance topped up",
|
||||
@@ -1684,6 +1690,8 @@
|
||||
"inactive": "Inactive",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"botLink": "Bot link",
|
||||
"webLink": "Web link",
|
||||
"users": "Users",
|
||||
"noUsers": "No registered users",
|
||||
"paid": "Paid",
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
"newUserReply": "کاربر در تیکت پاسخ داد: {{title}}",
|
||||
"newUserReplyTitle": "پاسخ کاربر"
|
||||
},
|
||||
"campaignBonus": {
|
||||
"title": "!پاداش فعال شد",
|
||||
"balance": "شما {{amount}} روبل از کمپین «{{name}}» دریافت کردید",
|
||||
"subscription": "شما اشتراک {{days}} روزه از کمپین «{{name}}» دریافت کردید",
|
||||
"tariff": "شما تعرفه «{{tariff}}» از کمپین «{{name}}» دریافت کردید"
|
||||
},
|
||||
"wsNotifications": {
|
||||
"balance": {
|
||||
"topupTitle": "موجودی شارژ شد",
|
||||
@@ -1421,6 +1427,8 @@
|
||||
"inactive": "غیرفعال",
|
||||
"copy": "کپی",
|
||||
"copied": "کپی شد!",
|
||||
"botLink": "لینک ربات",
|
||||
"webLink": "لینک وب",
|
||||
"users": "کاربران",
|
||||
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
||||
"paid": "پرداخت کرده",
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
"newUserReply": "Ответ пользователя в тикете: {{title}}",
|
||||
"newUserReplyTitle": "Ответ пользователя"
|
||||
},
|
||||
"campaignBonus": {
|
||||
"title": "Бонус активирован!",
|
||||
"balance": "Вам начислено {{amount}} руб. по акции «{{name}}»",
|
||||
"subscription": "Вам предоставлена подписка на {{days}} дн. по акции «{{name}}»",
|
||||
"tariff": "Вам предоставлен тариф «{{tariff}}» по акции «{{name}}»"
|
||||
},
|
||||
"wsNotifications": {
|
||||
"balance": {
|
||||
"topupTitle": "Баланс пополнен",
|
||||
@@ -2206,6 +2212,8 @@
|
||||
"inactive": "Неактивна",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано!",
|
||||
"botLink": "Ссылка для бота",
|
||||
"webLink": "Веб-ссылка",
|
||||
"users": "Пользователи",
|
||||
"noUsers": "Нет зарегистрированных пользователей",
|
||||
"paid": "Платил",
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
"newUserReply": "用户回复了工单:{{title}}",
|
||||
"newUserReplyTitle": "用户回复"
|
||||
},
|
||||
"campaignBonus": {
|
||||
"title": "奖励已激活!",
|
||||
"balance": "您从活动「{{name}}」获得了 {{amount}} 卢布",
|
||||
"subscription": "您从活动「{{name}}」获得了 {{days}} 天订阅",
|
||||
"tariff": "您从活动「{{name}}」获得了套餐「{{tariff}}」"
|
||||
},
|
||||
"wsNotifications": {
|
||||
"balance": {
|
||||
"topupTitle": "余额已充值",
|
||||
@@ -1420,6 +1426,8 @@
|
||||
"inactive": "未激活",
|
||||
"copy": "复制",
|
||||
"copied": "已复制!",
|
||||
"botLink": "机器人链接",
|
||||
"webLink": "网页链接",
|
||||
"users": "用户",
|
||||
"noUsers": "没有注册用户",
|
||||
"paid": "已付费",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
@@ -74,14 +75,18 @@ const bonusTypeConfig: Record<
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
|
||||
const formatRubles = (kopeks: number): string => {
|
||||
const rubles = kopeks / 100;
|
||||
return `${rubles.toLocaleString('ru-RU')} ₽`;
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return `${rubles.toLocaleString(locale)} ₽`;
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('ru-RU', {
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
@@ -94,8 +99,18 @@ export default function AdminCampaignStats() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedBot, setCopiedBot] = useState(false);
|
||||
const [copiedWeb, setCopiedWeb] = useState(false);
|
||||
const [showUsers, setShowUsers] = useState(false);
|
||||
const copyBotTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const copyWebTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(copyBotTimer.current);
|
||||
clearTimeout(copyWebTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch stats
|
||||
const {
|
||||
@@ -115,11 +130,29 @@ export default function AdminCampaignStats() {
|
||||
enabled: !!id && showUsers,
|
||||
});
|
||||
|
||||
const copyLink = () => {
|
||||
const copyBotLink = async () => {
|
||||
if (stats?.deep_link) {
|
||||
navigator.clipboard.writeText(stats.deep_link);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
try {
|
||||
await navigator.clipboard.writeText(stats.deep_link);
|
||||
setCopiedBot(true);
|
||||
clearTimeout(copyBotTimer.current);
|
||||
copyBotTimer.current = setTimeout(() => setCopiedBot(false), 2000);
|
||||
} catch {
|
||||
// Clipboard not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyWebLink = async () => {
|
||||
if (stats?.web_link) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stats.web_link);
|
||||
setCopiedWeb(true);
|
||||
clearTimeout(copyWebTimer.current);
|
||||
copyWebTimer.current = setTimeout(() => setCopiedWeb(false), 2000);
|
||||
} catch {
|
||||
// Clipboard not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,24 +218,57 @@ export default function AdminCampaignStats() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Deep Link */}
|
||||
{stats.deep_link && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LinkIcon />
|
||||
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
|
||||
{/* Links */}
|
||||
{(stats.deep_link || stats.web_link) && (
|
||||
<div className="space-y-3">
|
||||
{stats.deep_link && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('admin.campaigns.stats.botLink')}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LinkIcon />
|
||||
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyBotLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
{copiedBot
|
||||
? t('admin.campaigns.stats.copied')
|
||||
: t('admin.campaigns.stats.copy')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
{copied ? t('admin.campaigns.stats.copied') : t('admin.campaigns.stats.copy')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{stats.web_link && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('admin.campaigns.stats.webLink')}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LinkIcon />
|
||||
<span className="truncate text-sm text-dark-300">{stats.web_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyWebLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
{copiedWeb
|
||||
? t('admin.campaigns.stats.copied')
|
||||
: t('admin.campaigns.stats.copy')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams, Link, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { consumeCampaignSlug } from '../utils/campaign';
|
||||
import { tokenStorage } from '../utils/token';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
@@ -13,8 +14,11 @@ export default function VerifyEmail() {
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [error, setError] = useState('');
|
||||
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
|
||||
const hasVerified = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasVerified.current) return;
|
||||
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
@@ -23,17 +27,24 @@ export default function VerifyEmail() {
|
||||
return;
|
||||
}
|
||||
|
||||
hasVerified.current = true;
|
||||
let redirectTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
const response = await authApi.verifyEmail(token);
|
||||
const campaignSlug = consumeCampaignSlug();
|
||||
const response = await authApi.verifyEmail(token, campaignSlug);
|
||||
// Save tokens and log user in
|
||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||
setTokens(response.access_token, response.refresh_token);
|
||||
setUser(response.user);
|
||||
if (response.campaign_bonus) {
|
||||
useAuthStore.setState({ pendingCampaignBonus: response.campaign_bonus });
|
||||
}
|
||||
checkAdminStatus();
|
||||
setStatus('success');
|
||||
// Redirect to dashboard after short delay
|
||||
setTimeout(() => navigate('/', { replace: true }), 1500);
|
||||
redirectTimer = setTimeout(() => navigate('/', { replace: true }), 1500);
|
||||
} catch (err: unknown) {
|
||||
setStatus('error');
|
||||
const error = err as { response?: { data?: { detail?: string } } };
|
||||
@@ -42,6 +53,8 @@ export default function VerifyEmail() {
|
||||
};
|
||||
|
||||
verify();
|
||||
|
||||
return () => clearTimeout(redirectTimer);
|
||||
}, [searchParams, t, navigate, setTokens, setUser, checkAdminStatus]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { RegisterResponse, User } from '../types';
|
||||
import type { CampaignBonusInfo, RegisterResponse, User } from '../types';
|
||||
import { authApi } from '../api/auth';
|
||||
import { apiClient } from '../api/client';
|
||||
import { captureCampaignFromUrl, consumeCampaignSlug } from '../utils/campaign';
|
||||
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token';
|
||||
|
||||
export interface TelegramWidgetData {
|
||||
@@ -22,10 +23,12 @@ interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
pendingCampaignBonus: CampaignBonusInfo | null;
|
||||
|
||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||
setUser: (user: User) => void;
|
||||
setIsAdmin: (isAdmin: boolean) => void;
|
||||
clearCampaignBonus: () => void;
|
||||
logout: () => void;
|
||||
initialize: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
@@ -59,6 +62,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
isAdmin: false,
|
||||
pendingCampaignBonus: null,
|
||||
|
||||
clearCampaignBonus: () => set({ pendingCampaignBonus: null }),
|
||||
|
||||
setTokens: (accessToken, refreshToken) => {
|
||||
tokenStorage.setTokens(accessToken, refreshToken);
|
||||
@@ -235,49 +241,57 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
|
||||
loginWithTelegram: async (initData) => {
|
||||
const response = await authApi.loginTelegram(initData);
|
||||
const campaignSlug = consumeCampaignSlug();
|
||||
const response = await authApi.loginTelegram(initData, campaignSlug);
|
||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||
set({
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
pendingCampaignBonus: response.campaign_bonus || null,
|
||||
});
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
loginWithTelegramWidget: async (data) => {
|
||||
const response = await authApi.loginTelegramWidget(data);
|
||||
const campaignSlug = consumeCampaignSlug();
|
||||
const response = await authApi.loginTelegramWidget(data, campaignSlug);
|
||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||
set({
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
pendingCampaignBonus: response.campaign_bonus || null,
|
||||
});
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
loginWithEmail: async (email, password) => {
|
||||
const response = await authApi.loginEmail(email, password);
|
||||
const campaignSlug = consumeCampaignSlug();
|
||||
const response = await authApi.loginEmail(email, password, campaignSlug);
|
||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||
set({
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
pendingCampaignBonus: response.campaign_bonus || null,
|
||||
});
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
loginWithOAuth: async (provider, code, state) => {
|
||||
const response = await authApi.oauthCallback(provider, code, state);
|
||||
const campaignSlug = consumeCampaignSlug();
|
||||
const response = await authApi.oauthCallback(provider, code, state, campaignSlug);
|
||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||
set({
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
pendingCampaignBonus: response.campaign_bonus || null,
|
||||
});
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
@@ -285,6 +299,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
registerWithEmail: async (email, password, firstName, referralCode) => {
|
||||
// Registration now returns message, not tokens
|
||||
// User must verify email before they can login
|
||||
// Campaign slug stays in localStorage — consumed during verify_email step
|
||||
const response = await authApi.registerEmailStandalone({
|
||||
email,
|
||||
password,
|
||||
@@ -306,5 +321,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
),
|
||||
);
|
||||
|
||||
// Capture campaign slug from URL before auth initialization
|
||||
captureCampaignFromUrl();
|
||||
|
||||
// Initialize auth on app load
|
||||
useAuthStore.getState().initialize();
|
||||
|
||||
@@ -21,6 +21,15 @@ export interface OAuthProvider {
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// Campaign bonus info (returned during auth)
|
||||
export interface CampaignBonusInfo {
|
||||
campaign_name: string;
|
||||
bonus_type: 'balance' | 'subscription' | 'tariff' | 'none';
|
||||
balance_kopeks: number;
|
||||
subscription_days: number | null;
|
||||
tariff_name: string | null;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
@@ -28,6 +37,7 @@ export interface AuthResponse {
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: User;
|
||||
campaign_bonus?: CampaignBonusInfo | null;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
|
||||
74
src/utils/campaign.ts
Normal file
74
src/utils/campaign.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
const CAMPAIGN_KEY = 'campaign_slug';
|
||||
const CAMPAIGN_TTL_KEY = 'campaign_slug_ttl';
|
||||
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const SLUG_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
/**
|
||||
* Get valid slug from localStorage, clearing expired entries.
|
||||
*/
|
||||
function getValidSlug(): string | null {
|
||||
try {
|
||||
const slug = localStorage.getItem(CAMPAIGN_KEY);
|
||||
if (!slug) return null;
|
||||
|
||||
const ttl = localStorage.getItem(CAMPAIGN_TTL_KEY);
|
||||
if (!ttl || Number.isNaN(Number(ttl)) || Date.now() > Number(ttl)) {
|
||||
localStorage.removeItem(CAMPAIGN_KEY);
|
||||
localStorage.removeItem(CAMPAIGN_TTL_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return slug;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSlug(): void {
|
||||
try {
|
||||
localStorage.removeItem(CAMPAIGN_KEY);
|
||||
localStorage.removeItem(CAMPAIGN_TTL_KEY);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture campaign slug from URL query param, store in localStorage with TTL,
|
||||
* and clean the URL.
|
||||
*/
|
||||
export function captureCampaignFromUrl(): void {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const slug = params.get('campaign');
|
||||
if (!slug || !SLUG_PATTERN.test(slug)) return;
|
||||
|
||||
localStorage.setItem(CAMPAIGN_KEY, slug);
|
||||
localStorage.setItem(CAMPAIGN_TTL_KEY, String(Date.now() + TTL_MS));
|
||||
|
||||
// Clean URL
|
||||
params.delete('campaign');
|
||||
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 campaign slug. One-time use during auth.
|
||||
*/
|
||||
export function consumeCampaignSlug(): string | null {
|
||||
const slug = getValidSlug();
|
||||
if (slug) clearSlug();
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read stored campaign slug without clearing it (for email registration flow).
|
||||
*/
|
||||
export function getPendingCampaignSlug(): string | null {
|
||||
return getValidSlug();
|
||||
}
|
||||
Reference in New Issue
Block a user