Files
dezky/apps/portal/pages/help.vue
T
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- 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
2026-05-28 20:00:33 +02:00

485 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Help & support. Faithfully ports project/platform-admin.jsx `HelpScreen`
// (lines 306610). 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 MonFri · 08:0018: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>