Merge pull request #225 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-02-17 07:01:11 +03:00
committed by GitHub
13 changed files with 332 additions and 49 deletions

View File

@@ -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;
},

View File

@@ -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 {

View 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;
}

View File

@@ -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 */}

View File

@@ -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",

View File

@@ -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": "پرداخت کرده",

View File

@@ -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": "Платил",

View File

@@ -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": "已付费",

View File

@@ -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>
)}

View File

@@ -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 (

View File

@@ -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();

View File

@@ -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
View 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();
}