697 lines
47 KiB
HTML
697 lines
47 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Campus Support</title>
|
|
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
|
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="style.css?v=20260921l" />
|
|
<style>
|
|
/* ── page shell (clears the fixed topbar, centred column) ── */
|
|
.sup-page{max-width:720px;margin:0 auto;padding:calc(var(--topbar-h) + 34px) 20px 96px;}
|
|
|
|
/* ── hero ── */
|
|
.sup-hero{position:relative;text-align:center;padding:34px 24px 30px;border-radius:24px;
|
|
border:1px solid var(--line);overflow:hidden;margin-bottom:22px;
|
|
background:radial-gradient(ellipse 80% 120% at 50% -20%, var(--primary-soft), transparent 68%), var(--card);}
|
|
.sup-hero .hero-ic{width:60px;height:60px;border-radius:19px;display:grid;place-items:center;margin:0 auto 15px;
|
|
background:var(--gradient-main);box-shadow:0 10px 24px rgba(207,40,96,.30);}
|
|
.sup-hero .hero-ic svg{width:31px;height:31px;color:#fff;}
|
|
.sup-hero h1{font-size:2rem;font-weight:800;letter-spacing:-.03em;margin:0 0 7px;}
|
|
.sup-hero p{color:var(--muted);font-weight:600;line-height:1.55;margin:0 auto;max-width:470px;}
|
|
|
|
/* ── tabs (mirrors the site's .utab pills) ── */
|
|
.sup-tabs{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-bottom:18px;}
|
|
.sup-tabs button{cursor:pointer;font:inherit;font-weight:700;font-size:.9rem;padding:9px 18px;border-radius:999px;
|
|
background:var(--card);border:1px solid var(--line);color:var(--text);display:inline-flex;align-items:center;gap:8px;
|
|
transition:border-color .15s,color .15s,background .15s;}
|
|
.sup-tabs button:hover{border-color:var(--primary);color:var(--primary);}
|
|
.sup-tabs button.active{background:var(--gradient-main);border-color:transparent;color:#fff;}
|
|
.sup-tabs button[hidden],.pill[hidden]{display:none !important;} /* author display: rules otherwise beat [hidden] */
|
|
.sup-tabs .pill{min-width:20px;padding:1px 7px;border-radius:999px;font-size:.76rem;font-weight:800;
|
|
background:var(--primary);color:#fff;line-height:1.5;}
|
|
.sup-tabs button.active .pill{background:rgba(255,255,255,.3);}
|
|
|
|
.panel{display:none;} .panel.active{display:block;animation:fade .22s ease;}
|
|
@keyframes fade{from{opacity:0;transform:translateY(5px);}to{opacity:1;transform:none;}}
|
|
|
|
/* ── surface card ── */
|
|
.surface{background:var(--card);border:1px solid var(--line);border-radius:20px;
|
|
box-shadow:0 10px 30px rgba(0,0,0,.05);overflow:hidden;}
|
|
|
|
/* ── assistant header ── */
|
|
.asst-head{display:flex;align-items:center;gap:13px;padding:16px 18px;border-bottom:1px solid var(--line);background:var(--panel);}
|
|
.asst-av{width:42px;height:42px;border-radius:13px;flex:none;background:var(--gradient-main);display:grid;place-items:center;}
|
|
.asst-av img{width:28px;height:28px;object-fit:contain;filter:brightness(0) invert(1);}
|
|
.asst-name{font-weight:800;font-size:1rem;line-height:1.1;}
|
|
.asst-sub{font-size:.8rem;color:var(--muted);font-weight:600;display:flex;align-items:center;gap:6px;margin-top:3px;}
|
|
.asst-dot{width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.2);}
|
|
|
|
/* ── chat ── */
|
|
.chat{display:flex;flex-direction:column;gap:11px;padding:18px;}
|
|
.msg{max-width:90%;padding:11px 14px;border-radius:16px;line-height:1.5;font-size:.96rem;white-space:pre-wrap;color:var(--text);}
|
|
.msg.bot{align-self:flex-start;background:var(--panel);border:1px solid var(--line);border-top-left-radius:5px;}
|
|
.msg.me{align-self:flex-end;background:var(--gradient-main);color:#fff;border-top-right-radius:5px;font-weight:600;}
|
|
.choices{display:flex;flex-wrap:wrap;gap:8px;}
|
|
.choice{cursor:pointer;font:inherit;font-weight:600;font-size:.9rem;padding:9px 14px;border-radius:999px;
|
|
border:1px solid var(--line);background:var(--card);color:var(--text);text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s;}
|
|
.choice:hover{border-color:var(--primary);color:var(--primary);background:var(--primary-soft);}
|
|
.choice.primary{background:var(--gradient-main);border-color:transparent;color:#fff;}
|
|
.choice.primary:hover{filter:brightness(1.07);color:#fff;background:var(--gradient-main);}
|
|
.choice.ghost{color:var(--muted);border-style:dashed;}
|
|
.choice.ghost:hover{color:var(--primary);background:transparent;border-color:var(--primary);}
|
|
.typing{display:inline-flex;gap:4px;padding:2px 0;}
|
|
.typing span{width:7px;height:7px;border-radius:50%;background:var(--muted);opacity:.5;animation:tp 1s infinite;}
|
|
.typing span:nth-child(2){animation-delay:.15s;} .typing span:nth-child(3){animation-delay:.3s;}
|
|
@keyframes tp{0%,60%,100%{transform:translateY(0);opacity:.35;}30%{transform:translateY(-4px);opacity:.9;}}
|
|
|
|
/* ── talk-to-human CTA (panel footer) ── */
|
|
.human-bar{padding:14px 18px;border-top:1px solid var(--line);background:var(--panel);}
|
|
.human-btn{width:100%;display:inline-flex;align-items:center;justify-content:center;gap:9px;cursor:pointer;border:none;
|
|
font:inherit;font-weight:800;font-size:.98rem;padding:14px;border-radius:14px;background:var(--gradient-main);color:#fff;
|
|
box-shadow:0 8px 20px rgba(207,40,96,.26);transition:transform .15s,box-shadow .15s,filter .15s;}
|
|
.human-btn:hover{transform:translateY(-1px);box-shadow:0 12px 26px rgba(207,40,96,.34);filter:brightness(1.03);}
|
|
.human-btn svg{width:19px;height:19px;}
|
|
|
|
/* ── fields ── */
|
|
.field{font:inherit;font-size:.95rem;padding:12px 14px;border:1px solid var(--line);border-radius:12px;
|
|
background:var(--bg);color:var(--text);width:100%;box-sizing:border-box;transition:border-color .15s;}
|
|
.field:focus{outline:none;border-color:var(--primary);}
|
|
.field::placeholder{color:var(--muted);}
|
|
textarea.field{min-height:104px;resize:vertical;}
|
|
.row-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:11px;}
|
|
.note{font-size:.85rem;color:var(--muted);}
|
|
.discord-link{color:#5865F2;font-weight:700;text-decoration:none;}
|
|
.discord-link:hover{text-decoration:underline;}
|
|
|
|
/* ── ticket panels ── */
|
|
.panel-top{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px;}
|
|
.panel-top h2{font-size:1.2rem;font-weight:800;margin:0;}
|
|
.btn-new{cursor:pointer;font:inherit;font-weight:700;font-size:.9rem;padding:10px 16px;border-radius:11px;border:none;
|
|
background:var(--gradient-main);color:#fff;display:inline-flex;align-items:center;gap:7px;box-shadow:0 6px 16px rgba(207,40,96,.24);transition:transform .12s,box-shadow .15s;}
|
|
.btn-new:hover{transform:translateY(-1px);box-shadow:0 10px 22px rgba(207,40,96,.3);}
|
|
|
|
/* segmented staff filter */
|
|
.subfilter{display:inline-flex;gap:4px;padding:4px;border-radius:999px;background:var(--panel);border:1px solid var(--line);margin-bottom:16px;}
|
|
.subfilter button{cursor:pointer;font:inherit;font-weight:700;font-size:.84rem;padding:7px 15px;border-radius:999px;border:none;background:transparent;color:var(--muted);transition:.12s;}
|
|
.subfilter button:hover{color:var(--text);}
|
|
.subfilter button.active{background:var(--card);color:var(--text);box-shadow:0 1px 5px rgba(0,0,0,.1);}
|
|
|
|
/* ticket rows */
|
|
.tlist{display:flex;flex-direction:column;gap:12px;}
|
|
.trow{display:flex;align-items:flex-start;gap:14px;padding:16px 18px;border:1px solid var(--line);border-radius:16px;
|
|
background:var(--card);cursor:pointer;transition:border-color .15s,transform .12s,box-shadow .15s;}
|
|
.trow:hover{border-color:var(--primary);transform:translateY(-2px);box-shadow:0 8px 22px rgba(0,0,0,.07);}
|
|
.trow .tnum{flex:none;width:46px;height:46px;border-radius:13px;background:var(--primary-soft);color:var(--primary);
|
|
display:grid;place-items:center;font-weight:800;font-size:.82rem;}
|
|
.trow .tmain{flex:1;min-width:0;}
|
|
.trow .tsub{font-weight:800;font-size:.98rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
.trow .tprev{color:var(--muted);font-size:.88rem;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
.trow .tmeta{color:var(--muted);font-size:.78rem;font-weight:600;margin-top:7px;display:flex;gap:10px;flex-wrap:wrap;}
|
|
.trow .tmeta .who{color:var(--primary);font-weight:700;}
|
|
.badge{flex:none;font-size:.7rem;font-weight:800;padding:5px 11px;border-radius:999px;text-transform:uppercase;letter-spacing:.05em;}
|
|
.badge.open{background:rgba(34,197,94,.14);color:#15803d;}
|
|
.badge.closed{background:var(--panel);color:var(--muted);}
|
|
[data-theme="dark"] .badge.open{background:rgba(34,197,94,.2);color:#5fd27f;}
|
|
|
|
/* empty state */
|
|
.empty{color:var(--muted);text-align:center;padding:46px 20px;font-weight:600;}
|
|
.empty svg{width:40px;height:40px;opacity:.35;margin-bottom:12px;display:block;margin-left:auto;margin-right:auto;}
|
|
|
|
/* ── thread modal ── */
|
|
.modal-scrim{position:fixed;inset:0;background:rgba(10,8,20,.55);backdrop-filter:blur(3px);display:none;z-index:200;}
|
|
.modal-scrim.open{display:block;}
|
|
.modal{position:fixed;z-index:201;left:50%;top:50%;transform:translate(-50%,-50%) scale(.98);opacity:0;
|
|
width:min(820px,95vw);max-height:90vh;min-height:min(600px,86vh);background:var(--card);border:1px solid var(--line);border-radius:20px;
|
|
box-shadow:0 30px 80px rgba(0,0,0,.4);display:none;flex-direction:column;overflow:hidden;transition:opacity .16s,transform .16s;}
|
|
.modal.open{display:flex;opacity:1;transform:translate(-50%,-50%) scale(1);}
|
|
.modal-head{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid var(--line);}
|
|
.modal-head .mnum{flex:none;width:40px;height:40px;border-radius:11px;background:var(--primary-soft);color:var(--primary);display:grid;place-items:center;font-weight:800;font-size:.78rem;}
|
|
.modal-head .mtitle{flex:1;min-width:0;}
|
|
.modal-head h3{margin:0;font-size:1.05rem;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
.modal-head .msub{font-size:.8rem;color:var(--muted);font-weight:600;margin-top:2px;}
|
|
.modal-x{cursor:pointer;border:none;background:transparent;font-size:1.5rem;line-height:1;color:var(--muted);padding:2px 4px;}
|
|
.modal-x:hover{color:var(--text);}
|
|
.mtoggle{cursor:pointer;font:inherit;font-weight:700;font-size:.82rem;padding:8px 13px;border-radius:999px;
|
|
border:1px solid var(--line);background:var(--card);color:var(--muted);white-space:nowrap;transition:.12s;}
|
|
.mtoggle:hover{border-color:var(--primary);color:var(--primary);}
|
|
.thread{flex:1;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:14px;background:var(--page);}
|
|
/* structured "ticket details" panel parsed from the opening message */
|
|
.tdetails{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 17px;}
|
|
.tdetails .dh{font-size:.7rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:11px;display:flex;align-items:center;gap:7px;}
|
|
.tdetails .dh svg{width:15px;height:15px;color:var(--primary);}
|
|
.tdetails dl{display:grid;grid-template-columns:auto 1fr;gap:9px 18px;margin:0;}
|
|
.tdetails dt{color:var(--muted);font-size:.86rem;font-weight:700;white-space:nowrap;}
|
|
.tdetails dd{margin:0;font-size:.95rem;font-weight:600;color:var(--text);word-break:break-word;}
|
|
.tdetails dd.at{color:var(--primary);}
|
|
/* messages: avatar + bubble */
|
|
.msgrow{display:flex;gap:9px;max-width:86%;}
|
|
.msgrow.user{align-self:flex-start;}
|
|
.msgrow.staff{align-self:flex-end;flex-direction:row-reverse;}
|
|
.msgrow .bcol{display:flex;flex-direction:column;gap:3px;min-width:0;}
|
|
.msgrow.staff .bcol{align-items:flex-end;}
|
|
.av-sm{width:44px;height:44px;border-radius:50%;flex:none;display:grid;place-items:center;font-weight:800;font-size:1rem;margin-top:22px;text-transform:uppercase;overflow:hidden;}
|
|
.av-sm.staff{background:var(--gradient-main);color:#fff;}
|
|
.av-sm.user{background:var(--primary-soft);color:var(--primary);}
|
|
/* photo avatars: round, cropped to fill — same as the profile-page avatars */
|
|
.av-sm.img{background-color:var(--panel);background-size:cover;background-repeat:no-repeat;background-position:center;}
|
|
.who{font-size:.75rem;color:var(--muted);font-weight:700;padding:0 4px;}
|
|
.bubble{padding:12px 15px;border-radius:15px;font-size:1rem;white-space:pre-wrap;line-height:1.5;color:var(--text);max-width:100%;}
|
|
.bubble.user{background:var(--card);border:1px solid var(--line);border-top-left-radius:5px;}
|
|
.bubble.staff{background:var(--gradient-main);color:#fff;border-top-right-radius:5px;}
|
|
.modal-foot{border-top:1px solid var(--line);padding:13px 16px;background:var(--panel);}
|
|
.modal-foot .row{display:flex;gap:10px;align-items:flex-end;}
|
|
.modal-foot textarea.field{min-height:44px;max-height:140px;background:var(--card);}
|
|
.mfoot-closed{text-align:center;color:var(--muted);font-size:.88rem;font-weight:600;padding:4px 0;}
|
|
.btn-send{cursor:pointer;border:none;font:inherit;font-weight:700;padding:12px 18px;border-radius:12px;
|
|
background:var(--gradient-main);color:#fff;flex:none;transition:filter .12s,transform .12s;}
|
|
.btn-send:hover{filter:brightness(1.06);transform:translateY(-1px);}
|
|
.btn-send:disabled{opacity:.5;cursor:default;transform:none;}
|
|
|
|
/* ── create-ticket form modal ── */
|
|
.modal-body{padding:18px;overflow:auto;display:flex;flex-direction:column;gap:15px;}
|
|
.frow{display:flex;flex-direction:column;gap:6px;}
|
|
.fl{font-size:.85rem;font-weight:700;color:var(--text);}
|
|
.fl .rq{color:#e11d48;margin-left:3px;} .fl .opt{color:var(--muted);font-weight:600;font-size:.8rem;margin-left:4px;}
|
|
.fhint{font-size:.78rem;color:var(--muted);}
|
|
.at-field{display:flex;align-items:center;border:1px solid var(--line);border-radius:12px;background:var(--bg);overflow:hidden;transition:border-color .15s;}
|
|
.at-field:focus-within{border-color:var(--primary);}
|
|
.at-field .at{padding:0 2px 0 13px;color:var(--muted);font-weight:700;}
|
|
.at-field input{flex:1;border:none;background:transparent;color:var(--text);font:inherit;font-size:.95rem;padding:12px 13px 12px 4px;outline:none;}
|
|
select.field{appearance:none;-webkit-appearance:none;cursor:pointer;padding-right:38px;
|
|
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238a8a90' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
|
background-repeat:no-repeat;background-position:right 13px center;}
|
|
.tform-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-top:2px;}
|
|
.modal-login{padding:30px 22px;text-align:center;}
|
|
.modal-login p{color:var(--muted);font-weight:600;margin:0 0 18px;line-height:1.55;}
|
|
.modal-login .choices{justify-content:center;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<a class="skip-link" href="#main">Skip to content</a>
|
|
|
|
<header class="topbar">
|
|
<div class="top-left">
|
|
<button class="side-toggle" id="burger" aria-label="Open menu"><img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat"></button>
|
|
</div>
|
|
<div class="top-right">
|
|
<button class="icon-btn" id="searchBtn" aria-label="Search"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.7" y2="16.7"/></svg></button>
|
|
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg><span class="notif-badge hidden" id="notifBadge"></span></button>
|
|
<a href="/login" class="btn-login">Log in</a>
|
|
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
|
</div>
|
|
</header>
|
|
|
|
<aside class="cat-drawer" id="catDrawer">
|
|
<nav class="cd-nav">
|
|
<a href="/">Home</a>
|
|
<a href="/rooms">Rooms</a>
|
|
<a href="/#feed">Creative Program</a>
|
|
<a href="/patch-notes">Patch Notes</a>
|
|
<a href="/support">Support</a>
|
|
<a href="/news">News</a>
|
|
<a href="/download">Download</a>
|
|
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
|
<a href="/accessibility">Accessibility</a>
|
|
</nav>
|
|
<button class="cd-theme-toggle"></button>
|
|
</aside>
|
|
<div class="side-scrim" id="scrim"></div>
|
|
|
|
<main id="main">
|
|
<div class="sup-page">
|
|
|
|
<div class="sup-hero">
|
|
<div class="hero-ic">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
|
|
</div>
|
|
<h1>How can we help?</h1>
|
|
<p>Get quick answers from the assistant below, or open a ticket and talk to a real member of the Campus team.</p>
|
|
</div>
|
|
|
|
<div class="sup-tabs" id="tabs">
|
|
<button data-tab="help" class="active">Help assistant</button>
|
|
<button data-tab="mine" id="tabMine" hidden>My tickets</button>
|
|
<button data-tab="staff" id="tabStaff" hidden>All tickets <span class="pill" id="openPill" hidden>0</span></button>
|
|
</div>
|
|
|
|
<!-- HELP ASSISTANT -->
|
|
<section class="panel active" id="panel-help">
|
|
<div class="surface">
|
|
<div class="asst-head">
|
|
<div class="asst-av"><img src="assets/cat-icon.png" alt=""></div>
|
|
<div>
|
|
<div class="asst-name">Vanessa</div>
|
|
<div class="asst-sub"><span class="asst-dot"></span> Answers common questions instantly</div>
|
|
</div>
|
|
</div>
|
|
<div class="chat" id="chat"></div>
|
|
<div class="human-bar">
|
|
<button class="human-btn" id="humanBtn">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
Talk to a real person
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- MY TICKETS -->
|
|
<section class="panel" id="panel-mine">
|
|
<div class="panel-top">
|
|
<h2>My tickets</h2>
|
|
<button class="btn-new" id="newTicketBtn"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> New ticket</button>
|
|
</div>
|
|
<div class="tlist" id="mineList"></div>
|
|
</section>
|
|
|
|
<!-- STAFF: ALL TICKETS -->
|
|
<section class="panel" id="panel-staff">
|
|
<div class="panel-top"><h2>All tickets</h2></div>
|
|
<div class="subfilter" id="staffFilter">
|
|
<button data-status="open" class="active">Open</button>
|
|
<button data-status="closed">Closed</button>
|
|
<button data-status="all">All</button>
|
|
</div>
|
|
<div class="tlist" id="staffList"></div>
|
|
</section>
|
|
|
|
</div>
|
|
</main>
|
|
|
|
<!-- THREAD MODAL -->
|
|
<div class="modal-scrim" id="scrimM"></div>
|
|
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="mTitle">
|
|
<div class="modal-head">
|
|
<div class="mnum" id="mNum">#</div>
|
|
<div class="mtitle"><h3 id="mTitle">Ticket</h3><div class="msub" id="mSub"></div></div>
|
|
<span class="badge" id="mBadge" hidden></span>
|
|
<button class="mtoggle" id="mToggle" hidden>Close ticket</button>
|
|
<button class="modal-x" id="mClose" aria-label="Close">×</button>
|
|
</div>
|
|
<div class="thread" id="thread"></div>
|
|
<div class="modal-foot" id="mFoot">
|
|
<div class="row">
|
|
<textarea class="field" id="mReply" placeholder="Write a reply…" rows="1"></textarea>
|
|
<button class="btn-send" id="mSend">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CREATE-TICKET MODAL (centred overlay, like the in-game report dialog) -->
|
|
<div class="modal-scrim" id="scrimT"></div>
|
|
<div class="modal" id="tmodal" role="dialog" aria-modal="true" aria-labelledby="tmTitle">
|
|
<div class="modal-head">
|
|
<div class="mnum"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v4a2 2 0 0 0 0 4v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 0 0-4V6a2 2 0 0 1 2-2z"/><path d="M9 4v16"/></svg></div>
|
|
<div class="mtitle"><h3 id="tmTitle">Open a support ticket</h3><div class="msub">A real member of the team will reply here on the website.</div></div>
|
|
<button class="modal-x" id="tmClose" aria-label="Close">×</button>
|
|
</div>
|
|
<div class="modal-body" id="tmBody"></div>
|
|
</div>
|
|
|
|
<!-- SEARCH PANEL (nav parity) -->
|
|
<div class="search-panel" id="searchPanel"><div class="search-card"><div class="search-tabs"><button class="stab active" data-tab="people">People</button><button class="stab" data-tab="rooms">Rooms</button></div><div class="search-field"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.7" y2="16.7"/></svg><input type="text" id="searchInput" placeholder="Search people…" autocomplete="off"></div><div class="search-results" id="searchResults"></div></div></div>
|
|
<div class="search-scrim" id="searchScrim"></div>
|
|
|
|
<script src="data.js?v=20260921l"></script>
|
|
<script src="app.js?v=20260921l"></script>
|
|
<script>
|
|
(function () {
|
|
const DISCORD = "https://discord.gg/xH8MMwK5Bk";
|
|
const chat = document.getElementById('chat');
|
|
const state = { signedIn:false, staff:false, username:"", currentTicket:null };
|
|
|
|
const el = (tag, cls, html) => { const e=document.createElement(tag); if(cls)e.className=cls; if(html!=null)e.innerHTML=html; return e; };
|
|
const esc = s => (s==null?"":String(s)).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
const when = s => { try { return new Date(s).toLocaleString(); } catch { return ""; } };
|
|
function ago(s){ try{ const d=(Date.now()-new Date(s).getTime())/1000; if(d<60)return"just now";
|
|
if(d<3600)return Math.floor(d/60)+"m ago"; if(d<86400)return Math.floor(d/3600)+"h ago";
|
|
if(d<604800)return Math.floor(d/86400)+"d ago"; return new Date(s).toLocaleDateString(); }catch{return"";} }
|
|
async function jget(u){ const r=await fetch(u,{credentials:'same-origin'}); return r.ok?r.json():null; }
|
|
async function jpost(u,b){ const r=await fetch(u,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):null}); let j=null; try{j=await r.json();}catch{} return {ok:r.ok,status:r.status,body:j}; }
|
|
|
|
// ---------- tabs ----------
|
|
const tabs = document.getElementById('tabs');
|
|
function showTab(name){
|
|
tabs.querySelectorAll('button').forEach(b=>b.classList.toggle('active', b.dataset.tab===name));
|
|
document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active', p.id==='panel-'+name));
|
|
if(name==='mine') loadMine();
|
|
if(name==='staff') loadStaff(currentStaffStatus);
|
|
}
|
|
tabs.addEventListener('click', e => { const b=e.target.closest('button'); if(b&&b.dataset.tab && !b.hidden) showTab(b.dataset.tab); });
|
|
|
|
// ---------- bot tree ----------
|
|
const TREE = {
|
|
root:{ bot:["Hi! I'm Vanessa, the Campus support assistant.","What do you need help with?"], options:[
|
|
{label:"Logging in & my account",to:"account"},{label:"Joining friends & invites",to:"join"},
|
|
{label:"Currency, rewards & gift boxes",to:"econ"},{label:"Report a player or a bug",to:"report"},
|
|
{label:"Something else",to:"other"} ]},
|
|
account:{ bot:["Account & login — pick one:"], options:[
|
|
{label:"How do I make an account?",answer:"Accounts are made here on the website. Click Sign up, choose a username, a password and your date of birth, then log in from inside the game with that username and password."},
|
|
{label:"I can't log in",answer:"Two things to check: (1) your Steam account has to be on the allowlist — ask an admin to add your SteamID64; (2) in-game, enter the exact username and password you made on the website. Still stuck? Open a ticket below."},
|
|
{label:"I forgot my password",answer:"We can reset it — open a ticket below and include your username."},
|
|
{label:"← Back",to:"root"} ]},
|
|
join:{ bot:["Joining & invites — pick one:"], options:[
|
|
{label:"I can't join my friend",answer:"Make sure you're both online and they're in a room you're allowed into (a private room needs you to be friends). Try again — if it keeps failing, open a ticket and tell us who you tried to join."},
|
|
{label:"Invites aren't working",answer:"Send the invite from the friends list. The other player has to be online (not appearing offline). If the invite button does nothing, open a ticket below."},
|
|
{label:"“Unable to change rooms”",answer:"That usually clears on a retry. If it happens every time, open a ticket with what you were joining (a player, an invite, or an event)."},
|
|
{label:"← Back",to:"root"} ]},
|
|
econ:{ bot:["Currency, rewards & gift boxes — pick one:"], options:[
|
|
{label:"I didn't get my reward",answer:"Rewards are given when you finish an activity in its room; some are a 3-item pick on your watch. If a reward truly never showed, open a ticket with the game mode."},
|
|
{label:"How do gift boxes work?",answer:"Finish a supported game mode and you're offered a choice of gift boxes for that mode — pick one and it lands in your inventory."},
|
|
{label:"My balance looks wrong",answer:"Balances are saved to your account. If a number looks off, open a ticket with a screenshot."},
|
|
{label:"← Back",to:"root"} ]},
|
|
report:{ bot:["Reporting — pick one:"], options:[
|
|
{label:"Report a player",answer:"For anything urgent use the in-game report on their player card. Otherwise open a ticket with their username and what happened."},
|
|
{label:"Report a bug",answer:"Bug reports really help! Open a ticket and describe what you did and what went wrong (a screenshot helps)."},
|
|
{label:"← Back",to:"root"} ]},
|
|
other:{ bot:["No problem — the quickest way for anything else is to open a ticket below, or join our Discord."], options:[
|
|
{label:"Open Discord",href:DISCORD},{label:"← Back",to:"root"} ]},
|
|
};
|
|
// Follow new messages once the user has engaged (so the hero stays in view on first load).
|
|
let engaged=false;
|
|
const scrollChat = () => { if(engaged) window.scrollTo({top:document.body.scrollHeight,behavior:'smooth'}); };
|
|
function botSay(lines, then){ const t=el('div','msg bot'); t.appendChild(el('span','typing','<span></span><span></span><span></span>')); chat.appendChild(t); scrollChat();
|
|
setTimeout(()=>{ t.remove(); (Array.isArray(lines)?lines:[lines]).forEach(x=>{const m=el('div','msg bot');m.textContent=x;chat.appendChild(m);}); scrollChat(); then&&then(); },420); }
|
|
function meSay(x){ const m=el('div','msg me'); m.textContent=x; chat.appendChild(m); scrollChat(); }
|
|
function renderNode(id){ const node=TREE[id]; if(!node)return;
|
|
botSay(node.bot, ()=>{ const w=el('div','msg bot'), c=el('div','choices'); w.style.background='transparent'; w.style.border='none'; w.style.padding='0'; w.style.maxWidth='100%';
|
|
node.options.forEach(o=>{ const b=el('button','choice'+(o.to==='root'?' ghost':''),o.label);
|
|
b.onclick=()=>{ engaged=true; meSay(o.label);
|
|
if(o.href){ window.open(o.href,'_blank','noopener'); return; }
|
|
if(o.answer){ botSay(o.answer, ()=>botSay("Did that help? If not, tap “Talk to a real person” below to open a ticket.", ()=>renderNode(id))); }
|
|
else if(o.to){ renderNode(o.to); } };
|
|
c.appendChild(b); });
|
|
w.appendChild(c); chat.appendChild(w); scrollChat(); }); }
|
|
|
|
// ---------- create-ticket modal (centred overlay) ----------
|
|
const tmodal=document.getElementById('tmodal'), scrimT=document.getElementById('scrimT'), tmBody=document.getElementById('tmBody');
|
|
const CATS=[
|
|
{v:"",label:"Choose a topic…"},
|
|
{v:"account",label:"Account / login issue"},
|
|
{v:"join",label:"Joining & invites"},
|
|
{v:"econ",label:"Currency, rewards & gift boxes"},
|
|
{v:"report_player",label:"Report a player"},
|
|
{v:"report_room",label:"Report a room"},
|
|
{v:"bug",label:"Bug report"},
|
|
{v:"other",label:"Something else"},
|
|
];
|
|
const catLabel = v => (CATS.find(c=>c.v===v)||{label:"Something else"}).label;
|
|
|
|
// The extra fields each topic asks for. type: text | at | date ; optional defaults to required.
|
|
const FIELDS={
|
|
account:[
|
|
{k:"username",label:"Your account username",type:"at",ph:"your username"},
|
|
{k:"created",label:"When did you create your account?",type:"date",hint:"Roughly when you signed up — it helps us find your account."},
|
|
{k:"lastlogin",label:"When did you last log in successfully?",type:"date",optional:true},
|
|
],
|
|
join:[
|
|
{k:"target",label:"Who were you trying to join?",type:"at",ph:"their username",optional:true},
|
|
{k:"room",label:"Room or event name",type:"text",ph:"e.g. Crescendo",optional:true},
|
|
],
|
|
econ:[
|
|
{k:"mode",label:"Which game mode or room?",type:"text",ph:"e.g. Isle of Lost Skulls",optional:true},
|
|
{k:"missing",label:"What reward or currency is missing?",type:"text",ph:"e.g. 100 Gold",optional:true},
|
|
],
|
|
report_player:[
|
|
{k:"player",label:"Player you're reporting",type:"at",ph:"their username"},
|
|
{k:"where",label:"Where did it happen?",type:"text",ph:"room or game mode",optional:true},
|
|
],
|
|
report_room:[
|
|
{k:"roomname",label:"Room name",type:"text",ph:"the room's name"},
|
|
{k:"owner",label:"Room owner",type:"at",ph:"owner's username",optional:true},
|
|
{k:"why",label:"What's wrong with the room?",type:"text",ph:"e.g. inappropriate content",optional:true},
|
|
],
|
|
bug:[
|
|
{k:"where",label:"Where did it happen?",type:"text",ph:"room, game mode or a menu",optional:true},
|
|
],
|
|
other:[],
|
|
};
|
|
|
|
function fieldHTML(f){
|
|
const id="tf_"+f.k;
|
|
const lab='<label class="fl" for="'+id+'">'+f.label+(f.optional?' <span class="opt">(optional)</span>':' <span class="rq">*</span>')+'</label>';
|
|
let input;
|
|
if(f.type==='at') input='<div class="at-field"><span class="at">@</span><input id="'+id+'" maxlength="40" autocomplete="off"'+(f.ph?' placeholder="'+f.ph+'"':'')+'></div>';
|
|
else if(f.type==='date') input='<input class="field" id="'+id+'" type="date">';
|
|
else input='<input class="field" id="'+id+'" maxlength="120" autocomplete="off"'+(f.ph?' placeholder="'+f.ph+'"':'')+'>';
|
|
return '<div class="frow">'+lab+input+(f.hint?'<div class="fhint">'+f.hint+'</div>':'')+'</div>';
|
|
}
|
|
function renderDynFields(cat){ const wrap=document.getElementById('tfDyn'); if(wrap) wrap.innerHTML=(FIELDS[cat]||[]).map(fieldHTML).join(''); }
|
|
|
|
function closeTicketModal(){ tmodal.classList.remove('open'); scrimT.classList.remove('open'); }
|
|
document.getElementById('tmClose').onclick=closeTicketModal; scrimT.onclick=closeTicketModal;
|
|
document.addEventListener('keydown', e=>{ if(e.key==='Escape' && tmodal.classList.contains('open')) closeTicketModal(); });
|
|
|
|
function openTicketModal(presetCat){
|
|
if(!state.signedIn){
|
|
tmBody.className='modal-login';
|
|
tmBody.innerHTML='<p>You need to be logged in to open a ticket, so the team can reply to you here on the website.</p>'+
|
|
'<div class="choices"><a class="choice primary" href="/login">Log in</a><a class="choice" href="/signup">Sign up</a>'+
|
|
'<a class="choice" href="'+DISCORD+'" target="_blank" rel="noopener">Ask on Discord</a></div>';
|
|
} else {
|
|
tmBody.className='modal-body';
|
|
tmBody.innerHTML = ticketFormHTML(presetCat||"");
|
|
wireTicketForm();
|
|
}
|
|
tmodal.classList.add('open'); scrimT.classList.add('open');
|
|
const f=document.getElementById('tfTitle'); if(f) setTimeout(()=>f.focus(),60);
|
|
}
|
|
|
|
function ticketFormHTML(cat){
|
|
const opts=CATS.map(c=>'<option value="'+c.v+'"'+(c.v===cat?' selected':'')+(c.v===''?' disabled':'')+'>'+c.label+'</option>').join('');
|
|
return ''+
|
|
'<div class="frow"><label class="fl" for="tfTitle">Title <span class="rq">*</span></label>'+
|
|
'<input class="field" id="tfTitle" maxlength="120" placeholder="Short summary (e.g. Can\'t log in)"></div>'+
|
|
'<div class="frow"><label class="fl" for="tfCat">What\'s it about? <span class="rq">*</span></label>'+
|
|
'<select class="field" id="tfCat">'+opts+'</select></div>'+
|
|
'<div id="tfDyn"></div>'+
|
|
'<div class="frow"><label class="fl" for="tfDiscord">Discord <span class="opt">(optional)</span></label>'+
|
|
'<input class="field" id="tfDiscord" maxlength="60" placeholder="yourname — so we can reach you" autocomplete="off"></div>'+
|
|
'<div class="frow"><label class="fl" for="tfDesc">Describe the problem <span class="rq">*</span></label>'+
|
|
'<textarea class="field" id="tfDesc" maxlength="4000" placeholder="What happened? Add anything that helps us reproduce it."></textarea></div>'+
|
|
'<div class="tform-foot"><span class="note" id="tfStatus"></span>'+
|
|
'<button type="button" class="btn-send" id="tfSubmit">Submit ticket</button></div>';
|
|
}
|
|
|
|
function wireTicketForm(){
|
|
const cat=document.getElementById('tfCat');
|
|
cat.addEventListener('change', ()=>renderDynFields(cat.value));
|
|
renderDynFields(cat.value);
|
|
document.getElementById('tfSubmit').onclick = submitTicket;
|
|
document.getElementById('tfDesc').addEventListener('keydown', e=>{ if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)) submitTicket(); });
|
|
}
|
|
|
|
async function submitTicket(){
|
|
const g=id=>document.getElementById(id);
|
|
const title=g('tfTitle').value.trim(), cat=g('tfCat').value, discord=g('tfDiscord').value.trim(), desc=g('tfDesc').value.trim();
|
|
const status=g('tfStatus'), btn=g('tfSubmit');
|
|
if(!title){ status.textContent="Please add a title."; g('tfTitle').focus(); return; }
|
|
if(!cat){ status.textContent="Please choose a topic."; g('tfCat').focus(); return; }
|
|
// gather the topic-specific fields (validate the required ones)
|
|
const extra=[];
|
|
for(const f of (FIELDS[cat]||[])){
|
|
const inp=g('tf_'+f.k); let v=(inp?inp.value:'').trim();
|
|
if(!v){ if(!f.optional){ status.textContent="Please fill in “"+f.label.replace(/\?$/,'')+"”."; if(inp) inp.focus(); return; } continue; }
|
|
if(f.type==='at') v='@'+v.replace(/^@+/,'');
|
|
extra.push(f.label.replace(/\?$/,'')+": "+v);
|
|
}
|
|
if(desc.length<3){ status.textContent="Please describe the problem."; g('tfDesc').focus(); return; }
|
|
const lines=["Issue type: "+catLabel(cat)].concat(extra);
|
|
if(discord) lines.push("Discord: "+discord);
|
|
const message = lines.join("\n") + "\n———\n" + desc;
|
|
btn.disabled=true; btn.textContent="Submitting…"; status.textContent="";
|
|
const res=await jpost('/api/site/support/create',{subject:title,message});
|
|
if(res.ok && res.body && res.body.id){
|
|
document.getElementById('tabMine').hidden=false;
|
|
closeTicketModal();
|
|
openThread(res.body.id);
|
|
} else {
|
|
btn.disabled=false; btn.textContent="Submit ticket";
|
|
status.textContent=(res.body&&res.body.error)||"Couldn't open the ticket. Please try again.";
|
|
}
|
|
}
|
|
|
|
// entry points -> the same centred modal
|
|
document.getElementById('humanBtn').addEventListener('click', ()=>openTicketModal());
|
|
document.getElementById('newTicketBtn').onclick = ()=>openTicketModal();
|
|
|
|
// ---------- lists ----------
|
|
function ticketRow(t, showUser){
|
|
const r=el('div','trow');
|
|
const meta = (showUser?'<span class="who">@'+esc(t.username)+'</span>':'')+
|
|
'<span>'+esc(ago(t.updatedAt||t.createdAt))+'</span>'+
|
|
'<span>'+ (t.messageCount||0) +' message'+((t.messageCount===1)?'':'s')+'</span>';
|
|
r.innerHTML='<div class="tnum">#'+t.id+'</div>'+
|
|
'<div class="tmain"><div class="tsub">'+esc(t.subject)+'</div>'+
|
|
'<div class="tprev">'+esc(t.lastMessage||'No messages yet')+'</div>'+
|
|
'<div class="tmeta">'+meta+'</div></div>'+
|
|
'<span class="badge '+esc(t.status)+'">'+esc(t.status)+'</span>';
|
|
r.onclick=()=>openThread(t.id);
|
|
return r;
|
|
}
|
|
const emptyState = (txt) =>
|
|
'<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'+txt+'</div>';
|
|
async function loadMine(silent){
|
|
const list=document.getElementById('mineList'); if(!silent && !list.children.length) list.innerHTML='<div class="empty">Loading…</div>';
|
|
const j=await jget('/api/site/support/mine');
|
|
const ts=(j&&j.tickets)||[];
|
|
const frag=document.createDocumentFragment();
|
|
if(!ts.length){ list.innerHTML=emptyState("No tickets yet. Open one from the Help assistant."); return; }
|
|
ts.forEach(t=>frag.appendChild(ticketRow(t,false)));
|
|
list.replaceChildren(frag);
|
|
}
|
|
let currentStaffStatus='open';
|
|
document.getElementById('staffFilter').addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b)return;
|
|
currentStaffStatus=b.dataset.status; document.querySelectorAll('#staffFilter button').forEach(x=>x.classList.toggle('active',x===b)); loadStaff(currentStaffStatus); });
|
|
async function loadStaff(status, silent){
|
|
const list=document.getElementById('staffList'); if(!silent && !list.children.length) list.innerHTML='<div class="empty">Loading…</div>';
|
|
const j=await jget('/api/site/support/all?status='+encodeURIComponent(status));
|
|
const ts=(j&&j.tickets)||[];
|
|
const frag=document.createDocumentFragment();
|
|
if(!ts.length){ list.innerHTML=emptyState("No "+status+" tickets."); return; }
|
|
ts.forEach(t=>frag.appendChild(ticketRow(t,true)));
|
|
list.replaceChildren(frag);
|
|
}
|
|
|
|
// ---------- thread modal ----------
|
|
const modal=document.getElementById('modal'), scrimM=document.getElementById('scrimM');
|
|
function closeModal(){ modal.classList.remove('open'); scrimM.classList.remove('open'); state.currentTicket=null; stopThreadPoll(); }
|
|
document.getElementById('mClose').onclick=closeModal; scrimM.onclick=closeModal;
|
|
document.addEventListener('keydown', e=>{ if(e.key==='Escape' && modal.classList.contains('open')) closeModal(); });
|
|
async function openThread(id){
|
|
const j=await jget('/api/site/support/ticket/'+id);
|
|
if(!j||!j.ticket){ alert("Couldn't open that ticket."); return; }
|
|
renderThread(j.ticket, j.staff);
|
|
modal.classList.add('open'); scrimM.classList.add('open');
|
|
startThreadPoll(id);
|
|
}
|
|
|
|
// ---------- real-time updates (polling) ----------
|
|
function sigOf(t){ const m=t.messages||[]; return t.status+'|'+m.length+'|'+(m.length?m[m.length-1].at:''); }
|
|
let threadTimer=null;
|
|
function stopThreadPoll(){ if(threadTimer){ clearInterval(threadTimer); threadTimer=null; } }
|
|
function startThreadPoll(id){
|
|
stopThreadPoll();
|
|
threadTimer=setInterval(async ()=>{
|
|
if(!modal.classList.contains('open')){ stopThreadPoll(); return; }
|
|
const j=await jget('/api/site/support/ticket/'+id);
|
|
if(!j||!j.ticket) return;
|
|
if(sigOf(j.ticket)===state.threadSig) return; // nothing new
|
|
const box=document.getElementById('mReply');
|
|
const draft=box?box.value:'', wasFocused=box && document.activeElement===box;
|
|
renderThread(j.ticket, j.staff); // re-render with the new message(s)
|
|
const nb=document.getElementById('mReply');
|
|
if(nb){ nb.value=draft; if(wasFocused){ nb.focus(); nb.selectionStart=nb.selectionEnd=draft.length; } }
|
|
refreshLists();
|
|
}, 5000);
|
|
}
|
|
// keep the visible ticket list and the staff open-count pill fresh in the background
|
|
setInterval(async ()=>{
|
|
if(document.getElementById('panel-mine').classList.contains('active')) loadMine(true);
|
|
else if(document.getElementById('panel-staff').classList.contains('active')) loadStaff(currentStaffStatus, true);
|
|
if(state.staff){ const r=await jget('/api/site/support/role'); if(r){ const p=document.getElementById('openPill');
|
|
if(r.openCount>0){ p.hidden=false; p.textContent=r.openCount; } else p.hidden=true; } }
|
|
}, 8000);
|
|
const initial = s => (s||'?').trim().charAt(0).toUpperCase();
|
|
// The opening message is composed by the ticket form as "Label: value" lines, a "———"
|
|
// divider, then the description. Pull those apart so we can show a tidy details panel.
|
|
function parseOpener(body){
|
|
const idx = body.indexOf('\n———');
|
|
if(idx<0) return null;
|
|
const head = body.slice(0, idx).trim();
|
|
const rest = body.slice(idx).replace(/^\s*———\s*/, '').trim();
|
|
const rows=[]; let ok=false;
|
|
head.split('\n').forEach(line=>{ const m=line.match(/^([^:]{1,40}):\s*(.+)$/); if(m){ ok=true; rows.push([m[1].trim(), m[2].trim()]); } });
|
|
return ok ? {rows, body:rest} : null;
|
|
}
|
|
function detailsHTML(rows){
|
|
const dl = rows.map(([k,v])=>{ const at=/^@/.test(v); return '<dt>'+esc(k)+'</dt><dd'+(at?' class="at"':'')+'>'+esc(v)+'</dd>'; }).join('');
|
|
return '<div class="tdetails"><div class="dh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Ticket details</div><dl>'+dl+'</dl></div>';
|
|
}
|
|
function msgHTML(name, staffMsg, at, body, avatar){
|
|
const cls = staffMsg?'staff':'user';
|
|
const av = avatar
|
|
? '<div class="av-sm img" style="background-image:url(\''+esc(avatar)+'\')"></div>'
|
|
: '<div class="av-sm '+cls+'">'+esc(initial(name))+'</div>';
|
|
return '<div class="msgrow '+cls+'">'+av+
|
|
'<div class="bcol"><div class="who">'+esc(name)+(staffMsg?' · staff':'')+' · '+esc(ago(at))+'</div>'+
|
|
'<div class="bubble '+cls+'">'+esc(body)+'</div></div></div>';
|
|
}
|
|
function renderThread(t, staff){
|
|
state.currentTicket=t;
|
|
document.getElementById('mNum').textContent='#'+t.id;
|
|
document.getElementById('mTitle').textContent=t.subject;
|
|
document.getElementById('mSub').textContent='@'+t.username+' · opened '+when(t.createdAt);
|
|
const bd=document.getElementById('mBadge'); bd.hidden=false; bd.className='badge '+t.status; bd.textContent=t.status;
|
|
const th=document.getElementById('thread'); th.innerHTML='';
|
|
state.threadSig=sigOf(t);
|
|
const msgs=(t.messages||[]).slice();
|
|
// first message: split its structured header into a details panel
|
|
if(msgs.length){
|
|
const first=msgs[0], parsed=parseOpener(first.body||'');
|
|
if(parsed){
|
|
th.insertAdjacentHTML('beforeend', detailsHTML(parsed.rows));
|
|
if(parsed.body) th.insertAdjacentHTML('beforeend', msgHTML(first.fromName, first.staff, first.at, parsed.body, first.avatar));
|
|
msgs.shift();
|
|
}
|
|
}
|
|
msgs.forEach(m=> th.insertAdjacentHTML('beforeend', msgHTML(m.fromName, m.staff, m.at, m.body, m.avatar)) );
|
|
th.scrollTop=th.scrollHeight;
|
|
const tg=document.getElementById('mToggle'); tg.hidden=false;
|
|
tg.textContent = t.status==='open' ? (staff?'Close ticket':'Close my ticket') : 'Reopen';
|
|
tg.onclick = async () => { const path = t.status==='open'?'close':'reopen'; await jpost('/api/site/support/ticket/'+t.id+'/'+path); openThread(t.id); refreshLists(); };
|
|
// footer: only show the reply box on open tickets
|
|
const foot=document.getElementById('mFoot');
|
|
foot.innerHTML = (t.status==='open')
|
|
? '<div class="row"><textarea class="field" id="mReply" placeholder="Write a reply…" rows="1"></textarea><button class="btn-send" id="mSend">Send</button></div>'
|
|
: '<div class="mfoot-closed">This ticket is closed. '+(staff?'Reopen it':'Reply or reopen it')+' from the button above.</div>';
|
|
if(t.status==='open') wireSend();
|
|
}
|
|
function wireSend(){
|
|
const btn=document.getElementById('mSend'), box=document.getElementById('mReply');
|
|
const send = async () => {
|
|
const t=state.currentTicket; if(!t) return;
|
|
const v=box.value.trim(); if(!v) return;
|
|
btn.disabled=true;
|
|
const res=await jpost('/api/site/support/ticket/'+t.id+'/reply',{message:v});
|
|
btn.disabled=false;
|
|
if(res.ok && res.body && res.body.ticket){ renderThread(res.body.ticket,res.body.staff); refreshLists(); }
|
|
};
|
|
btn.onclick=send;
|
|
box.addEventListener('keydown', e=>{ if(e.key==='Enter' && (e.ctrlKey||e.metaKey)){ e.preventDefault(); send(); } });
|
|
}
|
|
function refreshLists(){ if(document.getElementById('panel-mine').classList.contains('active')) loadMine(true);
|
|
if(document.getElementById('panel-staff').classList.contains('active')) loadStaff(currentStaffStatus, true); }
|
|
|
|
// ---------- deep links ----------
|
|
// /support/ticket/{id} -> open that thread ; /support/logs -> staff "All tickets"
|
|
function handleDeepLink(){
|
|
const path = (window.location.pathname||"").replace(/\/+$/,"");
|
|
const m = path.match(/\/support\/ticket\/(\d+)$/i);
|
|
if(m){ openThread(parseInt(m[1],10)); return; }
|
|
if(/\/support\/logs$/i.test(path)){
|
|
if(state.staff) showTab('staff');
|
|
else if(state.signedIn) showTab('mine');
|
|
}
|
|
}
|
|
|
|
// ---------- boot ----------
|
|
(async function(){
|
|
renderNode('root');
|
|
const role=await jget('/api/site/support/role');
|
|
if(role){ state.signedIn=!!role.signedIn; state.staff=!!role.staff; state.username=role.username||"";
|
|
if(state.signedIn) document.getElementById('tabMine').hidden=false;
|
|
if(state.staff){ const ts=document.getElementById('tabStaff'); ts.hidden=false;
|
|
if(role.openCount>0){ const p=document.getElementById('openPill'); p.hidden=false; p.textContent=role.openCount; } }
|
|
}
|
|
handleDeepLink();
|
|
})();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|