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
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
// Waffle app launcher. Strict port of project/platform-app.jsx `AppLauncher`
// (lines 303-377). Right-aligned drop-in (margin 64px 20px 0 0, width 440),
// 3-col grid of centered tiles. The app you're currently inside is rendered
// with a signal-yellow icon container and a "HERE" pill.
import type { IconName } from './UiIcon.vue'
const launcher = useAppLauncher()
const route = useRoute()
const partnerMode = usePartnerMode()
interface Tile {
key: string
name: string
icon: IconName
ext: string
current?: boolean
}
// Section context drives which extras (Admin / Partner) appear in the grid,
// and which tile is marked `current` ("HERE" pill).
const section = computed<'partner' | 'admin' | 'user'>(() => {
if (partnerMode.isActive.value) return 'admin'
if (route.path.startsWith('/partner')) return 'partner'
if (route.path.startsWith('/admin')) return 'admin'
return 'user'
})
const tiles = computed<Tile[]>(() => {
const isAdmin = section.value === 'admin'
const isPartner = section.value === 'partner'
const base: Tile[] = [
{ key: 'mail', name: 'Mail', icon: 'mail', ext: 'mail.dezky.com' },
{ key: 'drev', name: 'Drev', icon: 'folder', ext: 'drev.dezky.com' },
{ key: 'moder', name: 'Møder', icon: 'video', ext: 'meet.dezky.com' },
{ key: 'chat', name: 'Chat', icon: 'chat', ext: 'chat.dezky.com' },
{ key: 'cal', name: 'Kalender', icon: 'calendar', ext: 'cal.dezky.com' },
{ key: 'contacts', name: 'Kontakter', icon: 'users', ext: 'contacts.dezky.com' },
]
if (isAdmin) {
base.push({ key: 'admin', name: 'Admin', icon: 'shield', ext: 'admin.dezky.com', current: !isPartner })
}
if (isPartner) {
base.push({ key: 'partner', name: 'Partner', icon: 'briefcase', ext: 'partner.nordicmsp.dk', current: true })
}
base.push({ key: 'docs', name: 'Docs', icon: 'file', ext: 'docs.dezky.com' })
return base
})
const toast = useToast()
function open(t: Tile) {
launcher.hide()
if (t.key === 'admin') return navigateTo('/admin')
if (t.key === 'partner') return navigateTo('/partner')
toast.info(`Opening ${t.name}`, t.ext)
}
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && launcher.open.value) launcher.hide()
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<Teleport to="body">
<Transition name="launcher">
<div v-if="launcher.open.value" class="scrim" @click="launcher.hide">
<div class="panel" @click.stop>
<header>
<div class="head-meta">
<Eyebrow>Apps</Eyebrow>
<div class="head-title">Open in new tab</div>
</div>
<button class="x" @click="launcher.hide" aria-label="Close">
<UiIcon name="x" :size="16" />
</button>
</header>
<div class="grid">
<a
v-for="t in tiles"
:key="t.key"
href="#"
class="tile"
@click.prevent="open(t)"
>
<span class="tile-icon" :class="{ current: t.current }">
<UiIcon :name="t.icon" :size="20" />
</span>
<span class="tile-name">{{ t.name }}</span>
<span v-if="t.current" class="here">HERE</span>
</a>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
background: rgba(10, 10, 10, 0.36);
z-index: 75;
display: flex;
justify-content: flex-end;
}
.panel {
margin: 64px 20px 0 0;
width: 440px;
height: max-content;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.head-meta { display: flex; flex-direction: column; gap: 2px; }
.head-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
letter-spacing: -0.015em;
margin-top: 2px;
}
.x {
background: transparent;
border: 0;
padding: 6px;
border-radius: 4px;
color: var(--text-mute);
cursor: pointer;
}
.x:hover { background: var(--surface); color: var(--text); }
.grid {
padding: 12px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.tile {
position: relative;
padding: 14px 8px;
border-radius: 6px;
text-decoration: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text);
transition: background 0.12s;
}
.tile:hover { background: var(--row-hover); }
.tile-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
}
.tile-icon.current {
background: var(--accent);
color: var(--accent-fg);
}
.tile-name {
font-size: 12px;
font-weight: 500;
text-align: center;
}
.here {
position: absolute;
top: 8px;
right: 8px;
font-family: var(--font-mono);
font-size: 9px;
font-weight: 600;
color: var(--accent-fg);
background: var(--accent);
padding: 1px 4px;
border-radius: 2px;
letter-spacing: 0.04em;
}
.launcher-enter-active, .launcher-leave-active { transition: opacity 0.14s; }
.launcher-enter-from, .launcher-leave-to { opacity: 0; }
.launcher-enter-active .panel { animation: launcherIn 0.18s ease-out; }
@keyframes launcherIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
</style>