Merge branch 'main' from bedolaga

This commit is contained in:
PEDZEO
2026-01-18 07:02:14 +03:00
39 changed files with 5027 additions and 592 deletions

View File

@@ -81,6 +81,24 @@ const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
</svg>
)
const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
)
const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)
const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const ChevronRightIcon = () => (
<svg className="w-4 h-4 text-dark-500 group-hover:text-dark-300 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
@@ -230,6 +248,16 @@ export default function AdminPanel() {
bgColor: 'bg-violet-500/20',
textColor: 'text-violet-400'
},
{
to: '/admin/promo-offers',
icon: <PromoOffersIcon />,
mobileIcon: <PromoOffersIcon className="w-6 h-6" />,
title: t('admin.nav.promoOffers', 'Промопредложения'),
description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'),
color: 'orange',
bgColor: 'bg-orange-500/20',
textColor: 'text-orange-400'
},
{
to: '/admin/campaigns',
icon: <CampaignIcon />,
@@ -260,6 +288,26 @@ export default function AdminPanel() {
bgColor: 'bg-red-500/20',
textColor: 'text-red-400'
},
{
to: '/admin/payments',
icon: <PaymentsIcon />,
mobileIcon: <PaymentsIcon className="w-6 h-6" />,
title: t('admin.nav.payments', 'Платежи'),
description: t('admin.panel.paymentsDesc', 'Проверка платежей'),
color: 'lime',
bgColor: 'bg-lime-500/20',
textColor: 'text-lime-400'
},
{
to: '/admin/remnawave',
icon: <RemnawaveIcon />,
mobileIcon: <RemnawaveIcon className="w-6 h-6" />,
title: t('admin.nav.remnawave', 'RemnaWave'),
description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'),
color: 'purple',
bgColor: 'bg-purple-500/20',
textColor: 'text-purple-400'
},
]
return (

283
src/pages/AdminPayments.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { adminPaymentsApi } from '../api/adminPayments'
import { useCurrency } from '../hooks/useCurrency'
import type { PendingPayment, PaginatedResponse } from '../types'
export default function AdminPayments() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { formatAmount, currencySymbol } = useCurrency()
const [page, setPage] = useState(1)
const [methodFilter, setMethodFilter] = useState<string>('')
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null)
// Fetch payments
const { data: payments, isLoading, refetch } = useQuery<PaginatedResponse<PendingPayment>>({
queryKey: ['admin-payments', page, methodFilter],
queryFn: () => adminPaymentsApi.getPendingPayments({
page,
per_page: 20,
method_filter: methodFilter || undefined,
}),
refetchInterval: 30000, // Auto-refresh every 30 seconds
})
// Fetch stats
const { data: stats } = useQuery({
queryKey: ['admin-payments-stats'],
queryFn: adminPaymentsApi.getStats,
refetchInterval: 30000,
})
// Check payment mutation
const checkPaymentMutation = useMutation({
mutationFn: ({ method, paymentId }: { method: string; paymentId: number }) =>
adminPaymentsApi.checkPaymentStatus(method, paymentId),
onSuccess: async (result) => {
if (result.status_changed) {
await refetch()
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] })
} else {
await refetch()
}
},
onSettled: () => {
setCheckingPaymentId(null)
},
})
const handleCheckPayment = (payment: PendingPayment) => {
setCheckingPaymentId(`${payment.method}_${payment.id}`)
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id })
}
// Get unique methods from stats for filter
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</Link>
<div>
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title', 'Проверка платежей')}</h1>
<p className="text-sm text-dark-400">{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}</p>
</div>
</div>
<button
onClick={() => refetch()}
className="btn-secondary flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
{t('common.refresh', 'Обновить')}
</button>
</div>
{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<div className="p-4 rounded-xl bg-dark-800/50 border border-dark-700/50">
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
<div className="text-sm text-dark-400">{t('admin.payments.totalPending', 'Всего ожидает')}</div>
</div>
{Object.entries(stats.by_method).map(([method, count]) => (
<div
key={method}
className={`p-4 rounded-xl border cursor-pointer transition-all ${
methodFilter === method
? 'bg-accent-500/20 border-accent-500/50'
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
}`}
onClick={() => setMethodFilter(methodFilter === method ? '' : method)}
>
<div className="text-2xl font-bold text-dark-50">{count}</div>
<div className="text-sm text-dark-400">{method}</div>
</div>
))}
</div>
)}
{/* Filter */}
{methodOptions.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm text-dark-400">{t('admin.payments.filterByMethod', 'Фильтр по методу')}:</span>
<button
onClick={() => setMethodFilter('')}
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
methodFilter === ''
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{t('common.all', 'Все')}
</button>
{methodOptions.map((method) => (
<button
key={method}
onClick={() => setMethodFilter(method)}
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
methodFilter === method
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{method}
</button>
))}
</div>
)}
{/* Payments list */}
<div className="card">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : payments?.items && payments.items.length > 0 ? (
<div className="space-y-3">
{payments.items.map((payment) => {
const paymentKey = `${payment.method}_${payment.id}`
const isChecking = checkingPaymentId === paymentKey
return (
<div
key={paymentKey}
className="p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="font-semibold text-dark-100">{payment.method_display}</span>
<span className="text-sm px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-300">
{payment.status_emoji} {payment.status_text}
</span>
{payment.is_paid && (
<span className="text-sm px-2 py-0.5 rounded-full bg-success-500/20 text-success-400">
{t('admin.payments.paid', 'Оплачено')}
</span>
)}
</div>
<div className="text-lg font-semibold text-dark-50">
{formatAmount(payment.amount_rubles)} {currencySymbol}
</div>
<div className="text-sm text-dark-400 mt-1">
ID: <code className="text-dark-300">{payment.identifier}</code>
</div>
<div className="text-xs text-dark-500 mt-1">
{new Date(payment.created_at).toLocaleString()}
</div>
{/* User info */}
{(payment.user_username || payment.user_telegram_id) && (
<div className="text-sm text-dark-400 mt-2">
<span className="text-dark-500">{t('admin.payments.user', 'Пользователь')}:</span>{' '}
{payment.user_username ? (
<span className="text-dark-200">@{payment.user_username}</span>
) : (
<span className="text-dark-300">ID: {payment.user_telegram_id}</span>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{payment.payment_url && (
<a
href={payment.payment_url}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary text-xs px-3 py-1.5"
>
{t('admin.payments.openLink', 'Открыть ссылку')}
</a>
)}
{payment.is_checkable && (
<button
onClick={() => handleCheckPayment(payment)}
disabled={isChecking}
className="btn-primary text-xs px-3 py-1.5"
>
{isChecking ? (
<span className="flex items-center gap-1">
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('admin.payments.checking', 'Проверка...')}
</span>
) : (
t('admin.payments.checkStatus', 'Проверить статус')
)}
</button>
)}
</div>
</div>
{/* Show result after check */}
{checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && (
<div className={`mt-3 p-2 rounded-lg text-sm ${
checkPaymentMutation.data?.status_changed
? 'bg-success-500/10 border border-success-500/30 text-success-400'
: 'bg-dark-700/30 text-dark-400'
}`}>
{checkPaymentMutation.data?.message}
</div>
)}
</div>
)
})}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="text-dark-400">{t('admin.payments.noPayments', 'Нет ожидающих платежей')}</div>
</div>
)}
{/* Pagination */}
{payments && payments.pages > 1 && (
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
<button
type="button"
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={payments.page <= 1}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
payments.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.back', 'Назад')}
</button>
<div className="flex-1 text-center">
{t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })}
</div>
<button
type="button"
onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))}
disabled={payments.page >= payments.pages}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
payments.page >= payments.pages ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.next', 'Далее')}
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,786 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
promoOffersApi,
PromoOfferTemplate,
PromoOfferTemplateUpdateRequest,
PromoOfferLog,
TARGET_SEGMENTS,
TargetSegment,
OFFER_TYPE_CONFIG,
OfferType,
} from '../api/promoOffers'
// Icons
const GiftIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
)
const XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const SendIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
)
const ClockIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
const UserIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
// Helper functions
const formatDateTime = (date: string | null): string => {
if (!date) return '-'
return new Date(date).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const getActionLabel = (action: string): string => {
const labels: Record<string, string> = {
created: 'Создано',
claimed: 'Активировано',
consumed: 'Использовано',
disabled: 'Деактивировано',
}
return labels[action] || action
}
const getActionColor = (action: string): string => {
const colors: Record<string, string> = {
created: 'bg-blue-500/20 text-blue-400',
claimed: 'bg-emerald-500/20 text-emerald-400',
consumed: 'bg-purple-500/20 text-purple-400',
disabled: 'bg-dark-600 text-dark-400',
}
return colors[action] || 'bg-dark-600 text-dark-400'
}
const getOfferTypeIcon = (offerType: string): string => {
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁'
}
const getOfferTypeLabel = (offerType: string): string => {
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType
}
// Template Edit Modal
interface TemplateEditModalProps {
template: PromoOfferTemplate
onSave: (data: PromoOfferTemplateUpdateRequest) => void
onClose: () => void
isLoading?: boolean
}
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
const [name, setName] = useState(template.name)
const [messageText, setMessageText] = useState(template.message_text)
const [buttonText, setButtonText] = useState(template.button_text)
const [validHours, setValidHours] = useState(template.valid_hours)
const [discountPercent, setDiscountPercent] = useState(template.discount_percent)
const [activeDiscountHours, setActiveDiscountHours] = useState(template.active_discount_hours || 0)
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0)
const [isActive, setIsActive] = useState(template.is_active)
const isTestAccess = template.offer_type === 'test_access'
const handleSubmit = () => {
const data: PromoOfferTemplateUpdateRequest = {
name,
message_text: messageText,
button_text: buttonText,
valid_hours: validHours,
discount_percent: discountPercent,
is_active: isActive,
}
if (isTestAccess) {
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined
} else {
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined
}
onSave(data)
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-3">
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
<h2 className="text-lg font-semibold text-dark-100">
Редактирование шаблона
</h2>
</div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div>
<label className="block text-sm text-dark-300 mb-1">Название шаблона</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="block text-sm text-dark-300 mb-1">Текст сообщения</label>
<textarea
value={messageText}
onChange={e => setMessageText(e.target.value)}
rows={4}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
/>
</div>
<div>
<label className="block text-sm text-dark-300 mb-1">Текст кнопки</label>
<input
type="text"
value={buttonText}
onChange={e => setButtonText(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-dark-300 mb-1">Срок предложения (часы)</label>
<input
type="number"
value={validHours}
onChange={e => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
<p className="text-xs text-dark-500 mt-1">Время на активацию</p>
</div>
{!isTestAccess && (
<div>
<label className="block text-sm text-dark-300 mb-1">Скидка (%)</label>
<input
type="number"
value={discountPercent}
onChange={e => setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
max={100}
/>
</div>
)}
</div>
{isTestAccess ? (
<div>
<label className="block text-sm text-dark-300 mb-1">Длительность доступа (часы)</label>
<input
type="number"
value={testDurationHours}
onChange={e => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
/>
<p className="text-xs text-dark-500 mt-1">0 = по умолчанию</p>
</div>
) : (
<div>
<label className="block text-sm text-dark-300 mb-1">Действие скидки (часы)</label>
<input
type="number"
value={activeDiscountHours}
onChange={e => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
/>
<p className="text-xs text-dark-500 mt-1">Сколько действует скидка после активации</p>
</div>
)}
<label className="flex items-center gap-3 cursor-pointer">
<button
type="button"
onClick={() => setIsActive(!isActive)}
className={`w-10 h-6 rounded-full transition-colors relative ${
isActive ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
isActive ? 'left-5' : 'left-1'
}`}
/>
</button>
<span className="text-sm text-dark-200">Шаблон активен</span>
</label>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
Отмена
</button>
<button
onClick={handleSubmit}
disabled={!name.trim() || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)
}
// Send Offer Modal
interface SendOfferModalProps {
templates: PromoOfferTemplate[]
onSend: (templateId: number, target: string | null, userId: number | null) => void
onClose: () => void
isLoading?: boolean
}
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(templates[0]?.id || null)
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment')
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active')
const [userId, setUserId] = useState('')
const selectedTemplate = templates.find(t => t.id === selectedTemplateId)
const activeTemplates = templates.filter(t => t.is_active)
const handleSubmit = () => {
if (!selectedTemplateId) return
if (sendMode === 'user') {
const id = parseInt(userId)
if (!id) return
onSend(selectedTemplateId, null, id)
} else {
onSend(selectedTemplateId, selectedTarget, null)
}
}
const isValid = () => {
if (!selectedTemplateId) return false
if (sendMode === 'user' && !userId.trim()) return false
return true
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg">
<SendIcon />
</div>
<h2 className="text-lg font-semibold text-dark-100">
Отправка промопредложения
</h2>
</div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Template Selection */}
<div>
<label className="block text-sm text-dark-300 mb-2">Шаблон предложения</label>
<div className="space-y-2">
{activeTemplates.map(template => (
<button
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedTemplateId === template.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-600 bg-dark-700 hover:border-dark-500'
}`}
>
<div className="flex items-center gap-3">
<span className="text-xl">{getOfferTypeIcon(template.offer_type)}</span>
<div className="flex-1">
<div className="font-medium text-dark-100">{template.name}</div>
<div className="text-sm text-dark-400">
{template.discount_percent > 0 && `${template.discount_percent}% скидка`}
{template.offer_type === 'test_access' && 'Тестовый доступ'}
<span className="mx-1"></span>
{template.valid_hours}ч на активацию
</div>
</div>
{selectedTemplateId === template.id && (
<CheckIcon />
)}
</div>
</button>
))}
</div>
</div>
{/* Send Mode */}
<div>
<label className="block text-sm text-dark-300 mb-2">Кому отправить</label>
<div className="flex gap-2 mb-3">
<button
onClick={() => setSendMode('segment')}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
sendMode === 'segment'
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 text-dark-400 hover:text-dark-200'
}`}
>
<UsersIcon />
<span className="ml-2">Сегмент</span>
</button>
<button
onClick={() => setSendMode('user')}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
sendMode === 'user'
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 text-dark-400 hover:text-dark-200'
}`}
>
<UserIcon />
<span className="ml-2">Пользователь</span>
</button>
</div>
{sendMode === 'segment' ? (
<select
value={selectedTarget}
onChange={e => setSelectedTarget(e.target.value as TargetSegment)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
>
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
) : (
<input
type="text"
value={userId}
onChange={e => setUserId(e.target.value)}
placeholder="Telegram ID или User ID"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
/>
)}
</div>
{/* Preview */}
{selectedTemplate && (
<div className="p-4 bg-dark-700/50 rounded-lg">
<h4 className="text-sm font-medium text-dark-300 mb-2">Предпросмотр</h4>
<div className="text-sm text-dark-200 whitespace-pre-wrap">
{selectedTemplate.message_text}
</div>
<div className="mt-3">
<span className="inline-block px-3 py-1.5 bg-accent-500 text-white text-sm rounded-lg">
{selectedTemplate.button_text}
</span>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
Отмена
</button>
<button
onClick={handleSubmit}
disabled={!isValid() || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<SendIcon />
{isLoading ? 'Отправка...' : 'Отправить'}
</button>
</div>
</div>
</div>
)
}
// Result Modal
interface ResultModalProps {
title: string
message: string
isSuccess: boolean
onClose: () => void
}
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full text-center">
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
}`}>
{isSuccess ? (
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : (
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
<h3 className="text-lg font-semibold text-dark-100 mb-2">{title}</h3>
<p className="text-dark-400 mb-6">{message}</p>
<button
onClick={onClose}
className="px-6 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
>
Закрыть
</button>
</div>
</div>
)
}
export default function AdminPromoOffers() {
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates')
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null)
const [showSendModal, setShowSendModal] = useState(false)
const [resultModal, setResultModal] = useState<{ title: string; message: string; isSuccess: boolean } | null>(null)
// Queries
const { data: templatesData, isLoading: templatesLoading } = useQuery({
queryKey: ['admin-promo-templates'],
queryFn: promoOffersApi.getTemplates,
})
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['admin-promo-logs'],
queryFn: () => promoOffersApi.getLogs({ limit: 100 }),
enabled: activeTab === 'logs',
})
// Mutations
const updateTemplateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) =>
promoOffersApi.updateTemplate(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] })
setEditingTemplate(null)
},
})
const broadcastMutation = useMutation({
mutationFn: promoOffersApi.broadcastOffer,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] })
setShowSendModal(false)
setResultModal({
title: 'Отправлено!',
message: `Промопредложение отправлено ${result.created_offers} пользователям`,
isSuccess: true,
})
},
onError: (error: any) => {
setResultModal({
title: 'Ошибка',
message: error.response?.data?.detail || 'Не удалось отправить предложение',
isSuccess: false,
})
},
})
const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => {
const template = templatesData?.items.find(t => t.id === templateId)
if (!template) return
const data: any = {
notification_type: template.offer_type,
valid_hours: template.valid_hours,
discount_percent: template.discount_percent,
effect_type: template.offer_type === 'test_access' ? 'test_access' : 'percent_discount',
extra_data: {
template_id: template.id,
active_discount_hours: template.active_discount_hours,
test_duration_hours: template.test_duration_hours,
test_squad_uuids: template.test_squad_uuids,
},
}
if (target) {
data.target = target
}
if (userId) {
data.telegram_id = userId
}
broadcastMutation.mutate(data)
}
const templates = templatesData?.items || []
const logs = logsData?.items || []
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-500/20 rounded-lg text-orange-400">
<GiftIcon />
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">Промопредложения</h1>
<p className="text-sm text-dark-400">Шаблоны, рассылка и логи предложений</p>
</div>
</div>
<button
onClick={() => setShowSendModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
>
<SendIcon />
Отправить
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 p-1 bg-dark-800 rounded-lg w-fit">
<button
onClick={() => setActiveTab('templates')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'templates'
? 'bg-dark-700 text-dark-100'
: 'text-dark-400 hover:text-dark-200'
}`}
>
Шаблоны ({templates.length})
</button>
<button
onClick={() => setActiveTab('logs')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'logs'
? 'bg-dark-700 text-dark-100'
: 'text-dark-400 hover:text-dark-200'
}`}
>
Логи
</button>
</div>
{/* Templates Tab */}
{activeTab === 'templates' && (
<>
{templatesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : templates.length === 0 ? (
<div className="text-center py-12">
<p className="text-dark-400">Нет шаблонов</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{templates.map((template) => (
<div
key={template.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
<div>
<h3 className="font-medium text-dark-100">{template.name}</h3>
<span className="text-xs text-dark-500">{getOfferTypeLabel(template.offer_type)}</span>
</div>
</div>
<button
onClick={() => setEditingTemplate(template)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
>
<EditIcon />
</button>
</div>
<div className="space-y-2 text-sm">
{template.discount_percent > 0 && (
<div className="flex justify-between">
<span className="text-dark-400">Скидка:</span>
<span className="text-accent-400 font-medium">{template.discount_percent}%</span>
</div>
)}
<div className="flex justify-between">
<span className="text-dark-400">Срок предложения:</span>
<span className="text-dark-200">{template.valid_hours}ч</span>
</div>
{template.active_discount_hours && (
<div className="flex justify-between">
<span className="text-dark-400">Действие скидки:</span>
<span className="text-dark-200">{template.active_discount_hours}ч</span>
</div>
)}
{template.test_duration_hours && (
<div className="flex justify-between">
<span className="text-dark-400">Тестовый доступ:</span>
<span className="text-dark-200">{template.test_duration_hours}ч</span>
</div>
)}
</div>
<div className="mt-3 pt-3 border-t border-dark-700">
<div className="flex items-center gap-2">
{template.is_active ? (
<span className="px-2 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">
Активен
</span>
) : (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
Неактивен
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</>
)}
{/* Logs Tab */}
{activeTab === 'logs' && (
<>
{logsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : logs.length === 0 ? (
<div className="text-center py-12">
<p className="text-dark-400">Нет записей</p>
</div>
) : (
<div className="space-y-3">
{logs.map((log: PromoOfferLog) => (
<div
key={log.id}
className="p-4 bg-dark-800 rounded-xl border border-dark-700"
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-dark-700 rounded-full flex items-center justify-center">
<UserIcon />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-dark-100">
{log.user?.full_name || log.user?.username || `User #${log.user_id}`}
</span>
<span className={`px-2 py-0.5 text-xs rounded ${getActionColor(log.action)}`}>
{getActionLabel(log.action)}
</span>
</div>
<div className="text-sm text-dark-400">
{log.source && (
<span>{getOfferTypeLabel(log.source)}</span>
)}
{log.percent && log.percent > 0 && (
<span className="ml-2 text-accent-400">{log.percent}%</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1 text-xs text-dark-500">
<ClockIcon />
{formatDateTime(log.created_at)}
</div>
</div>
</div>
))}
</div>
)}
</>
)}
{/* Template Edit Modal */}
{editingTemplate && (
<TemplateEditModal
template={editingTemplate}
onSave={(data) => updateTemplateMutation.mutate({ id: editingTemplate.id, data })}
onClose={() => setEditingTemplate(null)}
isLoading={updateTemplateMutation.isPending}
/>
)}
{/* Send Offer Modal */}
{showSendModal && (
<SendOfferModal
templates={templates}
onSend={handleSendOffer}
onClose={() => setShowSendModal(false)}
isLoading={broadcastMutation.isPending}
/>
)}
{/* Result Modal */}
{resultModal && (
<ResultModal
title={resultModal.title}
message={resultModal.message}
isSuccess={resultModal.isSuccess}
onClose={() => setResultModal(null)}
/>
)}
</div>
)
}

1090
src/pages/AdminRemnawave.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import { balanceApi } from '../api/balance'
import { wheelApi } from '../api/wheel'
import ConnectionModal from '../components/ConnectionModal'
import Onboarding, { useOnboarding } from '../components/Onboarding'
import PromoOffersSection from '../components/PromoOffersSection'
import { useCurrency } from '../hooks/useCurrency'
// Icons
@@ -398,6 +399,9 @@ export default function Dashboard() {
</div>
)}
{/* Promo Offers */}
<PromoOffersSection />
{/* Fortune Wheel Banner */}
{wheelConfig?.is_enabled && (
<Link

View File

@@ -26,6 +26,19 @@ const appSchemes = [
const COUNTDOWN_SECONDS = 5
// Validate deep link to prevent javascript: and other dangerous schemes
const isValidDeepLink = (url: string): boolean => {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false
}
// Only allow known app schemes
return appSchemes.some(app => lowerUrl.startsWith(app.scheme))
}
export default function DeepLinkRedirect() {
const { i18n } = useTranslation()
const navigate = useNavigate()
@@ -104,13 +117,13 @@ export default function DeepLinkRedirect() {
// Open deep link - same as miniapp, just window.location.href
const openDeepLink = useCallback(() => {
if (!deepLink) return
if (!deepLink || !isValidDeepLink(deepLink)) return
window.location.href = deepLink
}, [deepLink])
// Countdown timer effect
useEffect(() => {
if (!deepLink) {
if (!deepLink || !isValidDeepLink(deepLink)) {
setStatus('error')
return
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DOMPurify from 'dompurify'
import { infoApi, FaqPage } from '../api/info'
const InfoIcon = () => (
@@ -41,6 +42,15 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
// Sanitize HTML content to prevent XSS
const sanitizeHtml = (html: string): string => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'u', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'span', 'div'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
})
}
// Convert plain text to HTML with proper formatting
const formatContent = (content: string): string => {
if (!content) return ''
@@ -49,11 +59,11 @@ const formatContent = (content: string): string => {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
if (hasHtmlTags) {
return content
return sanitizeHtml(content)
}
// Convert plain text to formatted HTML
return content
const result = content
.split(/\n\n+/) // Split by double newlines (paragraphs)
.map(paragraph => {
// Check if it's a header (starts with # or numeric like "1.")
@@ -83,6 +93,8 @@ const formatContent = (content: string): string => {
return `<p>${formattedParagraph}</p>`
})
.join('')
return sanitizeHtml(result)
}
export default function Info() {

View File

@@ -2,247 +2,341 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { referralApi } from '../api/referral'
import { brandingApi } from '../api/branding'
import { useCurrency } from '../hooks/useCurrency'
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
<svg
className='w-4 h-4'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={1.5}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184'
/>
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<svg
className='w-4 h-4'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={2}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M4.5 12.75l6 6 9-13.5'
/>
</svg>
)
const ShareIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8l5-5m0 0l5 5m-5-5v12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
</svg>
<svg
className='w-4 h-4'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={1.5}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M7 8l5-5m0 0l5 5m-5-5v12'
/>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3'
/>
</svg>
)
export default function Referral() {
const { t } = useTranslation()
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
const [copied, setCopied] = useState(false)
const { t } = useTranslation()
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
const [copied, setCopied] = useState(false)
const { data: info, isLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
})
const { data: info, isLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
})
const { data: terms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
})
const { data: terms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
})
const { data: referralList } = useQuery({
queryKey: ['referral-list'],
queryFn: () => referralApi.getReferralList({ per_page: 10 }),
})
const { data: referralList } = useQuery({
queryKey: ['referral-list'],
queryFn: () => referralApi.getReferralList({ per_page: 10 }),
})
const { data: earnings } = useQuery({
queryKey: ['referral-earnings'],
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
})
const { data: earnings } = useQuery({
queryKey: ['referral-earnings'],
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
})
const copyLink = () => {
if (info?.referral_link) {
navigator.clipboard.writeText(info.referral_link)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
})
const shareLink = () => {
if (!info?.referral_link) return
const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0,
})
const copyLink = () => {
if (info?.referral_link) {
navigator.clipboard.writeText(info.referral_link)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (navigator.share) {
navigator
.share({
title: t('referral.title'),
text: shareText,
url: info.referral_link,
})
.catch(() => {
// ignore cancellation errors
})
return
}
const shareLink = () => {
if (!info?.referral_link) return
const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0,
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
})
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
info.referral_link
)}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
}
if (navigator.share) {
navigator
.share({
title: t('referral.title'),
text: shareText,
url: info.referral_link,
})
.catch(() => {
// ignore cancellation errors
})
return
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
info.referral_link
)}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('referral.title')}</h1>
if (isLoading) {
return (
<div className='flex items-center justify-center min-h-64'>
<div className='w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin' />
</div>
)
}
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="card">
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div>
<div className="stat-value mt-1">{info?.total_referrals || 0}</div>
<div className="text-sm text-dark-500 mt-1">
{info?.active_referrals || 0} {t('referral.stats.activeReferrals').toLowerCase()}
</div>
</div>
<div className="card">
<div className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</div>
<div className="stat-value text-success-400 mt-1">
{formatPositive(info?.total_earnings_rubles || 0)}
</div>
</div>
<div className="card col-span-2 sm:col-span-1">
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
<div className="stat-value text-accent-400 mt-1">
{info?.commission_percent || 0}%
</div>
</div>
</div>
return (
<div className='space-y-6'>
<h1 className='text-2xl sm:text-3xl font-bold text-dark-50'>
{t('referral.title')}
</h1>
{/* Referral Link */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourLink')}</h2>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
readOnly
value={info?.referral_link || ''}
className="input flex-1"
/>
<div className="flex gap-2">
<button
onClick={copyLink}
disabled={!info?.referral_link}
className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
<span className="ml-2">{copied ? t('referral.copied') : t('referral.copyLink')}</span>
</button>
<button
onClick={shareLink}
disabled={!info?.referral_link}
className={`btn-secondary px-5 flex items-center ${
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<ShareIcon />
<span className="ml-2">{t('referral.shareButton')}</span>
</button>
</div>
</div>
<p className="mt-3 text-sm text-dark-500">
{t('referral.shareHint', { percent: info?.commission_percent || 0 })}
</p>
</div>
{/* Stats Cards */}
<div className='grid grid-cols-2 sm:grid-cols-3 gap-4'>
<div className='card'>
<div className='text-sm text-dark-400'>
{t('referral.stats.totalReferrals')}
</div>
<div className='stat-value mt-1'>{info?.total_referrals || 0}</div>
<div className='text-sm text-dark-500 mt-1'>
{info?.active_referrals || 0}{' '}
{t('referral.stats.activeReferrals').toLowerCase()}
</div>
</div>
<div className='card'>
<div className='text-sm text-dark-400'>
{t('referral.stats.totalEarnings')}
</div>
<div className='stat-value text-success-400 mt-1'>
{formatPositive(info?.total_earnings_rubles || 0)}
</div>
</div>
<div className='card col-span-2 sm:col-span-1'>
<div className='text-sm text-dark-400'>
{t('referral.stats.commissionRate')}
</div>
<div className='stat-value text-accent-400 mt-1'>
{info?.commission_percent || 0}%
</div>
</div>
</div>
{/* Program Terms */}
{terms && (
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.terms.title')}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
<div className="text-lg font-semibold text-dark-100 mt-1">{terms.commission_percent}%</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
<div className="text-lg font-semibold text-dark-100 mt-1">{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.first_topup_bonus_rubles)}</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.inviter_bonus_rubles)}</div>
</div>
</div>
</div>
)}
{/* Referral Link */}
<div className='card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.yourLink')}
</h2>
<div className='flex flex-col gap-2 sm:flex-row'>
<input
type='text'
readOnly
value={info?.referral_link || ''}
className='input flex-1'
/>
<div className='flex gap-2'>
<button
onClick={copyLink}
disabled={!info?.referral_link}
className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
<span className='ml-2'>
{copied ? t('referral.copied') : t('referral.copyLink')}
</span>
</button>
<button
onClick={shareLink}
disabled={!info?.referral_link}
className={`btn-secondary px-5 flex items-center ${
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<ShareIcon />
<span className='ml-2'>{t('referral.shareButton')}</span>
</button>
</div>
</div>
<p className='mt-3 text-sm text-dark-500'>
{t('referral.shareHint', { percent: info?.commission_percent || 0 })}
</p>
</div>
{/* Referrals List */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourReferrals')}</h2>
{referralList?.items && referralList.items.length > 0 ? (
<div className="space-y-3">
{referralList.items.map((ref) => (
<div
key={ref.id}
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div>
<div className="text-dark-100 font-medium">
{ref.first_name || ref.username || `User #${ref.id}`}
</div>
<div className="text-xs text-dark-500 mt-0.5">
{new Date(ref.created_at).toLocaleDateString()}
</div>
</div>
{ref.has_paid ? (
<span className="badge-success">{t('referral.status.paid')}</span>
) : (
<span className="badge-neutral">{t('referral.status.pending')}</span>
)}
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</div>
<div className="text-dark-400">{t('referral.noReferrals')}</div>
</div>
)}
</div>
{/* Program Terms */}
{terms && (
<div className='card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.terms.title')}
</h2>
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
<div className='p-3 rounded-xl bg-dark-800/30'>
<div className='text-sm text-dark-500'>
{t('referral.terms.commission')}
</div>
<div className='text-lg font-semibold text-dark-100 mt-1'>
{terms.commission_percent}%
</div>
</div>
<div className='p-3 rounded-xl bg-dark-800/30'>
<div className='text-sm text-dark-500'>
{t('referral.terms.minTopup')}
</div>
<div className='text-lg font-semibold text-dark-100 mt-1'>
{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}
</div>
</div>
<div className='p-3 rounded-xl bg-dark-800/30'>
<div className='text-sm text-dark-500'>
{t('referral.terms.newUserBonus')}
</div>
<div className='text-lg font-semibold text-success-400 mt-1'>
{formatPositive(terms.first_topup_bonus_rubles)}
</div>
</div>
<div className='p-3 rounded-xl bg-dark-800/30'>
<div className='text-sm text-dark-500'>
{t('referral.terms.inviterBonus')}
</div>
<div className='text-lg font-semibold text-success-400 mt-1'>
{formatPositive(terms.inviter_bonus_rubles)}
</div>
</div>
</div>
</div>
)}
{/* Earnings History */}
{earnings?.items && earnings.items.length > 0 && (
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.earningsHistory')}</h2>
<div className="space-y-3">
{earnings.items.map((earning) => (
<div
key={earning.id}
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div>
<div className="text-dark-100">
{earning.referral_first_name || earning.referral_username || 'Referral'}
</div>
<div className="text-xs text-dark-500 mt-0.5">
{t(`referral.reasons.${earning.reason}`, earning.reason)} {new Date(earning.created_at).toLocaleDateString()}
</div>
</div>
<div className="text-success-400 font-semibold">
{formatPositive(earning.amount_rubles)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
{/* Referrals List */}
<div className='card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.yourReferrals')}
</h2>
{referralList?.items && referralList.items.length > 0 ? (
<div className='space-y-3'>
{referralList.items.map(ref => (
<div
key={ref.id}
className='flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30'
>
<div>
<div className='text-dark-100 font-medium'>
{ref.first_name || ref.username || `User #${ref.id}`}
</div>
<div className='text-xs text-dark-500 mt-0.5'>
{new Date(ref.created_at).toLocaleDateString()}
</div>
</div>
{ref.has_paid ? (
<span className='badge-success'>
{t('referral.status.paid')}
</span>
) : (
<span className='badge-neutral'>
{t('referral.status.pending')}
</span>
)}
</div>
))}
</div>
) : (
<div className='text-center py-12'>
<div className='w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center'>
<svg
className='w-8 h-8 text-dark-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={1.5}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z'
/>
</svg>
</div>
<div className='text-dark-400'>{t('referral.noReferrals')}</div>
</div>
)}
</div>
{/* Earnings History */}
{earnings?.items && earnings.items.length > 0 && (
<div className='card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.earningsHistory')}
</h2>
<div className='space-y-3'>
{earnings.items.map(earning => (
<div
key={earning.id}
className='flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30'
>
<div>
<div className='text-dark-100'>
{earning.referral_first_name ||
earning.referral_username ||
'Referral'}
</div>
<div className='text-xs text-dark-500 mt-0.5'>
{t(`referral.reasons.${earning.reason}`, earning.reason)} {' '}
{new Date(earning.created_at).toLocaleDateString()}
</div>
</div>
<div className='text-success-400 font-semibold'>
{formatPositive(earning.amount_rubles)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import { AxiosError } from 'axios'
import { subscriptionApi } from '../api/subscription'
import { promoApi } from '../api/promo'
import type { PurchaseSelection, PeriodOption, Tariff, TariffPeriod, ClassicPurchaseOptions } from '../types'
import ConnectionModal from '../components/ConnectionModal'
import { useCurrency } from '../hooks/useCurrency'
@@ -55,6 +56,24 @@ export default function Subscription() {
// Helper to format price from kopeks
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`
// Helper to apply promo discount to a price
const applyPromoDiscount = (priceKopeks: number, hasExistingDiscount: boolean = false): {
price: number;
original: number | null;
percent: number | null
} => {
// Only apply promo discount if no existing discount (from promo group) and we have an active promo discount
if (!activeDiscount?.is_active || !activeDiscount.discount_percent || hasExistingDiscount) {
return { price: priceKopeks, original: null, percent: null }
}
const discountedPrice = Math.round(priceKopeks * (1 - activeDiscount.discount_percent / 100))
return {
price: discountedPrice,
original: priceKopeks,
percent: activeDiscount.discount_percent
}
}
// Purchase state (classic mode)
const [currentStep, setCurrentStep] = useState<PurchaseStep>('period')
const [selectedPeriod, setSelectedPeriod] = useState<PeriodOption | null>(null)
@@ -91,6 +110,13 @@ export default function Subscription() {
queryFn: subscriptionApi.getPurchaseOptions,
})
// Fetch active promo discount
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
staleTime: 30000,
})
// Check if in tariffs mode (moved up to be available for useEffect)
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'
const classicOptions = !isTariffsMode ? purchaseOptions as ClassicPurchaseOptions : null
@@ -275,18 +301,20 @@ export default function Subscription() {
// Auto-scroll to switch tariff modal when it appears
useEffect(() => {
if (switchTariffId && switchModalRef.current) {
setTimeout(() => {
const timer = setTimeout(() => {
switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
return () => clearTimeout(timer)
}
}, [switchTariffId])
// Auto-scroll to tariff purchase form when it appears
useEffect(() => {
if (showTariffPurchase && tariffPurchaseRef.current) {
setTimeout(() => {
const timer = setTimeout(() => {
tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
return () => clearTimeout(timer)
}
}, [showTariffPurchase])
@@ -294,11 +322,12 @@ export default function Subscription() {
useEffect(() => {
const state = location.state as { scrollToExtend?: boolean } | null
if (state?.scrollToExtend && tariffsCardRef.current) {
setTimeout(() => {
const timer = setTimeout(() => {
tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 300)
// Clear the state to prevent re-scrolling on subsequent renders
window.history.replaceState({}, document.title)
return () => clearTimeout(timer)
}
}, [location.state])
@@ -1116,16 +1145,25 @@ export default function Subscription() {
// Daily tariff price (is_daily + daily_price_kopeks)
const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0
const originalDailyPrice = tariff.original_daily_price_kopeks || 0
const hasExistingDailyDiscount = originalDailyPrice > dailyPrice
if (dailyPrice > 0) {
// Apply promo discount if no existing discount
const promoDaily = applyPromoDiscount(dailyPrice, hasExistingDailyDiscount)
return (
<span className="flex items-center gap-2">
<span className="text-accent-400 font-medium">{formatPrice(dailyPrice)}</span>
{originalDailyPrice > dailyPrice && (
<span className="text-dark-500 text-xs line-through">{formatPrice(originalDailyPrice)}</span>
<span className="text-accent-400 font-medium">{formatPrice(promoDaily.price)}</span>
{/* Show original price from promo group or promo offer */}
{(hasExistingDailyDiscount || promoDaily.original) && (
<span className="text-dark-500 text-xs line-through">
{formatPrice(hasExistingDailyDiscount ? originalDailyPrice : promoDaily.original!)}
</span>
)}
<span>/ день</span>
{tariff.daily_discount_percent && tariff.daily_discount_percent > 0 && (
{/* Show discount badge */}
{(tariff.daily_discount_percent && tariff.daily_discount_percent > 0) ? (
<span className="px-1.5 py-0.5 bg-success-500/20 text-success-400 text-xs rounded">-{tariff.daily_discount_percent}%</span>
) : promoDaily.percent && (
<span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">-{promoDaily.percent}%</span>
)}
</span>
)
@@ -1133,18 +1171,24 @@ export default function Subscription() {
// Period-based price
if (tariff.periods.length > 0) {
const firstPeriod = tariff.periods[0]
const hasDiscount = firstPeriod?.original_price_kopeks && firstPeriod.original_price_kopeks > firstPeriod.price_kopeks
const hasExistingDiscount = !!(firstPeriod?.original_price_kopeks && firstPeriod.original_price_kopeks > firstPeriod.price_kopeks)
// Apply promo discount if no existing discount
const promoPeriod = applyPromoDiscount(firstPeriod?.price_kopeks || 0, hasExistingDiscount)
return (
<span className="flex items-center gap-2 flex-wrap">
<span>{t('subscription.from')}</span>
<span className="text-accent-400 font-medium">{formatPrice(firstPeriod?.price_kopeks || 0)}</span>
{hasDiscount && (
<>
<span className="text-dark-500 text-xs line-through">{formatPrice(firstPeriod.original_price_kopeks!)}</span>
{firstPeriod.discount_percent && (
<span className="px-1.5 py-0.5 bg-success-500/20 text-success-400 text-xs rounded">-{firstPeriod.discount_percent}%</span>
)}
</>
<span className="text-accent-400 font-medium">{formatPrice(promoPeriod.price)}</span>
{/* Show original price from promo group or promo offer */}
{(hasExistingDiscount || promoPeriod.original) && (
<span className="text-dark-500 text-xs line-through">
{formatPrice(hasExistingDiscount ? firstPeriod.original_price_kopeks! : promoPeriod.original!)}
</span>
)}
{/* Show discount badge */}
{hasExistingDiscount && firstPeriod.discount_percent ? (
<span className="px-1.5 py-0.5 bg-success-500/20 text-success-400 text-xs rounded">-{firstPeriod.discount_percent}%</span>
) : promoPeriod.percent && (
<span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">-{promoPeriod.percent}%</span>
)}
</span>
)
@@ -1330,34 +1374,47 @@ export default function Subscription() {
{/* Fixed periods */}
{selectedTariff.periods.length > 0 && !useCustomDays && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-4">
{selectedTariff.periods.map((period) => (
<button
key={period.days}
onClick={() => {
setSelectedTariffPeriod(period)
setUseCustomDays(false)
}}
className={`p-4 rounded-xl border text-left transition-all relative ${
selectedTariffPeriod?.days === period.days && !useCustomDays
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
}`}
>
{period.discount_percent && period.discount_percent > 0 && (
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-success-500 text-white text-xs font-medium rounded-full">
-{period.discount_percent}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
<div className="flex items-center gap-2">
<span className="text-accent-400 font-medium">{formatPrice(period.price_kopeks)}</span>
{period.original_price_kopeks && period.original_price_kopeks > period.price_kopeks && (
<span className="text-dark-500 text-sm line-through">{formatPrice(period.original_price_kopeks)}</span>
{selectedTariff.periods.map((period) => {
const hasExistingDiscount = !!(period.original_price_kopeks && period.original_price_kopeks > period.price_kopeks)
const promoPeriod = applyPromoDiscount(period.price_kopeks, hasExistingDiscount)
const displayDiscount = hasExistingDiscount ? period.discount_percent : promoPeriod.percent
const displayOriginal = hasExistingDiscount ? period.original_price_kopeks : promoPeriod.original
const displayPrice = promoPeriod.price
const displayPerMonth = hasExistingDiscount
? period.price_per_month_kopeks
: Math.round(promoPeriod.price / (period.days / 30))
return (
<button
key={period.days}
onClick={() => {
setSelectedTariffPeriod(period)
setUseCustomDays(false)
}}
className={`p-4 rounded-xl border text-left transition-all relative ${
selectedTariffPeriod?.days === period.days && !useCustomDays
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
}`}
>
{displayDiscount && displayDiscount > 0 && (
<div className={`absolute -top-2 -right-2 px-2 py-0.5 text-white text-xs font-medium rounded-full ${
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
}`}>
-{displayDiscount}%
</div>
)}
</div>
<div className="text-xs text-dark-500 mt-1">{formatPrice(period.price_per_month_kopeks)}/{t('subscription.month')}</div>
</button>
))}
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
<div className="flex items-center gap-2">
<span className="text-accent-400 font-medium">{formatPrice(displayPrice)}</span>
{displayOriginal && displayOriginal > displayPrice && (
<span className="text-dark-500 text-sm line-through">{formatPrice(displayOriginal)}</span>
)}
</div>
<div className="text-xs text-dark-500 mt-1">{formatPrice(displayPerMonth)}/{t('subscription.month')}</div>
</button>
)
})}
</div>
)}
@@ -1400,10 +1457,25 @@ export default function Subscription() {
className="w-20 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 text-center"
/>
</div>
<div className="flex justify-between text-sm">
<span className="text-dark-400">{customDays} дней × {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/день</span>
<span className="text-accent-400 font-medium">{formatPrice(customDays * (selectedTariff.price_per_day_kopeks ?? 0))}</span>
</div>
{(() => {
const basePrice = customDays * (selectedTariff.price_per_day_kopeks ?? 0)
const hasExistingDiscount = !!(selectedTariff.original_price_per_day_kopeks && selectedTariff.original_price_per_day_kopeks > (selectedTariff.price_per_day_kopeks ?? 0))
const promoCustom = applyPromoDiscount(basePrice, hasExistingDiscount)
return (
<div className="flex justify-between text-sm">
<span className="text-dark-400">{customDays} дней × {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/день</span>
<div className="flex items-center gap-2">
<span className="text-accent-400 font-medium">{formatPrice(promoCustom.price)}</span>
{promoCustom.original && (
<>
<span className="text-dark-500 text-xs line-through">{formatPrice(promoCustom.original)}</span>
<span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">-{promoCustom.percent}%</span>
</>
)}
</div>
</div>
)
})()}
</div>
)}
</div>
@@ -1472,42 +1544,76 @@ export default function Subscription() {
{/* Summary & Purchase */}
{(selectedTariffPeriod || useCustomDays) && (
<div className="bg-dark-800/50 rounded-xl p-5">
{/* Price breakdown */}
<div className="space-y-2 mb-4">
{useCustomDays ? (
<div className="flex justify-between text-sm text-dark-300">
<span>Период: {customDays} дней</span>
<span>{formatPrice(customDays * (selectedTariff.price_per_day_kopeks ?? 0))}</span>
</div>
) : selectedTariffPeriod && (
<div className="flex justify-between text-sm text-dark-300">
<span>Период: {selectedTariffPeriod.label}</span>
<span>{formatPrice(selectedTariffPeriod.price_kopeks)}</span>
</div>
)}
{useCustomTraffic && selectedTariff.custom_traffic_enabled && (
<div className="flex justify-between text-sm text-dark-300">
<span>Трафик: {customTrafficGb} ГБ</span>
<span>+{formatPrice(customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0))}</span>
</div>
)}
</div>
{(() => {
const periodPrice = useCustomDays
// Calculate prices with promo discount
const basePeriodPrice = useCustomDays
? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
: (selectedTariffPeriod?.price_kopeks || 0)
const hasExistingPeriodDiscount = !useCustomDays && selectedTariffPeriod?.original_price_kopeks
? selectedTariffPeriod.original_price_kopeks > selectedTariffPeriod.price_kopeks
: false
const promoPeriod = applyPromoDiscount(basePeriodPrice, hasExistingPeriodDiscount)
const trafficPrice = useCustomTraffic && selectedTariff.custom_traffic_enabled
? customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0)
: 0
const totalPrice = periodPrice + trafficPrice
const totalPrice = promoPeriod.price + trafficPrice
const originalTotal = promoPeriod.original ? promoPeriod.original + trafficPrice : null
const hasEnoughBalance = purchaseOptions && totalPrice <= purchaseOptions.balance_kopeks
return (
<>
{/* Price breakdown */}
<div className="space-y-2 mb-4">
{useCustomDays ? (
<div className="flex justify-between text-sm text-dark-300">
<span>Период: {customDays} дней</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{promoPeriod.original && (
<span className="text-dark-500 text-xs line-through">{formatPrice(promoPeriod.original)}</span>
)}
</div>
</div>
) : selectedTariffPeriod && (
<div className="flex justify-between text-sm text-dark-300">
<span>Период: {selectedTariffPeriod.label}</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{(hasExistingPeriodDiscount || promoPeriod.original) && (
<span className="text-dark-500 text-xs line-through">
{formatPrice(hasExistingPeriodDiscount ? selectedTariffPeriod.original_price_kopeks! : promoPeriod.original!)}
</span>
)}
</div>
</div>
)}
{useCustomTraffic && selectedTariff.custom_traffic_enabled && (
<div className="flex justify-between text-sm text-dark-300">
<span>Трафик: {customTrafficGb} ГБ</span>
<span>+{formatPrice(trafficPrice)}</span>
</div>
)}
</div>
{/* Promo discount info */}
{promoPeriod.percent && (
<div className="flex items-center justify-center gap-2 mb-4 p-2 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<span className="text-orange-400 text-sm font-medium">
{t('promo.discountApplied')} -{promoPeriod.percent}%
</span>
</div>
)}
<div className="flex justify-between items-center mb-4 pt-2 border-t border-dark-700/50">
<span className="text-dark-100 font-medium">{t('subscription.total')}</span>
<span className="text-2xl font-bold text-accent-400">{formatPrice(totalPrice)}</span>
<div className="text-right">
<span className="text-2xl font-bold text-accent-400">{formatPrice(totalPrice)}</span>
{originalTotal && (
<div className="text-sm text-dark-500 line-through">{formatPrice(originalTotal)}</div>
)}
</div>
</div>
{purchaseOptions && !hasEnoughBalance && (
@@ -1593,93 +1699,139 @@ export default function Subscription() {
{/* Step: Period Selection */}
{currentStep === 'period' && classicOptions && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{classicOptions.periods.map((period) => (
<button
key={period.id}
onClick={() => {
setSelectedPeriod(period)
if (period.traffic.current !== undefined) {
setSelectedTraffic(period.traffic.current)
}
if (period.servers.selected) {
setSelectedServers(period.servers.selected)
}
if (period.devices.current) {
setSelectedDevices(period.devices.current)
}
}}
className={`p-4 rounded-xl border text-left transition-all ${
selectedPeriod?.id === period.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
}`}
>
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
<div className="text-accent-400 font-medium">{formatPrice(period.price_kopeks)}</div>
{(period.discount_percent ?? 0) > 0 && (
<span className="badge-success text-xs mt-2 inline-block">-{period.discount_percent}%</span>
)}
</button>
))}
{classicOptions.periods.map((period) => {
const hasExistingDiscount = !!(period.discount_percent && period.discount_percent > 0)
const promoPeriod = applyPromoDiscount(period.price_kopeks, hasExistingDiscount)
const displayDiscount = hasExistingDiscount ? period.discount_percent : promoPeriod.percent
const displayOriginal = hasExistingDiscount ? period.original_price_kopeks : promoPeriod.original
return (
<button
key={period.id}
onClick={() => {
setSelectedPeriod(period)
if (period.traffic.current !== undefined) {
setSelectedTraffic(period.traffic.current)
}
if (period.servers.selected) {
setSelectedServers(period.servers.selected)
}
if (period.devices.current) {
setSelectedDevices(period.devices.current)
}
}}
className={`p-4 rounded-xl border text-left transition-all relative ${
selectedPeriod?.id === period.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
}`}
>
{displayDiscount && displayDiscount > 0 && (
<div className={`absolute -top-2 -right-2 px-2 py-0.5 text-white text-xs font-medium rounded-full ${
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
}`}>
-{displayDiscount}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
<div className="flex items-center gap-2">
<span className="text-accent-400 font-medium">{formatPrice(promoPeriod.price)}</span>
{displayOriginal && displayOriginal > promoPeriod.price && (
<span className="text-dark-500 text-sm line-through">{formatPrice(displayOriginal)}</span>
)}
</div>
</button>
)
})}
</div>
)}
{/* Step: Traffic Selection */}
{currentStep === 'traffic' && selectedPeriod?.traffic.options && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{selectedPeriod.traffic.options.map((option) => (
<button
key={option.value}
onClick={() => setSelectedTraffic(option.value)}
disabled={!option.is_available}
className={`p-4 rounded-xl border text-center transition-all ${
selectedTraffic === option.value
? 'border-accent-500 bg-accent-500/10'
: option.is_available
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
}`}
>
<div className="text-lg font-semibold text-dark-100">{option.label}</div>
<div className="text-accent-400">{formatPrice(option.price_kopeks)}</div>
</button>
))}
{selectedPeriod.traffic.options.map((option) => {
const hasExistingDiscount = !!(option.discount_percent && option.discount_percent > 0)
const promoTraffic = applyPromoDiscount(option.price_kopeks, hasExistingDiscount)
return (
<button
key={option.value}
onClick={() => setSelectedTraffic(option.value)}
disabled={!option.is_available}
className={`p-4 rounded-xl border text-center transition-all relative ${
selectedTraffic === option.value
? 'border-accent-500 bg-accent-500/10'
: option.is_available
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
}`}
>
{promoTraffic.percent && (
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-orange-500 text-white text-xs font-medium rounded-full">
-{promoTraffic.percent}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">{option.label}</div>
<div className="flex items-center justify-center gap-2">
<span className="text-accent-400">{formatPrice(promoTraffic.price)}</span>
{promoTraffic.original && (
<span className="text-dark-500 text-xs line-through">{formatPrice(promoTraffic.original)}</span>
)}
</div>
</button>
)
})}
</div>
)}
{/* Step: Server Selection */}
{currentStep === 'servers' && selectedPeriod?.servers.options && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{selectedPeriod.servers.options.map((server) => (
<button
key={server.uuid}
onClick={() => toggleServer(server.uuid)}
disabled={!server.is_available}
className={`p-4 rounded-xl border text-left transition-all ${
selectedServers.includes(server.uuid)
? 'border-accent-500 bg-accent-500/10'
: server.is_available
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
{selectedPeriod.servers.options.map((server) => {
const hasExistingDiscount = !!(server.discount_percent && server.discount_percent > 0)
const promoServer = applyPromoDiscount(server.price_kopeks, hasExistingDiscount)
return (
<button
key={server.uuid}
onClick={() => toggleServer(server.uuid)}
disabled={!server.is_available}
className={`p-4 rounded-xl border text-left transition-all relative ${
selectedServers.includes(server.uuid)
? 'border-accent-500 bg-accent-500'
: 'border-dark-600'
}`}>
{selectedServers.includes(server.uuid) && (
<CheckIcon />
)}
? 'border-accent-500 bg-accent-500/10'
: server.is_available
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
}`}
>
{promoServer.percent && (
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-orange-500 text-white text-xs font-medium rounded-full">
-{promoServer.percent}%
</div>
)}
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
selectedServers.includes(server.uuid)
? 'border-accent-500 bg-accent-500'
: 'border-dark-600'
}`}>
{selectedServers.includes(server.uuid) && (
<CheckIcon />
)}
</div>
<div>
<div className="font-medium text-dark-100">{server.name}</div>
<div className="flex items-center gap-2">
<span className="text-sm text-accent-400">{formatPrice(promoServer.price)}</span>
{promoServer.original && (
<span className="text-dark-500 text-xs line-through">{formatPrice(promoServer.original)}</span>
)}
</div>
</div>
</div>
<div>
<div className="font-medium text-dark-100">{server.name}</div>
<div className="text-sm text-accent-400">{formatPrice(server.price_kopeks)}</div>
</div>
</div>
</button>
))}
</button>
)
})}
</div>
)}
@@ -1723,6 +1875,18 @@ export default function Subscription() {
</div>
) : preview ? (
<div className="bg-dark-800/50 rounded-xl p-5 space-y-4">
{/* Active promo discount banner */}
{activeDiscount?.is_active && activeDiscount.discount_percent && !preview.original_price_kopeks && (
<div className="flex items-center justify-center gap-2 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<svg className="w-4 h-4 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
<span className="text-orange-400 text-sm font-medium">
{t('promo.discountApplied')} -{activeDiscount.discount_percent}%
</span>
</div>
)}
{preview.breakdown.map((item, idx) => (
<div key={idx} className="flex justify-between text-dark-300">
<span>{item.label}</span>
@@ -1730,15 +1894,25 @@ export default function Subscription() {
</div>
))}
<div className="border-t border-dark-700/50 pt-4 flex justify-between items-center">
<span className="font-semibold text-dark-100 text-lg">{t('subscription.total')}</span>
<div className="text-right">
<div className="text-2xl font-bold text-accent-400">{formatPrice(preview.total_price_kopeks)}</div>
{preview.original_price_kopeks && (
<div className="text-sm text-dark-500 line-through">{formatPrice(preview.original_price_kopeks)}</div>
)}
</div>
</div>
{(() => {
// Apply promo discount if not already applied by server
const hasServerDiscount = !!preview.original_price_kopeks
const promoTotal = applyPromoDiscount(preview.total_price_kopeks, hasServerDiscount)
const displayTotal = promoTotal.price
const displayOriginal = hasServerDiscount ? preview.original_price_kopeks : promoTotal.original
return (
<div className="border-t border-dark-700/50 pt-4 flex justify-between items-center">
<span className="font-semibold text-dark-100 text-lg">{t('subscription.total')}</span>
<div className="text-right">
<div className="text-2xl font-bold text-accent-400">{formatPrice(displayTotal)}</div>
{displayOriginal && (
<div className="text-sm text-dark-500 line-through">{formatPrice(displayOriginal)}</div>
)}
</div>
</div>
)
})()}
{preview.discount_label && (
<div className="text-sm text-success-400 text-center">{preview.discount_label}</div>

View File

@@ -3,8 +3,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ticketsApi } from '../api/tickets'
import { infoApi } from '../api/info'
import { logger } from '../utils/logger'
import type { TicketDetail, TicketMessage } from '../types'
const log = logger.createLogger('Support')
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
@@ -120,7 +123,7 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
}
export default function Support() {
console.log('[Support] Component loaded - VERSION 2024-01-16-v3')
log.debug('Component loaded')
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -271,20 +274,20 @@ export default function Support() {
// If tickets are disabled, show redirect message
if (supportConfig && !supportConfig.tickets_enabled) {
console.log('[Support] Tickets disabled, config:', supportConfig)
log.debug('Tickets disabled, config:', supportConfig)
const getSupportMessage = () => {
console.log('[Support] Getting support message for type:', supportConfig.support_type)
log.debug('Getting support message for type:', supportConfig.support_type)
if (supportConfig.support_type === 'profile') {
const supportUsername = supportConfig.support_username || '@support'
console.log('[Support] Opening profile:', supportUsername)
log.debug('Opening profile:', supportUsername)
return {
title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
console.log('[Support] Button clicked, opening:', supportUsername)
log.debug('Button clicked, opening:', supportUsername)
const webApp = window.Telegram?.WebApp
// Extract username without @
@@ -292,41 +295,36 @@ export default function Support() {
? supportUsername.slice(1)
: supportUsername
// Try different URL formats
const telegramUrl = `tg://resolve?domain=${username}`
const webUrl = `https://t.me/${username}`
console.log('[Support] Telegram URL:', telegramUrl)
console.log('[Support] Web URL:', webUrl)
console.log('[Support] WebApp methods:', {
log.debug('Web URL:', webUrl, 'WebApp methods:', {
openTelegramLink: !!webApp?.openTelegramLink,
openLink: !!webApp?.openLink,
})
// Try openTelegramLink first (for tg:// links)
if (webApp?.openTelegramLink) {
console.log('[Support] Using openTelegramLink with web URL')
log.debug('Using openTelegramLink with web URL')
try {
webApp.openTelegramLink(webUrl)
return
} catch (e) {
console.error('[Support] openTelegramLink failed:', e)
log.error('openTelegramLink failed:', e)
}
}
// Fallback to openLink
if (webApp?.openLink) {
console.log('[Support] Using openLink')
log.debug('Using openLink')
try {
webApp.openLink(webUrl)
return
} catch (e) {
console.error('[Support] openLink failed:', e)
log.error('openLink failed:', e)
}
}
// Last resort - window.open
console.log('[Support] Using window.open')
log.debug('Using window.open')
window.open(webUrl, '_blank')
},
}
@@ -350,13 +348,13 @@ export default function Support() {
// Fallback: contact support (should not normally happen if config is correct)
const supportUsername = supportConfig.support_username || '@support'
console.log('[Support] Fallback: Opening profile:', supportUsername)
log.debug('Fallback: Opening profile:', supportUsername)
return {
title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
console.log('[Support] Fallback button clicked, opening:', supportUsername)
log.debug('Fallback button clicked, opening:', supportUsername)
const webApp = window.Telegram?.WebApp
// Extract username without @
@@ -365,16 +363,16 @@ export default function Support() {
: supportUsername
const webUrl = `https://t.me/${username}`
console.log('[Support] Fallback opening URL:', webUrl)
log.debug('Fallback opening URL:', webUrl)
if (webApp?.openTelegramLink) {
console.log('[Support] Fallback using openTelegramLink')
log.debug('Fallback using openTelegramLink')
webApp.openTelegramLink(webUrl)
} else if (webApp?.openLink) {
console.log('[Support] Fallback using openLink')
log.debug('Fallback using openLink')
webApp.openLink(webUrl)
} else {
console.log('[Support] Fallback using window.open')
log.debug('Fallback using window.open')
window.open(webUrl, '_blank')
}
},

View File

@@ -32,19 +32,27 @@ export default function TelegramCallback() {
return
}
// Parse and validate numeric fields
const parsedId = parseInt(id, 10)
const parsedAuthDate = parseInt(authDate, 10)
if (Number.isNaN(parsedId) || Number.isNaN(parsedAuthDate)) {
setError(t('auth.telegramRequired'))
return
}
try {
await loginWithTelegramWidget({
id: parseInt(id, 10),
id: parsedId,
first_name: firstName,
last_name: lastName || undefined,
username: username || undefined,
photo_url: photoUrl || undefined,
auth_date: parseInt(authDate, 10),
auth_date: parsedAuthDate,
hash: hash,
})
navigate('/')
} catch (err: unknown) {
console.error('Telegram auth error:', err)
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error'))
}

View File

@@ -5,6 +5,29 @@ import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { brandingApi } from '../api/branding'
// Validate redirect URL to prevent open redirect attacks
const getSafeRedirectUrl = (url: string | null): string => {
if (!url) return '/'
// Only allow relative paths starting with /
// Block protocol-relative URLs (//evil.com) and absolute URLs
if (!url.startsWith('/') || url.startsWith('//')) {
return '/'
}
// Additional check for encoded characters that could bypass validation
try {
const decoded = decodeURIComponent(url)
if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('://')) {
return '/'
}
} catch {
return '/'
}
return url
}
const MAX_RETRY_ATTEMPTS = 3
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count'
export default function TelegramRedirect() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -12,6 +35,10 @@ export default function TelegramRedirect() {
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
const [errorMessage, setErrorMessage] = useState('')
const [retryCount, setRetryCount] = useState(() => {
const stored = sessionStorage.getItem(RETRY_COUNT_KEY)
return stored ? parseInt(stored, 10) : 0
})
// Get branding for nice display
const { data: branding } = useQuery({
@@ -24,8 +51,8 @@ export default function TelegramRedirect() {
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Get redirect target from URL params
const redirectTo = searchParams.get('redirect') || '/'
// Get redirect target from URL params (validated)
const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'))
useEffect(() => {
// If already authenticated, redirect immediately
@@ -76,13 +103,28 @@ export default function TelegramRedirect() {
setTimeout(initTelegram, 300)
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
// Handle retry
// Handle retry with limit to prevent infinite loops
const handleRetry = () => {
if (retryCount >= MAX_RETRY_ATTEMPTS) {
setErrorMessage('Превышено количество попыток. Попробуйте позже.')
sessionStorage.removeItem(RETRY_COUNT_KEY)
return
}
const newCount = retryCount + 1
setRetryCount(newCount)
sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount))
setStatus('loading')
setErrorMessage('')
window.location.reload()
}
// Clear retry count on successful auth
useEffect(() => {
if (status === 'success') {
sessionStorage.removeItem(RETRY_COUNT_KEY)
}
}, [status])
return (
<div className="min-h-screen flex items-center justify-center p-4">
{/* Background */}