mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +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 = {
|
export const authApi = {
|
||||||
// Telegram WebApp authentication
|
// 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', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
||||||
init_data: initData,
|
init_data: initData,
|
||||||
|
campaign_slug: campaignSlug || undefined,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Telegram Login Widget authentication
|
// Telegram Login Widget authentication
|
||||||
loginTelegramWidget: async (data: {
|
loginTelegramWidget: async (
|
||||||
id: number;
|
data: {
|
||||||
first_name: string;
|
id: number;
|
||||||
last_name?: string;
|
first_name: string;
|
||||||
username?: string;
|
last_name?: string;
|
||||||
photo_url?: string;
|
username?: string;
|
||||||
auth_date: number;
|
photo_url?: string;
|
||||||
hash: string;
|
auth_date: number;
|
||||||
}): Promise<AuthResponse> => {
|
hash: string;
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data);
|
},
|
||||||
|
campaignSlug?: string | null,
|
||||||
|
): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
|
||||||
|
...data,
|
||||||
|
campaign_slug: campaignSlug || undefined,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Email login
|
// 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', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
campaign_slug: campaignSlug || undefined,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -62,8 +74,11 @@ export const authApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Verify email and get auth tokens
|
// Verify email and get auth tokens
|
||||||
verifyEmail: async (token: string): Promise<AuthResponse> => {
|
verifyEmail: async (token: string, campaignSlug?: string | null): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token });
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', {
|
||||||
|
token,
|
||||||
|
campaign_slug: campaignSlug || undefined,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -146,10 +161,15 @@ export const authApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// OAuth: callback (exchange code for tokens)
|
// 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>(
|
const response = await apiClient.post<AuthResponse>(
|
||||||
`/cabinet/auth/oauth/${encodeURIComponent(provider)}/callback`,
|
`/cabinet/auth/oauth/${encodeURIComponent(provider)}/callback`,
|
||||||
{ code, state },
|
{ code, state, campaign_slug: campaignSlug || undefined },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface CampaignDetail {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
deep_link: string | null;
|
deep_link: string | null;
|
||||||
|
web_link: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignCreateRequest {
|
export interface CampaignCreateRequest {
|
||||||
@@ -104,6 +105,7 @@ export interface CampaignStatistics {
|
|||||||
conversion_rate: number;
|
conversion_rate: number;
|
||||||
trial_conversion_rate: number;
|
trial_conversion_rate: number;
|
||||||
deep_link: string | null;
|
deep_link: string | null;
|
||||||
|
web_link: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignRegistrationItem {
|
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 { UI } from '@/config/constants';
|
||||||
|
|
||||||
import WebSocketNotifications from '@/components/WebSocketNotifications';
|
import WebSocketNotifications from '@/components/WebSocketNotifications';
|
||||||
|
import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
|
||||||
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
||||||
@@ -281,6 +282,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
|
|
||||||
{/* Global components */}
|
{/* Global components */}
|
||||||
<WebSocketNotifications />
|
<WebSocketNotifications />
|
||||||
|
<CampaignBonusNotifier />
|
||||||
<SuccessNotificationModal />
|
<SuccessNotificationModal />
|
||||||
|
|
||||||
{/* Desktop Header */}
|
{/* Desktop Header */}
|
||||||
|
|||||||
@@ -70,6 +70,12 @@
|
|||||||
"newUserReply": "User replied in ticket: {{title}}",
|
"newUserReply": "User replied in ticket: {{title}}",
|
||||||
"newUserReplyTitle": "User Reply"
|
"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": {
|
"wsNotifications": {
|
||||||
"balance": {
|
"balance": {
|
||||||
"topupTitle": "Balance topped up",
|
"topupTitle": "Balance topped up",
|
||||||
@@ -1684,6 +1690,8 @@
|
|||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
|
"botLink": "Bot link",
|
||||||
|
"webLink": "Web link",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"noUsers": "No registered users",
|
"noUsers": "No registered users",
|
||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
|
|||||||
@@ -64,6 +64,12 @@
|
|||||||
"newUserReply": "کاربر در تیکت پاسخ داد: {{title}}",
|
"newUserReply": "کاربر در تیکت پاسخ داد: {{title}}",
|
||||||
"newUserReplyTitle": "پاسخ کاربر"
|
"newUserReplyTitle": "پاسخ کاربر"
|
||||||
},
|
},
|
||||||
|
"campaignBonus": {
|
||||||
|
"title": "!پاداش فعال شد",
|
||||||
|
"balance": "شما {{amount}} روبل از کمپین «{{name}}» دریافت کردید",
|
||||||
|
"subscription": "شما اشتراک {{days}} روزه از کمپین «{{name}}» دریافت کردید",
|
||||||
|
"tariff": "شما تعرفه «{{tariff}}» از کمپین «{{name}}» دریافت کردید"
|
||||||
|
},
|
||||||
"wsNotifications": {
|
"wsNotifications": {
|
||||||
"balance": {
|
"balance": {
|
||||||
"topupTitle": "موجودی شارژ شد",
|
"topupTitle": "موجودی شارژ شد",
|
||||||
@@ -1421,6 +1427,8 @@
|
|||||||
"inactive": "غیرفعال",
|
"inactive": "غیرفعال",
|
||||||
"copy": "کپی",
|
"copy": "کپی",
|
||||||
"copied": "کپی شد!",
|
"copied": "کپی شد!",
|
||||||
|
"botLink": "لینک ربات",
|
||||||
|
"webLink": "لینک وب",
|
||||||
"users": "کاربران",
|
"users": "کاربران",
|
||||||
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
||||||
"paid": "پرداخت کرده",
|
"paid": "پرداخت کرده",
|
||||||
|
|||||||
@@ -73,6 +73,12 @@
|
|||||||
"newUserReply": "Ответ пользователя в тикете: {{title}}",
|
"newUserReply": "Ответ пользователя в тикете: {{title}}",
|
||||||
"newUserReplyTitle": "Ответ пользователя"
|
"newUserReplyTitle": "Ответ пользователя"
|
||||||
},
|
},
|
||||||
|
"campaignBonus": {
|
||||||
|
"title": "Бонус активирован!",
|
||||||
|
"balance": "Вам начислено {{amount}} руб. по акции «{{name}}»",
|
||||||
|
"subscription": "Вам предоставлена подписка на {{days}} дн. по акции «{{name}}»",
|
||||||
|
"tariff": "Вам предоставлен тариф «{{tariff}}» по акции «{{name}}»"
|
||||||
|
},
|
||||||
"wsNotifications": {
|
"wsNotifications": {
|
||||||
"balance": {
|
"balance": {
|
||||||
"topupTitle": "Баланс пополнен",
|
"topupTitle": "Баланс пополнен",
|
||||||
@@ -2206,6 +2212,8 @@
|
|||||||
"inactive": "Неактивна",
|
"inactive": "Неактивна",
|
||||||
"copy": "Копировать",
|
"copy": "Копировать",
|
||||||
"copied": "Скопировано!",
|
"copied": "Скопировано!",
|
||||||
|
"botLink": "Ссылка для бота",
|
||||||
|
"webLink": "Веб-ссылка",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"noUsers": "Нет зарегистрированных пользователей",
|
"noUsers": "Нет зарегистрированных пользователей",
|
||||||
"paid": "Платил",
|
"paid": "Платил",
|
||||||
|
|||||||
@@ -64,6 +64,12 @@
|
|||||||
"newUserReply": "用户回复了工单:{{title}}",
|
"newUserReply": "用户回复了工单:{{title}}",
|
||||||
"newUserReplyTitle": "用户回复"
|
"newUserReplyTitle": "用户回复"
|
||||||
},
|
},
|
||||||
|
"campaignBonus": {
|
||||||
|
"title": "奖励已激活!",
|
||||||
|
"balance": "您从活动「{{name}}」获得了 {{amount}} 卢布",
|
||||||
|
"subscription": "您从活动「{{name}}」获得了 {{days}} 天订阅",
|
||||||
|
"tariff": "您从活动「{{name}}」获得了套餐「{{tariff}}」"
|
||||||
|
},
|
||||||
"wsNotifications": {
|
"wsNotifications": {
|
||||||
"balance": {
|
"balance": {
|
||||||
"topupTitle": "余额已充值",
|
"topupTitle": "余额已充值",
|
||||||
@@ -1420,6 +1426,8 @@
|
|||||||
"inactive": "未激活",
|
"inactive": "未激活",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"copied": "已复制!",
|
"copied": "已复制!",
|
||||||
|
"botLink": "机器人链接",
|
||||||
|
"webLink": "网页链接",
|
||||||
"users": "用户",
|
"users": "用户",
|
||||||
"noUsers": "没有注册用户",
|
"noUsers": "没有注册用户",
|
||||||
"paid": "已付费",
|
"paid": "已付费",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router';
|
import { useParams, useNavigate, Link } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '../i18n';
|
||||||
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
|
||||||
@@ -74,14 +75,18 @@ const bonusTypeConfig: Record<
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper functions
|
// 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 formatRubles = (kopeks: number): string => {
|
||||||
const rubles = kopeks / 100;
|
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 => {
|
const formatDate = (date: string | null): string => {
|
||||||
if (!date) return '-';
|
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',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -94,8 +99,18 @@ export default function AdminCampaignStats() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copiedBot, setCopiedBot] = useState(false);
|
||||||
|
const [copiedWeb, setCopiedWeb] = useState(false);
|
||||||
const [showUsers, setShowUsers] = 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
|
// Fetch stats
|
||||||
const {
|
const {
|
||||||
@@ -115,11 +130,29 @@ export default function AdminCampaignStats() {
|
|||||||
enabled: !!id && showUsers,
|
enabled: !!id && showUsers,
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyBotLink = async () => {
|
||||||
if (stats?.deep_link) {
|
if (stats?.deep_link) {
|
||||||
navigator.clipboard.writeText(stats.deep_link);
|
try {
|
||||||
setCopied(true);
|
await navigator.clipboard.writeText(stats.deep_link);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Deep Link */}
|
{/* Links */}
|
||||||
{stats.deep_link && (
|
{(stats.deep_link || stats.web_link) && (
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
{stats.deep_link && (
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
<LinkIcon />
|
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||||
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
|
{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>
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={copyLink}
|
{stats.web_link && (
|
||||||
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"
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
>
|
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||||
<CopyIcon />
|
{t('admin.campaigns.stats.webLink')}
|
||||||
<span className="text-sm">
|
</div>
|
||||||
{copied ? t('admin.campaigns.stats.copied') : t('admin.campaigns.stats.copy')}
|
<div className="flex items-center justify-between gap-2">
|
||||||
</span>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
</button>
|
<LinkIcon />
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams, Link, useNavigate } from 'react-router';
|
import { useSearchParams, Link, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { consumeCampaignSlug } from '../utils/campaign';
|
||||||
import { tokenStorage } from '../utils/token';
|
import { tokenStorage } from '../utils/token';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
|
|
||||||
@@ -13,8 +14,11 @@ export default function VerifyEmail() {
|
|||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
|
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
|
||||||
|
const hasVerified = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasVerified.current) return;
|
||||||
|
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -23,17 +27,24 @@ export default function VerifyEmail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasVerified.current = true;
|
||||||
|
let redirectTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const verify = async () => {
|
const verify = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.verifyEmail(token);
|
const campaignSlug = consumeCampaignSlug();
|
||||||
|
const response = await authApi.verifyEmail(token, campaignSlug);
|
||||||
// Save tokens and log user in
|
// Save tokens and log user in
|
||||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
setTokens(response.access_token, response.refresh_token);
|
setTokens(response.access_token, response.refresh_token);
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
|
if (response.campaign_bonus) {
|
||||||
|
useAuthStore.setState({ pendingCampaignBonus: response.campaign_bonus });
|
||||||
|
}
|
||||||
checkAdminStatus();
|
checkAdminStatus();
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
// Redirect to dashboard after short delay
|
// Redirect to dashboard after short delay
|
||||||
setTimeout(() => navigate('/', { replace: true }), 1500);
|
redirectTimer = setTimeout(() => navigate('/', { replace: true }), 1500);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
const error = err as { response?: { data?: { detail?: string } } };
|
const error = err as { response?: { data?: { detail?: string } } };
|
||||||
@@ -42,6 +53,8 @@ export default function VerifyEmail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
verify();
|
verify();
|
||||||
|
|
||||||
|
return () => clearTimeout(redirectTimer);
|
||||||
}, [searchParams, t, navigate, setTokens, setUser, checkAdminStatus]);
|
}, [searchParams, t, navigate, setTokens, setUser, checkAdminStatus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { RegisterResponse, User } from '../types';
|
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 { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token';
|
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token';
|
||||||
|
|
||||||
export interface TelegramWidgetData {
|
export interface TelegramWidgetData {
|
||||||
@@ -22,10 +23,12 @@ interface AuthState {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
pendingCampaignBonus: CampaignBonusInfo | null;
|
||||||
|
|
||||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||||
setUser: (user: User) => void;
|
setUser: (user: User) => void;
|
||||||
setIsAdmin: (isAdmin: boolean) => void;
|
setIsAdmin: (isAdmin: boolean) => void;
|
||||||
|
clearCampaignBonus: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
@@ -59,6 +62,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
pendingCampaignBonus: null,
|
||||||
|
|
||||||
|
clearCampaignBonus: () => set({ pendingCampaignBonus: null }),
|
||||||
|
|
||||||
setTokens: (accessToken, refreshToken) => {
|
setTokens: (accessToken, refreshToken) => {
|
||||||
tokenStorage.setTokens(accessToken, refreshToken);
|
tokenStorage.setTokens(accessToken, refreshToken);
|
||||||
@@ -235,49 +241,57 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegram: async (initData) => {
|
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);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
set({
|
set({
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token,
|
refreshToken: response.refresh_token,
|
||||||
user: response.user,
|
user: response.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
pendingCampaignBonus: response.campaign_bonus || null,
|
||||||
});
|
});
|
||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegramWidget: async (data) => {
|
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);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
set({
|
set({
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token,
|
refreshToken: response.refresh_token,
|
||||||
user: response.user,
|
user: response.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
pendingCampaignBonus: response.campaign_bonus || null,
|
||||||
});
|
});
|
||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithEmail: async (email, password) => {
|
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);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
set({
|
set({
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token,
|
refreshToken: response.refresh_token,
|
||||||
user: response.user,
|
user: response.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
pendingCampaignBonus: response.campaign_bonus || null,
|
||||||
});
|
});
|
||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithOAuth: async (provider, code, state) => {
|
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);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
set({
|
set({
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token,
|
refreshToken: response.refresh_token,
|
||||||
user: response.user,
|
user: response.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
pendingCampaignBonus: response.campaign_bonus || null,
|
||||||
});
|
});
|
||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
@@ -285,6 +299,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
registerWithEmail: async (email, password, firstName, referralCode) => {
|
registerWithEmail: async (email, password, firstName, referralCode) => {
|
||||||
// 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
|
||||||
const response = await authApi.registerEmailStandalone({
|
const response = await authApi.registerEmailStandalone({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -306,5 +321,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Capture campaign slug from URL before auth initialization
|
||||||
|
captureCampaignFromUrl();
|
||||||
|
|
||||||
// Initialize auth on app load
|
// Initialize auth on app load
|
||||||
useAuthStore.getState().initialize();
|
useAuthStore.getState().initialize();
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export interface OAuthProvider {
|
|||||||
display_name: string;
|
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
|
// Auth types
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -28,6 +37,7 @@ export interface AuthResponse {
|
|||||||
token_type: string;
|
token_type: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
user: User;
|
user: User;
|
||||||
|
campaign_bonus?: CampaignBonusInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
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