Files
Campus-25/server/Server/2025-RecRoom-Server-E12354-main/Website/support.html
T
2026-10-14 06:59:51 +02:00

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">&times;</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">&times;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>