import { useState, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { ticketsApi } from '../api/tickets'; import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; import { infoApi } from '../api/info'; import { useAuthStore } from '../store/auth'; import { logger } from '../utils/logger'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; import type { TicketDetail } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { usePlatform } from '@/platform'; import { linkifyText } from '../utils/linkify'; const log = logger.createLogger('Support'); const PlusIcon = () => ( ); const SendIcon = () => ( ); const ImageIcon = () => ( ); const CloseIcon = () => ( ); // Media attachment state interface MediaAttachment { id: string; file: File; preview: string; uploading: boolean; fileId?: string; error?: string; } export default function Support() { log.debug('Component loaded'); const { t } = useTranslation(); const isAdmin = useAuthStore((state) => state.isAdmin); const queryClient = useQueryClient(); const { openTelegramLink, openLink } = usePlatform(); const [selectedTicket, setSelectedTicket] = useState(null); const [showCreateForm, setShowCreateForm] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newMessage, setNewMessage] = useState(''); const [replyMessage, setReplyMessage] = useState(''); const [rateLimitError, setRateLimitError] = useState(null); // Media attachment states (multi-upload, up to 10) const [createAttachments, setCreateAttachments] = useState([]); const [replyAttachments, setReplyAttachments] = useState([]); const createFileInputRef = useRef(null); const replyFileInputRef = useRef(null); const blobUrlsRef = useRef>(new Set()); useEffect(() => { const urls = blobUrlsRef; return () => { urls.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); const clearCreateAttachments = () => { createAttachments.forEach((a) => { if (a.preview) URL.revokeObjectURL(a.preview); }); setCreateAttachments([]); if (createFileInputRef.current) createFileInputRef.current.value = ''; }; const clearReplyAttachments = () => { replyAttachments.forEach((a) => { if (a.preview) URL.revokeObjectURL(a.preview); }); setReplyAttachments([]); if (replyFileInputRef.current) replyFileInputRef.current.value = ''; }; // Get support configuration const { data: supportConfig, isLoading: configLoading } = useQuery({ queryKey: ['support-config'], queryFn: infoApi.getSupportConfig, }); const { data: tickets, isLoading } = useQuery({ queryKey: ['tickets'], queryFn: () => ticketsApi.getTickets({ per_page: 20 }), enabled: supportConfig?.tickets_enabled === true, }); const { data: ticketDetail, isLoading: detailLoading } = useQuery({ queryKey: ['ticket', selectedTicket?.id], queryFn: () => ticketsApi.getTicket(selectedTicket!.id), enabled: !!selectedTicket, }); // Handle file selection (multi-upload) const handleFileSelect = async ( file: File, setAttachments: React.Dispatch>, ) => { const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (!allowedTypes.includes(file.type)) return; if (file.size > 10 * 1024 * 1024) return; const preview = URL.createObjectURL(file); blobUrlsRef.current.add(preview); const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `att_${Date.now()}_${Math.random().toString(36).slice(2)}`; const entry: MediaAttachment = { id, file, preview, uploading: true }; setAttachments((prev) => (prev.length >= 10 ? prev : [...prev, entry])); try { const result = await ticketsApi.uploadMedia(file, 'photo'); setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, uploading: false, fileId: result.file_id } : a)), ); } catch { setAttachments((prev) => prev.map((a) => a.id === id ? { ...a, uploading: false, error: t('support.uploadFailed') } : a, ), ); } }; const createMutation = useMutation({ mutationFn: async () => { const ready = createAttachments.filter((a) => a.fileId) as Array<{ fileId: string }>; const media = ready.length > 0 ? { media_type: 'photo', media_file_id: ready[0].fileId, media_items: ready.map((a) => ({ type: 'photo' as const, file_id: a.fileId })), } : undefined; return ticketsApi.createTicket(newTitle, newMessage, media); }, onSuccess: (ticket) => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); setShowCreateForm(false); setNewTitle(''); setNewMessage(''); clearCreateAttachments(); setSelectedTicket(ticket); }, }); const replyMutation = useMutation({ mutationFn: async () => { const ready = replyAttachments.filter((a) => a.fileId) as Array<{ fileId: string }>; const media = ready.length > 0 ? { media_type: 'photo', media_file_id: ready[0].fileId, media_items: ready.map((a) => ({ type: 'photo' as const, file_id: a.fileId })), } : undefined; await ticketsApi.addMessage(selectedTicket!.id, replyMessage, media); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }); setReplyMessage(''); clearReplyAttachments(); }, }); const getStatusBadge = (status: string) => { switch (status) { case 'open': return 'badge-info'; case 'answered': return 'badge-success'; case 'pending': return 'badge-warning'; case 'closed': return 'badge-neutral'; default: return 'badge-neutral'; } }; const getStatusLabel = (status: string) => { return t(`support.status.${status}`) || status; }; // Show loading while checking configuration if (configLoading) { return ( ); } // If tickets are disabled, show redirect message if (supportConfig && !supportConfig.tickets_enabled) { log.debug('Tickets disabled, config:', supportConfig); const getSupportMessage = () => { log.debug('Getting support message for type:', supportConfig.support_type); if (supportConfig.support_type === 'profile') { const supportUsername = supportConfig.support_username || '@support'; log.debug('Opening profile:', supportUsername); return { title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), message: t('support.contactSupport', { username: supportUsername }), buttonText: t('support.contactUs'), buttonAction: () => { log.debug('Button clicked, opening:', supportUsername); // Extract username without @ const username = supportUsername.startsWith('@') ? supportUsername.slice(1) : supportUsername; const webUrl = `https://t.me/${username}`; log.debug('Web URL:', webUrl); // Use platform's openTelegramLink openTelegramLink(webUrl); }, }; } if (supportConfig.support_type === 'url' && supportConfig.support_url) { return { title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), message: t('support.useExternalLink'), buttonText: t('support.openSupport'), buttonAction: () => { openLink(supportConfig.support_url!, { tryInstantView: false }); }, }; } // Fallback: contact support (should not normally happen if config is correct) const supportUsername = supportConfig.support_username || '@support'; log.debug('Fallback: Opening profile:', supportUsername); return { title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), message: t('support.contactSupport', { username: supportUsername }), buttonText: t('support.contactUs'), buttonAction: () => { log.debug('Fallback button clicked, opening:', supportUsername); // Extract username without @ const username = supportUsername.startsWith('@') ? supportUsername.slice(1) : supportUsername; const webUrl = `https://t.me/${username}`; log.debug('Fallback opening URL:', webUrl); // Use platform's openTelegramLink openTelegramLink(webUrl); }, }; }; const supportMessage = getSupportMessage(); return ( {supportMessage.title} {supportMessage.message} {supportMessage.buttonText} ); } // Attachments preview component const AttachmentsPreview = ({ items, onRemove, }: { items: MediaAttachment[]; onRemove: (idx: number) => void; }) => items.length === 0 ? null : ( {items.map((att, idx) => ( {att.preview ? ( ) : ( {att.file.name.slice(-6)} )} {att.uploading && ( )} {att.error && ( ! )} onRemove(idx)} className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-dark-600 text-dark-300 hover:bg-error-500 hover:text-white" > ))} ); return ( {t('support.title')} { setShowCreateForm(true); setSelectedTicket(null); clearCreateAttachments(); }} > {t('support.newTicket')} {/* Contact support card for "both" mode */} {supportConfig?.support_type === 'both' && supportConfig.support_username && ( {t('support.contactUs')} {supportConfig.support_username} { const username = supportConfig.support_username!.startsWith('@') ? supportConfig.support_username!.slice(1) : supportConfig.support_username!; openTelegramLink(`https://t.me/${username}`); }} > {t('support.contactUs')} )} {/* Tickets List */} {t('support.yourTickets')} {isLoading ? ( ) : tickets?.items && tickets.items.length > 0 ? ( {tickets.items.map((ticket) => ( { setSelectedTicket(ticket as unknown as TicketDetail); setShowCreateForm(false); clearReplyAttachments(); }} className={`w-full rounded-bento border p-4 text-left transition-all ${ selectedTicket?.id === ticket.id ? 'border-accent-500 bg-accent-500/10' : 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600' }`} > {ticket.title} {getStatusLabel(ticket.status)} {new Date(ticket.updated_at).toLocaleDateString()} ))} ) : ( {t('support.noTickets')} )} {/* Ticket Detail / Create Form */} {showCreateForm ? ( {t('support.createTicket')} { 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 })); return; } createMutation.mutate(); }} className="space-y-4" > {t('support.subject')} setNewTitle(e.target.value)} required minLength={3} maxLength={255} /> {t('support.message')} setNewMessage(e.target.value)} required minLength={10} maxLength={4000} /> {/* Image attachments for create */} { const files = Array.from(e.target.files || []); files.forEach((file) => handleFileSelect(file, setCreateAttachments)); e.target.value = ''; }} /> setCreateAttachments((prev) => { const removed = prev[idx]; if (removed?.preview) URL.revokeObjectURL(removed.preview); return prev.filter((_, i) => i !== idx); }) } /> {createAttachments.length < 10 && ( createFileInputRef.current?.click()} disabled={createAttachments.some((a) => a.uploading)} className="mt-2 flex items-center gap-2 text-sm text-dark-400 transition-colors hover:text-dark-200 disabled:opacity-50" > {t('support.attachImage')}{' '} {createAttachments.length > 0 && `(${createAttachments.length}/10)`} )} {rateLimitError && ( {rateLimitError} )} a.uploading)} loading={createMutation.isPending} > {t('support.send')} { setShowCreateForm(false); clearCreateAttachments(); }} > {t('common.cancel')} ) : selectedTicket ? ( {ticketDetail?.title || selectedTicket.title} {getStatusLabel(ticketDetail?.status || selectedTicket.status)} {t('support.created')}{' '} {new Date(selectedTicket.created_at).toLocaleDateString()} {/* Messages */} {detailLoading ? ( ) : ticketDetail?.messages ? ( {ticketDetail.messages.map((msg) => ( {msg.is_from_admin ? t('support.supportTeam') : t('support.you')} {new Date(msg.created_at).toLocaleString()} {msg.message_text && ( )} {/* Display media if present */} ))} ) : null} {/* Reply Form */} {ticketDetail?.status !== 'closed' && !ticketDetail?.is_reply_blocked && ( { 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 })); return; } replyMutation.mutate(); }} className="border-t border-dark-800/50 pt-4" > setReplyMessage(e.target.value)} maxLength={4000} /> {/* Image attachments for reply */} { const files = Array.from(e.target.files || []); files.forEach((file) => handleFileSelect(file, setReplyAttachments)); e.target.value = ''; }} /> setReplyAttachments((prev) => { const removed = prev[idx]; if (removed?.preview) URL.revokeObjectURL(removed.preview); return prev.filter((_, i) => i !== idx); }) } /> {replyAttachments.length < 10 && ( replyFileInputRef.current?.click()} disabled={replyAttachments.some((a) => a.uploading)} className="flex items-center gap-2 text-sm text-dark-400 transition-colors hover:text-dark-200 disabled:opacity-50" > {t('support.attachImage')}{' '} {replyAttachments.length > 0 && `(${replyAttachments.length}/10)`} )} a.fileId).length === 0) || replyAttachments.some((a) => a.uploading) } loading={replyMutation.isPending} > {rateLimitError && ( {rateLimitError} )} )} {ticketDetail?.is_reply_blocked && ( {t('support.repliesDisabled')} )} ) : ( {t('support.selectTicket')} )} ); }
{supportMessage.message}