0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
485 lines
19 KiB
Vue
485 lines
19 KiB
Vue
<script setup lang="ts">
|
||
// Help & support. Faithfully ports project/platform-admin.jsx `HelpScreen`
|
||
// (lines 306–610). 4 tabs: Knowledge base / My tickets / New ticket / Contact.
|
||
// A SidePanel opens for a ticket's full conversation thread.
|
||
|
||
|
||
import { helpArticles, myTickets } from '~/data/enduser'
|
||
|
||
const toast = useToast()
|
||
const tab = ref('kb')
|
||
|
||
// --- Knowledge base ---
|
||
const q = ref('')
|
||
|
||
const popular = computed(() => helpArticles.filter((a) => a.popular))
|
||
const categories = computed(() => {
|
||
const map = new Map<string, typeof helpArticles>()
|
||
for (const a of helpArticles) {
|
||
if (!map.has(a.category)) map.set(a.category, [])
|
||
map.get(a.category)!.push(a)
|
||
}
|
||
return Array.from(map.entries())
|
||
})
|
||
|
||
// --- Tickets ---
|
||
const openTicket = ref<typeof myTickets[number] | null>(null)
|
||
|
||
// Ticket conversation thread — mirrors source TicketDetail messages.
|
||
const ticketThread = computed(() => {
|
||
if (!openTicket.value) return []
|
||
return [
|
||
{ who: 'You', when: `${openTicket.value.age} ago`, them: false,
|
||
body: `Hi — we're seeing slow delivery to Gmail recipients from @dezky.com. Started yesterday around 14:00 CET. SPF and DKIM all check out via mxtoolbox. Could you investigate?` },
|
||
{ who: 'Sofie Lindberg', when: '4 h ago', them: true,
|
||
body: `Thanks for the detailed report — we've reproduced it. Looks like our outbound IP got temporarily greylisted by Google after a brief spike. Working with Postmark to resolve. ETA 30 minutes.` },
|
||
{ who: 'Sofie Lindberg', when: '2 h ago', them: true,
|
||
body: `Postmark resolved the greylisting. Delivery should be back to normal. Can you confirm on your end and we'll close this out?` },
|
||
]
|
||
})
|
||
|
||
// --- New ticket ---
|
||
const newTicket = reactive({
|
||
subject: '',
|
||
affected: 'Mail · delivery to Gmail',
|
||
severity: 'P3' as 'P1' | 'P2' | 'P3' | 'P4',
|
||
body: '',
|
||
})
|
||
|
||
function submitTicket() {
|
||
toast.ok('Ticket submitted', `Severity ${newTicket.severity} · we'll reply within SLA`)
|
||
newTicket.subject = ''
|
||
newTicket.body = ''
|
||
tab.value = 'tickets'
|
||
}
|
||
|
||
// Tone resolver for ticket statuses (matches source TicketsTab inline logic).
|
||
function ticketTone(status: string): 'ok' | 'info' | 'warn' {
|
||
if (status === 'resolved') return 'ok'
|
||
if (status === 'in progress') return 'info'
|
||
return 'warn'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<PageHeader
|
||
eyebrow="Get unstuck"
|
||
title="Help & support"
|
||
subtitle="Search the knowledge base, file a ticket, or pick up an existing conversation."
|
||
>
|
||
<template #actions>
|
||
<UiButton variant="secondary" @click="toast.info('Opening live chat')">
|
||
<template #leading><UiIcon name="chat" :size="13" /></template>
|
||
Live chat
|
||
</UiButton>
|
||
<UiButton variant="primary" @click="tab = 'new'">
|
||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||
New ticket
|
||
</UiButton>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<div class="tabs-wrap">
|
||
<Tabs
|
||
v-model="tab"
|
||
:items="[
|
||
{ value: 'kb', label: 'Knowledge base' },
|
||
{ value: 'tickets', label: 'My tickets', count: myTickets.length },
|
||
{ value: 'new', label: 'New ticket' },
|
||
{ value: 'contact', label: 'Contact' },
|
||
]"
|
||
/>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<!-- Knowledge base -->
|
||
<section v-if="tab === 'kb'">
|
||
<div class="search-wrap">
|
||
<div class="search">
|
||
<UiIcon name="search" :size="18" stroke="var(--text-mute)" />
|
||
<input v-model="q" placeholder="Search articles… try 'MFA setup' or 'OIOUBL'" />
|
||
<span class="kbd">⌘/</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Popular row -->
|
||
<div class="kb-section">
|
||
<Eyebrow style="display: block; margin-bottom: 12px;">Popular</Eyebrow>
|
||
<div class="popular-grid">
|
||
<button v-for="a in popular" :key="a.id" class="popular-tile" @click="toast.info('Opening ' + a.title)">
|
||
<span class="pt-icon"><UiIcon name="file" :size="16" /></span>
|
||
<div class="pt-text">
|
||
<div class="pt-title">{{ a.title }}</div>
|
||
<Mono dim>{{ a.category }} · {{ a.read }}</Mono>
|
||
</div>
|
||
<UiIcon name="arrowRight" :size="14" stroke="var(--text-mute)" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- All categories -->
|
||
<Eyebrow style="display: block; margin-bottom: 12px;">All categories</Eyebrow>
|
||
<div class="cat-grid">
|
||
<Card v-for="[cat, articles] in categories" :key="cat" :pad="0">
|
||
<div class="cat-head">
|
||
<div class="cat-name">{{ cat }}</div>
|
||
<Mono dim>{{ articles.length }} article{{ articles.length > 1 ? 's' : '' }}</Mono>
|
||
</div>
|
||
<button
|
||
v-for="a in articles"
|
||
:key="a.id"
|
||
class="cat-row"
|
||
@click="toast.info('Opening ' + a.title)"
|
||
>
|
||
<div class="cr-text">
|
||
<div class="cr-title">{{ a.title }}</div>
|
||
<Mono dim>{{ a.read }} read</Mono>
|
||
</div>
|
||
<UiIcon name="chevRight" :size="13" stroke="var(--text-mute)" />
|
||
</button>
|
||
</Card>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- My tickets -->
|
||
<section v-else-if="tab === 'tickets'">
|
||
<Card :pad="0">
|
||
<table class="tickets">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Subject</th>
|
||
<th>Status</th>
|
||
<th>Severity</th>
|
||
<th>Age</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="t in myTickets" :key="t.id" @click="openTicket = t">
|
||
<td><Mono>{{ t.id }}</Mono></td>
|
||
<td>
|
||
<div class="t-subj">{{ t.title }}</div>
|
||
<Mono dim>{{ t.updates }} update{{ t.updates > 1 ? 's' : '' }} · last {{ t.last }}</Mono>
|
||
</td>
|
||
<td><Badge :tone="ticketTone(t.status)" dot>{{ t.status }}</Badge></td>
|
||
<td><Mono>{{ t.severity }}</Mono></td>
|
||
<td><Mono dim>{{ t.age }}</Mono></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</section>
|
||
|
||
<!-- New ticket -->
|
||
<section v-else-if="tab === 'new'">
|
||
<div class="new-wrap">
|
||
<p class="new-intro">
|
||
Tell us what's not working and we'll get back to you within your plan's SLA. Most P3 tickets are answered within 4 hours during business days.
|
||
</p>
|
||
<div class="new-form">
|
||
<EnduserFormField label="Subject">
|
||
<input v-model="newTicket.subject" placeholder="What's the problem in one sentence?" />
|
||
</EnduserFormField>
|
||
<EnduserFormField label="Affected area">
|
||
<input v-model="newTicket.affected" />
|
||
</EnduserFormField>
|
||
<EnduserFormField label="Severity">
|
||
<div class="sev-row">
|
||
<button
|
||
v-for="s in (['P1', 'P2', 'P3', 'P4'] as const)"
|
||
:key="s"
|
||
:class="{ active: newTicket.severity === s }"
|
||
@click="newTicket.severity = s"
|
||
>{{ s }}</button>
|
||
</div>
|
||
<div class="sev-help">
|
||
<b>P1</b> · outage affecting whole org · <b>P2</b> · major feature broken · <b>P3</b> · standard · <b>P4</b> · question / feature request
|
||
</div>
|
||
</EnduserFormField>
|
||
<EnduserFormField label="What happened">
|
||
<textarea v-model="newTicket.body" placeholder="What did you try? What did you expect to happen? What actually happened?" rows="6" />
|
||
</EnduserFormField>
|
||
<EnduserFormField label="Attachments">
|
||
<button class="drop" @click="toast.info('File picker stub')">
|
||
<UiIcon name="upload" :size="14" />
|
||
<span>Drag screenshots or click to browse · 25 MB limit</span>
|
||
</button>
|
||
</EnduserFormField>
|
||
</div>
|
||
<div class="form-actions">
|
||
<UiButton variant="ghost" @click="toast.info('Draft saved')">Save draft</UiButton>
|
||
<UiButton variant="primary" @click="submitTicket">
|
||
<template #leading><UiIcon name="mail" :size="13" /></template>
|
||
Submit ticket
|
||
</UiButton>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Contact -->
|
||
<section v-else-if="tab === 'contact'">
|
||
<div class="contact-grid">
|
||
<Card>
|
||
<div class="c-card">
|
||
<span class="c-tile primary"><UiIcon name="chat" :size="20" /></span>
|
||
<div>
|
||
<div class="c-l">Live chat</div>
|
||
<div class="c-d">Available Mon–Fri · 08:00–18:00 CET</div>
|
||
</div>
|
||
<UiButton variant="primary" @click="toast.info('Live chat opening')">
|
||
Open chat
|
||
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
|
||
</UiButton>
|
||
</div>
|
||
</Card>
|
||
<Card>
|
||
<div class="c-card">
|
||
<span class="c-tile"><UiIcon name="mail" :size="20" /></span>
|
||
<div>
|
||
<div class="c-l">Email</div>
|
||
<div class="c-d">support@dezky.com · response within 4h</div>
|
||
</div>
|
||
<UiButton variant="secondary" @click="toast.info('Composing email')">
|
||
Compose mail
|
||
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
|
||
</UiButton>
|
||
</div>
|
||
</Card>
|
||
<Card>
|
||
<div class="c-card">
|
||
<span class="c-tile"><UiIcon name="video" :size="20" /></span>
|
||
<div>
|
||
<div class="c-l">Schedule a call</div>
|
||
<div class="c-d">For complex setup or migrations</div>
|
||
</div>
|
||
<UiButton variant="secondary" @click="toast.info('Opening scheduler')">
|
||
Book 30 min
|
||
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
|
||
</UiButton>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<div class="escalation">
|
||
<Eyebrow style="display: block; margin-bottom: 10px;">Escalation</Eyebrow>
|
||
<div class="esc-grid">
|
||
<div>
|
||
<Mono dim>P1 outage · 24/7</Mono>
|
||
<div class="esc-val">+45 70 70 12 34 · oncall@dezky.com</div>
|
||
</div>
|
||
<div>
|
||
<Mono dim>Account manager</Mono>
|
||
<div class="esc-val">Mette Holst · mette@dezky.com</div>
|
||
</div>
|
||
<div>
|
||
<Mono dim>Status page</Mono>
|
||
<div class="esc-val"><a href="#">status.dezky.com</a></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- Ticket detail side panel -->
|
||
<SidePanel
|
||
:open="openTicket !== null"
|
||
width="lg"
|
||
:eyebrow="openTicket?.id"
|
||
:title="openTicket?.title"
|
||
@close="openTicket = null"
|
||
>
|
||
<template #header v-if="openTicket">
|
||
<div class="ticket-head">
|
||
<Badge :tone="ticketTone(openTicket.status)" dot>{{ openTicket.status }}</Badge>
|
||
<Badge tone="neutral">{{ openTicket.severity }}</Badge>
|
||
<Mono dim>opened {{ openTicket.age }} ago</Mono>
|
||
</div>
|
||
</template>
|
||
<div class="thread">
|
||
<div v-for="(m, i) in ticketThread" :key="i" class="msg" :data-them="m.them">
|
||
<div class="msg-head">
|
||
<Avatar v-if="m.them" :name="m.who" :size="24" />
|
||
<span v-else class="msg-you">YOU</span>
|
||
<span class="msg-who">{{ m.who }}</span>
|
||
<Mono dim>{{ m.when }}</Mono>
|
||
</div>
|
||
<div class="msg-body">{{ m.body }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="reply-box">
|
||
<textarea placeholder="Write a reply…" rows="4" />
|
||
</div>
|
||
<template #footer>
|
||
<UiButton variant="ghost" @click="toast.ok('Ticket marked resolved')">Mark as resolved</UiButton>
|
||
<div style="flex: 1;" />
|
||
<UiButton variant="primary" @click="toast.info('Reply sent')">
|
||
<template #leading><UiIcon name="mail" :size="13" /></template>
|
||
Reply
|
||
</UiButton>
|
||
</template>
|
||
</SidePanel>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
|
||
.content { padding: 20px 40px 64px 40px; }
|
||
|
||
/* KB search */
|
||
.search-wrap { max-width: 720px; margin: 0 auto 28px auto; }
|
||
.search {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 0 20px; height: 56px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
}
|
||
.search input {
|
||
flex: 1; border: none; outline: none; background: transparent;
|
||
font-size: 15px; color: var(--text); font-family: inherit;
|
||
}
|
||
.kbd {
|
||
font-family: var(--font-mono); font-size: 11px;
|
||
padding: 3px 8px; background: var(--bg);
|
||
border-radius: 4px; color: var(--text-mute);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.kb-section { margin-bottom: 28px; }
|
||
|
||
/* Popular tiles */
|
||
.popular-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||
.popular-tile {
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 18px;
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||
cursor: pointer; font-family: inherit; color: var(--text); text-align: left;
|
||
}
|
||
.popular-tile:hover { border-color: var(--text); }
|
||
.pt-icon {
|
||
width: 36px; height: 36px; border-radius: 7px;
|
||
background: var(--text); color: var(--bg);
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.pt-text { flex: 1; min-width: 0; }
|
||
.pt-title { font-size: 14px; font-weight: 500; }
|
||
.pt-text :deep(.mono) { display: block; margin-top: 2px; }
|
||
|
||
/* Categories grid */
|
||
.cat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||
.cat-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||
.cat-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
|
||
.cat-head :deep(.mono) { display: block; margin-top: 2px; }
|
||
.cat-row {
|
||
display: flex; align-items: center; gap: 10px;
|
||
width: 100%; padding: 12px 20px;
|
||
background: transparent; border: none; border-bottom: 1px solid var(--border);
|
||
cursor: pointer; font-family: inherit; color: var(--text); text-align: left;
|
||
}
|
||
.cat-row:last-child { border-bottom: none; }
|
||
.cat-row:hover { background: var(--row-hover); }
|
||
.cr-text { flex: 1; min-width: 0; }
|
||
.cr-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cr-text :deep(.mono) { display: block; margin-top: 2px; }
|
||
|
||
/* Tickets table */
|
||
.tickets { width: 100%; border-collapse: collapse; }
|
||
.tickets thead th {
|
||
text-align: left;
|
||
padding: 12px 22px;
|
||
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute);
|
||
font-weight: 500; background: var(--bg); border-bottom: 1px solid var(--border);
|
||
}
|
||
.tickets tbody td {
|
||
padding: 14px 22px;
|
||
font-size: 13px;
|
||
border-bottom: 1px solid var(--border);
|
||
vertical-align: top;
|
||
}
|
||
.tickets tbody tr { cursor: pointer; }
|
||
.tickets tbody tr:hover { background: var(--row-hover); }
|
||
.tickets tbody tr:last-child td { border-bottom: none; }
|
||
.t-subj { font-size: 13px; font-weight: 500; }
|
||
.tickets td :deep(.mono) { display: block; margin-top: 2px; }
|
||
|
||
/* New ticket form */
|
||
.new-wrap { max-width: 680px; }
|
||
.new-intro { color: var(--text-dim); font-size: 14px; line-height: 1.6; margin: 0 0 24px 0; }
|
||
.new-form { display: flex; flex-direction: column; gap: 14px; }
|
||
.new-form textarea {
|
||
width: 100%; min-height: 140px; padding: 12px;
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||
font-size: 13px; color: var(--text); font-family: inherit; resize: vertical; line-height: 1.55;
|
||
box-sizing: border-box;
|
||
}
|
||
.sev-row { display: inline-flex; gap: 6px; }
|
||
.sev-row button {
|
||
padding: 8px 18px; border-radius: 6px;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
color: var(--text); font-family: inherit; font-size: 13px; font-weight: 500;
|
||
cursor: pointer;
|
||
}
|
||
.sev-row button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||
.sev-help { font-size: 12px; color: var(--text-mute); margin-top: 6px; line-height: 1.5; }
|
||
.sev-help b { color: var(--text); }
|
||
|
||
.drop {
|
||
width: 100%; padding: 20px 14px;
|
||
background: transparent; border: 1px dashed var(--border-hi); border-radius: 6px;
|
||
color: var(--text-mute); cursor: pointer; font-family: inherit; font-size: 13px;
|
||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||
}
|
||
.drop:hover { border-color: var(--text); color: var(--text); background: var(--row-hover); }
|
||
|
||
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
||
|
||
/* Contact */
|
||
.contact-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 1100px; margin-bottom: 16px; }
|
||
.c-card { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; }
|
||
.c-tile {
|
||
width: 44px; height: 44px; border-radius: 10px;
|
||
background: var(--bg); color: var(--text-dim);
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
}
|
||
.c-tile.primary { background: var(--text); color: var(--bg); }
|
||
.c-l { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||
.c-d { margin-top: 6px; font-size: 13px; color: var(--text-mute); line-height: 1.5; }
|
||
|
||
.escalation {
|
||
padding: 16px;
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||
max-width: 1100px;
|
||
}
|
||
.esc-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; font-size: 13px; }
|
||
.esc-val { margin-top: 4px; }
|
||
.esc-val a { color: inherit; }
|
||
|
||
/* Ticket detail side panel */
|
||
.ticket-head { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
|
||
.thread { display: flex; flex-direction: column; gap: 16px; }
|
||
.msg {
|
||
padding: 14px;
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||
}
|
||
.msg[data-them='true'] { background: var(--bg); }
|
||
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||
.msg-you {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
width: 24px; height: 24px; border-radius: 999px;
|
||
background: var(--text); color: var(--bg);
|
||
font-size: 10px; font-weight: 700;
|
||
}
|
||
.msg-who { font-size: 13px; font-weight: 500; }
|
||
.msg-body { font-size: 13px; line-height: 1.6; color: var(--text); }
|
||
|
||
.reply-box { padding-top: 16px; }
|
||
.reply-box textarea {
|
||
width: 100%; min-height: 100px; padding: 12px;
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||
font-size: 13px; color: var(--text); font-family: inherit; resize: vertical;
|
||
box-sizing: border-box;
|
||
}
|
||
</style>
|