mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23: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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user