diff --git a/src/api/auth.ts b/src/api/auth.ts index 798d587..8b0605c 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -3,32 +3,44 @@ import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User export const authApi = { // Telegram WebApp authentication - loginTelegram: async (initData: string): Promise => { + loginTelegram: async (initData: string, campaignSlug?: string | null): Promise => { const response = await apiClient.post('/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 => { - const response = await apiClient.post('/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 => { + const response = await apiClient.post('/cabinet/auth/telegram/widget', { + ...data, + campaign_slug: campaignSlug || undefined, + }); return response.data; }, // Email login - loginEmail: async (email: string, password: string): Promise => { + loginEmail: async ( + email: string, + password: string, + campaignSlug?: string | null, + ): Promise => { const response = await apiClient.post('/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 => { - const response = await apiClient.post('/cabinet/auth/email/verify', { token }); + verifyEmail: async (token: string, campaignSlug?: string | null): Promise => { + const response = await apiClient.post('/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 => { + oauthCallback: async ( + provider: string, + code: string, + state: string, + campaignSlug?: string | null, + ): Promise => { const response = await apiClient.post( `/cabinet/auth/oauth/${encodeURIComponent(provider)}/callback`, - { code, state }, + { code, state, campaign_slug: campaignSlug || undefined }, ); return response.data; }, diff --git a/src/api/campaigns.ts b/src/api/campaigns.ts index 422878a..607e3ba 100644 --- a/src/api/campaigns.ts +++ b/src/api/campaigns.ts @@ -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 { diff --git a/src/components/CampaignBonusNotifier.tsx b/src/components/CampaignBonusNotifier.tsx new file mode 100644 index 0000000..a4e1c45 --- /dev/null +++ b/src/components/CampaignBonusNotifier.tsx @@ -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; +} diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 365c6c7..eb339f4 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -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 */} + {/* Desktop Header */} diff --git a/src/locales/en.json b/src/locales/en.json index cff5a8e..a0cd87a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 28f3377..b2a0e69 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "پرداخت کرده", diff --git a/src/locales/ru.json b/src/locales/ru.json index af829b7..20cc607 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Платил", diff --git a/src/locales/zh.json b/src/locales/zh.json index 7c54171..5e02d71 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "已付费", diff --git a/src/pages/AdminCampaignStats.tsx b/src/pages/AdminCampaignStats.tsx index 13494ee..c723c7a 100644 --- a/src/pages/AdminCampaignStats.tsx +++ b/src/pages/AdminCampaignStats.tsx @@ -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 = { 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>(undefined); + const copyWebTimer = useRef>(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() {
- {/* Deep Link */} - {stats.deep_link && ( -
-
-
- - {stats.deep_link} + {/* Links */} + {(stats.deep_link || stats.web_link) && ( +
+ {stats.deep_link && ( +
+
+ {t('admin.campaigns.stats.botLink')} +
+
+
+ + {stats.deep_link} +
+ +
- -
+ )} + {stats.web_link && ( +
+
+ {t('admin.campaigns.stats.webLink')} +
+
+
+ + {stats.web_link} +
+ +
+
+ )}
)} diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx index af85a78..9f35539 100644 --- a/src/pages/VerifyEmail.tsx +++ b/src/pages/VerifyEmail.tsx @@ -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; + 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 ( diff --git a/src/store/auth.ts b/src/store/auth.ts index 02590ef..31d77e5 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -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; refreshUser: () => Promise; @@ -59,6 +62,9 @@ export const useAuthStore = create()( 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()( }, 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()( 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()( ), ); +// Capture campaign slug from URL before auth initialization +captureCampaignFromUrl(); + // Initialize auth on app load useAuthStore.getState().initialize(); diff --git a/src/types/index.ts b/src/types/index.ts index 23bc36e..0d5e525 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 { diff --git a/src/utils/campaign.ts b/src/utils/campaign.ts new file mode 100644 index 0000000..4fa3d2c --- /dev/null +++ b/src/utils/campaign.ts @@ -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(); +}