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
This commit is contained in:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
+484
View File
@@ -0,0 +1,484 @@
<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>