/* ============================================================ page-support.jsx — Support ticket management tab ?v=8 (vpsie connect modal) ============================================================ */ (function () { const { useState, useEffect, useCallback, useRef } = React; const parseDetail = async (url, opts) => { const r = await fetch(url, { credentials: 'include', ...opts }); if (!r.ok) throw new Error(r.status); return r.json(); }; /* ── helpers ─────────────────────────────────────── */ function fmtDate(iso) { if (!iso) return '—'; const d = new Date(iso); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } function fmtRelative(iso) { if (!iso) return ''; const diff = Date.now() - new Date(iso).getTime(); const s = Math.floor(diff / 1000); /* future timestamps or <60s → "just now" */ const ar = window.appLang && window.appLang() === 'ar'; if (s < 60) return ar ? 'الآن' : 'just now'; const m = Math.floor(s / 60); if (m < 60) return ar ? `قبل ${m} د` : `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return ar ? `قبل ${h} س` : `${h}h ago`; return ar ? `قبل ${Math.floor(h / 24)} ي` : `${Math.floor(h / 24)}d ago`; } function fmtMin(min) { if (min == null) return '—'; if (min < 60) return `${Math.round(min)}m`; return `${(min / 60).toFixed(1)}h`; } function initials(name) { if (!name) return '?'; return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); } /* ── avatar ──────────────────────────────────────── */ function Avatar({ name, size = 28 }) { const colors = ['#6366f1','#8b5cf6','#ec4899','#0ea5e9','#10b981','#f59e0b']; const i = name ? name.charCodeAt(0) % colors.length : 0; return (
{initials(name)}
); } /* ── severity pill ───────────────────────────────── */ function SevPill({ sev }) { const T = window.appT || (s => s); const MAP = { low: { bg: '#10b981', label: T('Low') }, medium: { bg: '#f59e0b', label: T('Medium') }, high: { bg: '#ef4444', label: T('High') }, }; const { bg, label } = MAP[sev] || { bg: '#6b7280', label: sev || '—' }; return ( {label} ); } /* ── status pill ─────────────────────────────────── */ function StatusPill({ st }) { const T = window.appT || (s => s); const MAP = { open: { bg: '#6b7280', label: T('Open') }, acknowledged: { bg: '#3b82f6', label: T('Acknowledged') }, in_progress: { bg: '#f59e0b', label: T('In Progress') }, resolved: { bg: '#10b981', label: T('Resolved') }, closed: { bg: '#475569', label: T('Closed') }, }; const { bg, label } = MAP[st] || { bg: '#6b7280', label: st || '—' }; return ( {label} ); } /* ── toast ───────────────────────────────────────── */ function Toast({ msg, type = 'ok', onDone }) { useEffect(() => { const t = setTimeout(onDone, 2800); return () => clearTimeout(t); }, []); const bg = type === 'err' ? '#ef4444' : '#10b981'; return (
{msg}
); } /* ── stats strip ─────────────────────────────────── */ function StatsStrip({ stats }) { const st = stats || {}; const noData = stats === null; const T = window.appT || (s => s); const items = [ { label: T('Open'), value: noData ? '—' : (st.open ?? 0), accent: '#6b7280', topBar: '#6b7280' }, { label: T('Acknowledged'), value: noData ? '—' : (st.acknowledged ?? 0), accent: '#3b82f6', topBar: '#3b82f6' }, { label: T('Resolved this week'),value: noData ? '—' : (st.resolved_this_week ?? 0), accent: '#10b981', topBar: '#10b981' }, { label: T('Avg response'), value: fmtMin(st.avg_response_min), accent: st.avg_response_min != null ? 'var(--text-1)' : 'var(--text-3)', topBar: '#8b5cf6' }, ]; return (
{items.map(it => (
{ e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.25)'; }} onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'none'; }} > {/* top accent bar */}
{it.value}
{it.label}
))}
); } /* ── severity segmented control ─────────────────── */ function SevSegmented({ value, onChange }) { const T = window.appT || (s => s); const opts = [ { value: 'low', label: T('Low'), bg: '#10b981' }, { value: 'medium', label: T('Medium'), bg: '#f59e0b' }, { value: 'high', label: T('High'), bg: '#ef4444' }, ]; return (
{opts.map(o => ( ))}
); } /* ── VPSie connect modal ─────────────────────────── */ function VpsieConnectModal({ onClose, onConnected }) { const [clientId, setClientId] = useState(''); const [clientSecret, setClientSecret] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); async function submit(e) { e && e.preventDefault(); const cid = clientId.trim(), sec = clientSecret.trim(); const T = window.appT || (s => s); if (!cid || !sec) { setErr(T('Both Client ID and Client Secret are required.')); return; } setErr(null); setBusy(true); try { const r = await fetch('/api/me/vpsie/connect', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: cid, client_secret: sec }), }); if (!r.ok) { let msg = `HTTP ${r.status}`; try { const j = await r.json(); msg = j.detail || j.message || msg; } catch(_) {} setErr(typeof msg === 'string' ? msg : T('Could not validate those credentials.')); setBusy(false); return; } // Success — bust cache and notify parent await fetch('/api/me/vpsie/refresh', { method: 'POST', credentials: 'include' }).catch(()=>{}); setBusy(false); onConnected && onConnected(); onClose && onClose(); } catch (_e) { setErr(T('Network error — please try again.')); setBusy(false); } } const overlay = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, }; const panel = { width: 'min(480px, 92vw)', background: 'var(--bg-elev-1)', border: '1px solid var(--line)', borderRadius: 12, padding: '22px 22px 18px', boxShadow: '0 24px 60px rgba(0,0,0,0.45)', }; const inputStyle = { width: '100%', boxSizing: 'border-box', padding: '9px 12px', background: 'var(--bg-1)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text-1)', fontSize: 13, fontFamily: 'var(--font-mono, monospace)', }; const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 5 }; return (
{ if (e.target === e.currentTarget && !busy) onClose && onClose(); }}>
{(window.appT||(s=>s))("Connect your VPSie account")}
{(window.appT||(s=>s))("Your client ID + secret are stored encrypted and only used to fetch")} {(window.appT||(s=>s))(" your ")}{(window.appT||(s=>s))("servers.")} {' '}{(window.appT||(s=>s))("Admins cannot see them. Generate creds at")}{' '} my.vpsie.com/account/api.
{err && (
{err}
)}
setClientId(e.target.value)} placeholder={(window.appT||(s=>s))("vpsie client ID")} disabled={busy} />
setClientSecret(e.target.value)} placeholder={(window.appT||(s=>s))("vpsie client secret")} disabled={busy} />
); } /* ── new ticket form (VPSie-inspired layout) ─────── */ function NewTicketForm({ onCreated }) { const [subject, setSubject] = useState(''); const [relatedServer, setRelatedSvr] = useState(''); const [relatedSvrData, setRelatedSvrData] = useState(null); const [category, setCategory] = useState(''); const [severity, setSeverity] = useState('medium'); const [issue, setIssue] = useState(''); const [files, setFiles] = useState([]); const [showVpsieModal, setShowVpsieModal] = useState(false); const [vpsieReloadKey, setVpsieReloadKey] = useState(0); const [vpsieToast, setVpsieToast] = useState(null); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); const [ok, setOk] = useState(null); const fileInputRef = useRef(null); function onFilesChange(e) { const picked = Array.from(e.target.files || []); // Cap individual file size and total selection at sensible limits const MAX_BYTES = 25 * 1024 * 1024; const valid = picked.filter(f => f.size <= MAX_BYTES); if (valid.length < picked.length) { setErr((window.appT||(s=>s))('One or more files exceed the 25 MB per-file limit.')); } setFiles(prev => [...prev, ...valid]); // Reset input so the same file can be re-selected if removed if (fileInputRef.current) fileInputRef.current.value = ''; } function removeFile(idx) { setFiles(prev => prev.filter((_, i) => i !== idx)); } function fmtBytes(n) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } async function submit(e) { e.preventDefault(); if (!issue.trim()) { setErr((window.appT||(s=>s))('Issue details are required.')); return; } setErr(null); setLoading(true); try { // Category value is "Label|@bot.matrix_id" — split it const [catLabel, assignedBot] = (category || '').split('|'); // Compose backend payload — prepend subject + related-server context to issue body const composedIssue = [ subject.trim() && `Subject: ${subject.trim()}`, relatedSvrData ? `Related server: hostname=${relatedSvrData.hostname}, ip=${relatedSvrData.ip}, status=${relatedSvrData.status}, region=${relatedSvrData.region}` : (relatedServer.trim() && `Related server: ${relatedServer.trim()}`), issue.trim(), ].filter(Boolean).join('\n\n'); const res = await parseDetail('/api/support/escalate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ team_name: catLabel || null, severity, issue: composedIssue, assigned_bot_matrix_id: assignedBot || null, }), }); // Upload attachments (if any) — fire-and-forget; backend posts to room if (files.length > 0 && res?.id) { try { const fd = new FormData(); files.forEach(f => fd.append('files', f, f.name)); await fetch(`/api/support/tickets/${res.id}/attachments`, { method: 'POST', credentials: 'include', body: fd, }); } catch (_e) { /* don't block ticket creation on attachment failure */ } } setOk(res.ticket_number); setSubject(''); setRelatedSvr(''); setRelatedSvrData(null); setCategory(''); setSeverity('medium'); setIssue(''); setFiles([]); onCreated && onCreated(); } catch (_e) { setErr((window.appT||(s=>s))('Failed to create ticket. Please try again.')); } finally { setLoading(false); } } const fieldLabel = { position: 'absolute', top: -8, left: 12, padding: '0 6px', background: 'var(--bg-elev-1)', fontSize: 11, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.04em', textTransform: 'uppercase', pointerEvents: 'none', }; const fieldWrap = { position: 'relative', flex: 1 }; const selectStyle = { width: '100%', boxSizing: 'border-box', appearance: 'none', WebkitAppearance: 'none', backgroundImage: 'url("data:image/svg+xml;charset=US-ASCII,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'10\' height=\'6\' viewBox=\'0 0 10 6\'%3E%3Cpath fill=\'%2394a3b8\' d=\'M0 0l5 6 5-6z\'/%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 14px center', paddingRight: 36, }; return ( {showVpsieModal && ( setShowVpsieModal(false)} onConnected={() => { setVpsieReloadKey(k => k + 1); setVpsieToast((window.appT||(s=>s))('VPSie connected. Server list refreshed.')); setTimeout(() => setVpsieToast(null), 4000); }} /> )}
{vpsieToast && (
{vpsieToast}
)} {ok && (
{(window.appT||(s=>s))("Ticket")} {ok} {(window.appT||(s=>s))("created and posted to the support room.")}
)} {err && (
{err}
)}
{/* Row 1: Case Subject (full width) */}
{(window.appT||(s=>s))("Case Subject")} setSubject(e.target.value)} placeholder={(window.appT||(s=>s))("Briefly describe the issue")} />
{/* Row 2: Related Server | Category | Priority */}
{(window.appT||(s=>s))("Related Server")} setShowVpsieModal(true)} onChange={id => { // Store ID + find server data for context injection on submit setRelatedSvr(id || ''); setRelatedSvrData(window.__vpsieServersCache ? window.__vpsieServersCache.find(s => String(s.id) === String(id)) : null); }} placeholder={(window.appT||(s=>s))("optional — select a VPSie server")} />
{(window.appT||(s=>s))("Category")}
{(window.appT||(s=>s))("Priority")}
{/* Row 3: Issue Details + helper */}
{(window.appT||(s=>s))("Issue Details")}