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
251 lines
8.6 KiB
Vue
251 lines
8.6 KiB
Vue
<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: `You’ve 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>
|