Files
dezky/apps/portal/pages/partner/branding.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

251 lines
8.6 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">
// Partner branding. Strict port of PartnerBrandingScreen
// (partner-screens.jsx lines 839-942). Three cards:
// • Your brand · NordicMSP identity
// • Customer defaults · what gets pushed to new customers (7 toggles)
// • Email templates · 2-col grid of 5 templates
import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue'
const toast = useToast()
const identityOpen = ref(false)
const editing = ref<EmailTemplate | null>(null)
// Customer defaults · partner-screens.jsx line 872-878
const defaults = ref([
{ l: 'Accent color', d: 'Cobalt #3F6BFF', on: true },
{ l: 'Product name pattern', d: '"{Customer} Workspace" e.g. Acme Workspace', on: true },
{ l: 'Custom subdomain', d: 'workspace.{customer-domain}', on: true },
{ l: 'Login screen', d: 'NordicMSP co-brand + customer logo', on: true },
{ l: 'Email templates', d: '5 templates · NordicMSP voice', on: true },
{ l: 'Allow customer override', d: 'Business plans and above', on: true },
{ l: 'Lock typography', d: 'Inter Tight + JetBrains Mono · brand-locked', on: false },
])
// Source mustache literals. Constructed in JS to avoid Vue parser eating
// nested {{ }} (see CRITICAL note in task brief).
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
const TAG_PLAN = '{' + '{plan.name}' + '}'
const templates = ref<EmailTemplate[]>([
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE} — managed by NordicMSP`, body: '', edited: '5 days ago' },
{ id: 'invitation', name: 'User invitation', subject: `Youve been invited to ${TAG_WORKSPACE}`, body: '', edited: '3 days ago' },
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
{ id: 'invoice', name: 'Invoice notification', subject: `Your NordicMSP invoice ${TAG_INVOICE}`, body: '', edited: '2 weeks ago' },
])
function saveTemplate(t: EmailTemplate) {
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
editing.value = null
toast.ok('Template saved', t.name)
}
</script>
<template>
<div>
<PageHeader
eyebrow="Whitelabel"
title="Partner branding"
subtitle="Your own brand identity, plus the defaults pushed to every customer you provision."
/>
<div class="content">
<!-- Your brand · identity card -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Your brand</Eyebrow>
<div class="card-title">NordicMSP identity</div>
<p class="sub">Shown in the partner console and on emails sent by your team.</p>
</div>
<UiButton size="sm" variant="ghost" @click="identityOpen = true">Edit</UiButton>
</div>
<div class="id-grid">
<dl class="def">
<div><dt>Display name</dt><dd>NordicMSP</dd></div>
<div><dt>Logo</dt><dd>nordic-logo.svg · 4:1 horizontal</dd></div>
<div><dt>Mark</dt><dd>nordic-mark.svg · 1:1</dd></div>
<div>
<dt>Primary color</dt>
<dd>
<div class="color-row">
<div class="color-swatch" style="background:#3F6BFF" />
<Mono>#3F6BFF</Mono>
</div>
</dd>
</div>
</dl>
<dl class="def">
<div><dt>Support email</dt><dd>support@nordicmsp.dk</dd></div>
<div><dt>Support phone</dt><dd>+45 70 70 12 34</dd></div>
<div><dt>Website</dt><dd>nordicmsp.dk</dd></div>
<div><dt>Reply-to</dt><dd>no-reply@nordicmsp.dk</dd></div>
</dl>
</div>
</Card>
<!-- Customer defaults · toggle list -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Customer defaults</Eyebrow>
<div class="card-title">What gets pushed to new customers</div>
<p class="sub">Applied at provisioning. Customers can override per their tier entitlements.</p>
</div>
</div>
<div class="defaults-list">
<div
v-for="(row, i) in defaults"
:key="row.l"
class="def-row"
:class="{ last: i === defaults.length - 1 }"
>
<div class="dr-meta">
<div class="dr-label">{{ row.l }}</div>
<div class="dr-detail">{{ row.d }}</div>
</div>
<button class="switch" :class="{ on: row.on }" @click="row.on = !row.on">
<span class="thumb" />
</button>
</div>
</div>
</Card>
<!-- Email templates · 2-col grid -->
<Card>
<Eyebrow>Templates</Eyebrow>
<div class="card-title">Email templates · NordicMSP defaults</div>
<div class="tpl-grid">
<button
v-for="t in templates"
:key="t.id"
class="tpl-row"
@click="editing = t"
>
<UiIcon name="mail" :size="14" />
<div class="tpl-meta">
<div class="tpl-top">
<span class="tpl-name">{{ t.name }}</span>
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
</div>
<Mono dim>edited {{ t.edited }}</Mono>
</div>
<UiIcon name="chevRight" :size="14" />
</button>
</div>
</Card>
</div>
<PartnerEditIdentityModal :open="identityOpen" @close="identityOpen = false" />
<PartnerEmailTemplateEditor
:template="editing"
brand-color="#3F6BFF"
brand-name="NordicMSP"
@close="editing = null"
@save="saveTemplate"
/>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1100px; }
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
margin-top: 4px;
}
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; max-width: 580px; line-height: 1.5; }
/* Identity DefList grid */
.id-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
.def div { display: grid; grid-template-columns: 140px 1fr; gap: 12px; font-size: 13px; align-items: center; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
.color-row { display: flex; align-items: center; gap: 8px; }
.color-swatch { width: 14px; height: 14px; border-radius: 3px; }
/* Defaults toggle list */
.defaults-list { display: flex; flex-direction: column; }
.def-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.def-row.last { border-bottom: none; }
.dr-meta { flex: 1; min-width: 0; }
.dr-label { font-size: 13px; font-weight: 500; }
.dr-detail { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.switch {
width: 36px;
height: 20px;
border-radius: 999px;
background: var(--border);
border: none;
padding: 2px;
display: inline-flex;
align-items: center;
cursor: pointer;
transition: background 150ms;
flex-shrink: 0;
}
.switch.on { background: var(--text); }
.thumb {
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--bg);
transition: transform 150ms;
}
.switch.on .thumb { transform: translateX(16px); }
/* Templates grid */
.tpl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
}
.tpl-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.tpl-row:hover { background: var(--row-hover); }
.tpl-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
.tpl-meta { flex: 1; min-width: 0; }
.tpl-top { display: flex; align-items: center; gap: 8px; }
.tpl-name { font-weight: 500; }
</style>