mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Merge remote-tracking branch 'bedolaga/main'
This commit is contained in:
@@ -155,10 +155,21 @@ export default function AdminTickets() {
|
||||
|
||||
const formatUser = (ticket: AdminTicket | AdminTicketDetail) => {
|
||||
if (!ticket.user) return 'Unknown'
|
||||
const { first_name, last_name, username, telegram_id } = ticket.user
|
||||
const { first_name, last_name, username } = ticket.user
|
||||
if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim()
|
||||
if (username) return `@${username}`
|
||||
return `ID: ${telegram_id}`
|
||||
return 'User'
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -248,7 +259,17 @@ export default function AdminTickets() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{formatUser(ticket)} | {new Date(ticket.updated_at).toLocaleDateString()}
|
||||
{formatUser(ticket)}
|
||||
{ticket.user?.telegram_id && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(String(ticket.user!.telegram_id)) }}
|
||||
className="ml-1 text-dark-600 hover:text-accent-400 transition-colors"
|
||||
title={t('admin.tickets.copyTelegramId')}
|
||||
>
|
||||
(TG: {ticket.user!.telegram_id})
|
||||
</button>
|
||||
)}
|
||||
{' '}| {new Date(ticket.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
{ticket.last_message && (
|
||||
<div className="text-xs text-dark-600 mt-1 truncate">
|
||||
@@ -315,8 +336,17 @@ export default function AdminTickets() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-dark-500 mb-4">
|
||||
{t('admin.tickets.from')}: {formatUser(selectedTicket)} |{' '}
|
||||
{t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
|
||||
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
|
||||
{selectedTicket.user?.telegram_id && (
|
||||
<button
|
||||
onClick={() => copyToClipboard(String(selectedTicket.user!.telegram_id))}
|
||||
className="ml-1 px-2 py-0.5 text-xs bg-dark-700 hover:bg-dark-600 rounded transition-colors"
|
||||
title={t('admin.tickets.copyTelegramId')}
|
||||
>
|
||||
TG: {selectedTicket.user!.telegram_id}
|
||||
</button>
|
||||
)}
|
||||
{' '}| {t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['open', 'pending', 'answered', 'closed'].map((s) => (
|
||||
|
||||
@@ -1095,6 +1095,13 @@ export default function Subscription() {
|
||||
{/* Tariff List - current tariff first */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[...tariffs]
|
||||
// Hide Trial tariffs for users who already have trial subscription
|
||||
.filter((tariff) => {
|
||||
if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aIsCurrent = a.is_current || a.id === subscription?.tariff_id
|
||||
const bIsCurrent = b.is_current || b.id === subscription?.tariff_id
|
||||
@@ -1787,7 +1794,15 @@ export default function Subscription() {
|
||||
{/* 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) => {
|
||||
{selectedPeriod.servers.options
|
||||
// Hide Trial server for users who already have trial subscription
|
||||
.filter((server) => {
|
||||
if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((server) => {
|
||||
const hasExistingDiscount = !!(server.discount_percent && server.discount_percent > 0)
|
||||
const promoServer = applyPromoDiscount(server.price_kopeks, hasExistingDiscount)
|
||||
|
||||
@@ -1822,7 +1837,7 @@ export default function Subscription() {
|
||||
<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>
|
||||
<span className="text-sm text-accent-400">{formatPrice(promoServer.price)}{t('subscription.perMonth')}</span>
|
||||
{promoServer.original && (
|
||||
<span className="text-dark-500 text-xs line-through">{formatPrice(promoServer.original)}</span>
|
||||
)}
|
||||
@@ -1860,8 +1875,15 @@ export default function Subscription() {
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-dark-500 mt-4">
|
||||
{formatPrice(selectedPeriod.devices.price_per_device_kopeks)} {t('subscription.perDevice')}
|
||||
<div className="text-sm text-dark-500 mt-4 text-center space-y-1">
|
||||
<div className="text-accent-400">
|
||||
{selectedPeriod.devices.min} {t('subscription.devicesFree')}
|
||||
</div>
|
||||
{selectedPeriod.devices.max > selectedPeriod.devices.min && (
|
||||
<div>
|
||||
{formatPrice(selectedPeriod.devices.price_per_device_kopeks)} {t('subscription.perExtraDevice')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { ticketsApi } from '../api/tickets'
|
||||
import { infoApi } from '../api/info'
|
||||
import { logger } from '../utils/logger'
|
||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
|
||||
import type { TicketDetail, TicketMessage } from '../types'
|
||||
|
||||
const log = logger.createLogger('Support')
|
||||
@@ -132,6 +133,7 @@ export default function Support() {
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newMessage, setNewMessage] = useState('')
|
||||
const [replyMessage, setReplyMessage] = useState('')
|
||||
const [rateLimitError, setRateLimitError] = useState<string | null>(null)
|
||||
|
||||
// Media attachment states
|
||||
const [createAttachment, setCreateAttachment] = useState<MediaAttachment | null>(null)
|
||||
@@ -507,6 +509,13 @@ export default function Support() {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setRateLimitError(null)
|
||||
// Rate limit: max 3 tickets per 60 seconds
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
|
||||
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE)
|
||||
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
|
||||
return
|
||||
}
|
||||
createMutation.mutate()
|
||||
}}
|
||||
className="space-y-4"
|
||||
@@ -567,6 +576,12 @@ export default function Support() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rateLimitError && (
|
||||
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-3 rounded-xl text-sm">
|
||||
{rateLimitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -655,6 +670,13 @@ export default function Support() {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setRateLimitError(null)
|
||||
// Rate limit: max 5 replies per 30 seconds
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
|
||||
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY)
|
||||
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
|
||||
return
|
||||
}
|
||||
replyMutation.mutate()
|
||||
}}
|
||||
className="pt-4 border-t border-dark-800/50"
|
||||
@@ -715,6 +737,11 @@ export default function Support() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{rateLimitError && (
|
||||
<div className="mt-2 bg-error-500/10 border border-error-500/30 text-error-400 p-2 rounded-lg text-sm">
|
||||
{rateLimitError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user