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
+904
View File
@@ -0,0 +1,904 @@
<script setup lang="ts">
// My profile. Faithfully ports project/platform-enduser.jsx `ProfileScreenDeep`
// (lines 9721400) and its tab sub-components. 5 tabs: Profile / Work info /
// Preferences / Email signature / Notifications, plus a sticky save bar that
// surfaces when any tab is dirty.
const toast = useToast()
const tweaks = usePortalTweaks()
// Honour ?tab=… so the topbar bell's "Preferences" can deep-link to
// the Notifications tab (since /notifications no longer exists).
const route = useRoute()
const validTabs = ['profile', 'work', 'preferences', 'signature', 'notifications'] as const
const initialTab = (typeof route.query.tab === 'string' && (validTabs as readonly string[]).includes(route.query.tab))
? route.query.tab as string
: 'profile'
const tab = ref(initialTab)
const dirty = ref(false)
const markDirty = () => { dirty.value = true }
function discard() {
dirty.value = false
toast.warn('Discarded unsaved changes')
}
function save() {
dirty.value = false
toast.ok('Profile saved · changes live in ~10 seconds')
}
// Modals
const photoOpen = ref(false)
const quietOpen = ref(false)
const overrideOpen = ref(false)
// Mustache-tag literals — held as JS constants so the template doesn't have
// to nest `{{ ... }}` inside `{{ ... }}` (which trips Vue's parser).
const AVAILABLE_VARS_TEXT =
'{' + '{full_name}}' + ' · ' +
'{' + '{first_name}}' + ' · ' +
'{' + '{job_title}}' + ' · ' +
'{' + '{phone}}' + ' · ' +
'{' + '{email}}' + ' · ' +
'{' + '{company_name}}' + ' · ' +
'{' + '{pronouns}}'
const MERGE_TAG_LITERAL = '{' + '{merge}}'
// --- Profile tab ---
const profile = reactive({
firstName: 'Anne',
lastName: 'Baslund',
displayName: 'Anne B.',
pronouns: 'she/her',
email: 'anne@dezky.com',
phone: '+45 21 47 88 02',
})
// --- Work info ---
const work = reactive({
title: 'Founder · CEO',
department: 'Leadership',
manager: '(none · top of org)',
location: 'Copenhagen, DK',
startDate: '14 Jan 2026',
officeDays: 'Mon · Wed · Fri',
})
// Match source `WorkInfoTab` connections list (5 items, only Google + MS365 connected).
const connections = ref([
{ id: 'google', name: 'Google', initial: 'G', color: '#4285F4', connected: true, account: 'anne@gmail.com', scopes: 'calendar · contacts', since: '14 Jan 2026' },
{ id: 'microsoft', name: 'Microsoft 365', initial: 'M', color: '#00A4EF', connected: true, account: 'anne.baslund@outlook.com', scopes: 'calendar', since: '02 Mar 2026' },
{ id: 'apple', name: 'Apple', initial: 'A', color: '#000000', connected: false, account: '', scopes: '', since: '' },
{ id: 'github', name: 'GitHub', initial: 'g', color: '#181717', connected: false, account: '', scopes: '', since: '' },
{ id: 'slack', name: 'Slack (legacy DM)', initial: 's', color: '#4A154B', connected: false, account: '', scopes: '', since: '' },
])
const apiTokens = ref([
{ id: 'tk-1', name: 'CLI · macbook', scope: 'read:files write:files', last: '2 d ago' },
{ id: 'tk-2', name: 'Zapier integration', scope: 'read:mail', last: '14 d ago' },
])
// --- Preferences ---
const theme = ref<'system' | 'light' | 'dark'>('system')
const density = ref<'Comfortable' | 'Compact'>('Comfortable')
const reduceMotion = ref(false)
const ooo = reactive({
enabled: false,
from: '2026-06-14',
to: '2026-06-21',
message: "Im on holiday until 21 June and will reply when Im back.\n\nFor urgent matters, contact Mikkel at mikkel@dezky.com.",
redirect: true,
})
const region = reactive({
language: 'English (UK)',
spellcheck: 'Dansk · English',
timezone: 'Europe/Copenhagen · CEST',
dateFormat: 'DD MMM YYYY · 14 May 2026',
timeFormat: '24-hour · 14:32',
weekStarts: 'Monday',
currency: 'DKK · 1.940,00',
workHours: '09:00 17:00',
})
function pickTheme(v: 'system' | 'light' | 'dark') {
theme.value = v
if (v === 'light' || v === 'dark') tweaks.setTheme(v)
markDirty()
}
// --- Signature ---
const sig = reactive({
// Signature body is plain text shown in the contentEditable preview.
// Source uses 3 lines + a disclosure footer; we keep the same structure.
body: 'Anne Baslund\nFounder · CEO at baslund\n+45 21 47 88 02 · anne@dezky.com',
})
const sigApply = reactive({ newEmails: true, replies: true, invites: false, ooo: false })
// --- Notifications ---
// Source `NotifPrefsTab` channels and event labels.
const notifChannels = ['In-app', 'Email', 'Push · mobile', 'Slack mirror'] as const
const notifEvents = ref([
{ id: 'mentions', label: 'Mentions in chat', vals: [true, true, true, false] },
{ id: 'dms', label: 'Direct messages', vals: [true, true, true, false] },
{ id: 'shared', label: 'Files shared with me', vals: [true, true, false, false] },
{ id: 'invites', label: 'Meeting invites', vals: [true, true, true, false] },
{ id: 'reminders', label: 'Calendar reminders', vals: [true, false, true, false] },
{ id: 'security', label: 'Security alerts on my account', vals: [true, true, true, false] },
{ id: 'announcements',label: 'Workspace announcements (admin)', vals: [true, true, false, false] },
{ id: 'billing', label: 'Billing reminders (admin only)', vals: [true, true, false, false] },
{ id: 'digest', label: 'Weekly digest', vals: [false, true, false, false] },
{ id: 'marketing', label: 'Marketing from dezky', vals: [false, false, false, false] },
])
// Quiet hours modal state — mirrors source EditQuietHoursModal.
const quiet = reactive({
weekFrom: '22:00',
weekTo: '07:00',
weekendDifferent: true,
wkndFrom: '00:00',
wkndTo: '09:00',
pauseHolidays: true,
allowManager: false,
})
// Change photo modal state — mirrors source ChangePhotoModal.
const photo = reactive({ uploaded: false, crop: 50 })
function uploadPhoto() { photo.uploaded = true }
function removePhoto() { photo.uploaded = false }
watch(photoOpen, (v) => { if (v) { photo.uploaded = false; photo.crop = 50 } })
// Signature override modal state.
const override = reactive({ scope: 'domain' as 'domain' | 'recipient' | 'category', domain: 'baslund.dk', name: 'External communications', body: 'Best regards,\nAnne Baslund\nFounder · baslund\nbaslund.dk · +45 21 47 88 02' })
</script>
<template>
<div>
<PageHeader
eyebrow="Account"
title="My profile"
subtitle="Update your personal details, preferences, and email signature."
/>
<div class="tabs-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'profile', label: 'Profile' },
{ value: 'work', label: 'Work info' },
{ value: 'preferences', label: 'Preferences' },
{ value: 'signature', label: 'Email signature' },
{ value: 'notifications', label: 'Notifications' },
]"
/>
</div>
<div class="content">
<!-- Profile tab -->
<section v-if="tab === 'profile'" class="profile-grid">
<Card>
<div class="photo">
<Avatar :name="`${profile.firstName} ${profile.lastName}`" :size="88" />
<UiButton size="sm" variant="secondary" @click="photoOpen = true">
<template #leading><UiIcon name="upload" :size="13" /></template>
Change photo
</UiButton>
<div class="photo-meta">
<div class="display">{{ profile.firstName }} {{ profile.lastName }}</div>
<Mono dim>{{ profile.email }}</Mono>
</div>
</div>
</Card>
<Card>
<header class="card-header">
<Eyebrow>Personal</Eyebrow>
<h2>Contact details</h2>
</header>
<div class="grid-2">
<EnduserFormField label="First name"><input v-model="profile.firstName" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Last name"><input v-model="profile.lastName" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Display name"><input v-model="profile.displayName" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Pronouns"><input v-model="profile.pronouns" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Email"><input v-model="profile.email" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Phone"><input v-model="profile.phone" @input="markDirty" /></EnduserFormField>
</div>
</Card>
</section>
<!-- Work info tab -->
<section v-else-if="tab === 'work'" class="stack">
<Card>
<header class="card-header">
<Eyebrow>Workplace</Eyebrow>
<h2>What you do at baslund</h2>
</header>
<div class="grid-2">
<EnduserFormField label="Job title"><input v-model="work.title" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Department"><input v-model="work.department" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Manager"><input v-model="work.manager" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Location"><input v-model="work.location" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Start date"><input v-model="work.startDate" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Office days"><input v-model="work.officeDays" @input="markDirty" /></EnduserFormField>
</div>
</Card>
<Card>
<header class="card-header">
<Eyebrow>Connected accounts</Eyebrow>
<h2>Link external services</h2>
<p>For calendar sync, contact lookup, and single sign-on. dezky never sees your password.</p>
</header>
<ul class="conn">
<li v-for="p in connections" :key="p.id">
<span class="conn-tile" :style="{ background: p.color }">{{ p.initial }}</span>
<div class="conn-text">
<div class="conn-name">
{{ p.name }}
<Badge v-if="p.connected" tone="ok" dot>connected</Badge>
</div>
<Mono v-if="p.connected" dim>{{ p.account }} · {{ p.scopes }} · since {{ p.since }}</Mono>
<Mono v-else dim>not linked</Mono>
</div>
<div class="conn-actions">
<template v-if="p.connected">
<UiButton size="sm" variant="ghost" @click="toast.info(`Managing ${p.name}`)">Manage</UiButton>
<UiButton size="sm" variant="ghost" @click="toast.warn(`Disconnecting ${p.name}`); markDirty()">Disconnect</UiButton>
</template>
<UiButton v-else size="sm" variant="secondary" @click="toast.info(`Connecting ${p.name}`)">
<template #leading><UiIcon name="external" :size="13" /></template>
Connect
</UiButton>
</div>
</li>
</ul>
</Card>
<Card>
<header class="card-header with-actions">
<div class="head-text">
<Eyebrow>Developers</Eyebrow>
<h2>Personal API tokens</h2>
<p>Use these in scripts and integrations that act on your behalf. Treat like passwords.</p>
</div>
<UiButton size="sm" variant="secondary" @click="toast.info('New token wizard')">
<template #leading><UiIcon name="plus" :size="13" /></template>
New token
</UiButton>
</header>
<ul class="tokens">
<li v-for="t in apiTokens" :key="t.id">
<UiIcon name="key" :size="15" stroke="var(--text-mute)" />
<div class="conn-text">
<div class="conn-name">{{ t.name }}</div>
<Mono dim>{{ t.scope }} · last used {{ t.last }}</Mono>
</div>
<UiButton size="sm" variant="ghost" @click="toast.warn(`Token ${t.id} revoked`)">Revoke</UiButton>
</li>
</ul>
</Card>
</section>
<!-- Preferences tab -->
<section v-else-if="tab === 'preferences'" class="stack">
<Card>
<header class="card-header with-actions">
<div class="head-text">
<Eyebrow>Out of office</Eyebrow>
<h2>Auto-reply when you're away</h2>
<p>Active for the dates below. Senders get a one-time reply per thread.</p>
</div>
<EnduserToggle v-model="ooo.enabled" @update:model-value="markDirty" />
</header>
<div class="ooo" :data-on="ooo.enabled">
<div class="grid-2">
<EnduserFormField label="From"><input v-model="ooo.from" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Until"><input v-model="ooo.to" @input="markDirty" /></EnduserFormField>
</div>
<EnduserFormField label="Auto-reply message">
<textarea v-model="ooo.message" rows="5" @input="markDirty" />
</EnduserFormField>
<div class="redirect-row">
<div>
<div class="rr-l">Forward incoming mail to a coworker</div>
<Mono dim>mikkel@dezky.com · gets a tag so they know it's a forward</Mono>
</div>
<EnduserToggle v-model="ooo.redirect" @update:model-value="markDirty" />
</div>
<Mono dim>presence will be set to <b style="color: var(--text);">Away</b> automatically · status reverts on the end date</Mono>
</div>
</Card>
<Card>
<header class="card-header">
<Eyebrow>Language & region</Eyebrow>
<h2>Where and how dezky speaks to you</h2>
</header>
<div class="grid-2">
<EnduserFormField label="Display language"><input v-model="region.language" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Spell-check language"><input v-model="region.spellcheck" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Timezone"><input v-model="region.timezone" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Date format"><input v-model="region.dateFormat" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Time format"><input v-model="region.timeFormat" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Week starts on"><input v-model="region.weekStarts" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Currency"><input v-model="region.currency" @input="markDirty" /></EnduserFormField>
<EnduserFormField label="Working hours"><input v-model="region.workHours" @input="markDirty" /></EnduserFormField>
</div>
</Card>
<Card>
<header class="card-header">
<Eyebrow>Appearance</Eyebrow>
<h2>How dezky looks</h2>
<p>Affects this device only. Global theme is set per-workspace by admins.</p>
</header>
<div class="appearance">
<EnduserFormField label="Theme">
<div class="theme-tiles">
<button
v-for="t in [
{ v: 'system' as const, l: 'Match system', bg: 'linear-gradient(135deg, #F4F3EE 50%, #0A0A0A 50%)' },
{ v: 'light' as const, l: 'Light', bg: '#F4F3EE' },
{ v: 'dark' as const, l: 'Dark', bg: '#0A0A0A' },
]"
:key="t.v"
class="theme-tile"
:class="{ active: theme === t.v }"
@click="pickTheme(t.v)"
>
<span class="theme-swatch" :style="{ background: t.bg }" />
<span>{{ t.l }}</span>
</button>
</div>
</EnduserFormField>
<EnduserFormField label="Density">
<div class="density">
<button
v-for="d in (['Comfortable', 'Compact'] as const)"
:key="d"
:class="{ active: density === d }"
@click="density = d; tweaks.setDensity(d === 'Comfortable' ? 'comfy' : 'compact'); markDirty()"
>{{ d }}</button>
</div>
</EnduserFormField>
<EnduserFormField label="Reduce motion">
<div class="row-toggle">
<EnduserToggle v-model="reduceMotion" @update:model-value="markDirty" />
<span>Honor system preference</span>
</div>
</EnduserFormField>
</div>
</Card>
</section>
<!-- Signature tab -->
<section v-else-if="tab === 'signature'" class="sig-grid">
<Card>
<header class="card-header">
<Eyebrow>Email signature</Eyebrow>
<h2>Your default signature</h2>
<p>Appended to outgoing email from your dezky address. You can override per-account.</p>
</header>
<div class="toolbar">
<button class="tb b" @click="markDirty">B</button>
<button class="tb i" @click="markDirty">I</button>
<button class="tb u" @click="markDirty">U</button>
<button class="tb" @click="markDirty">⇿</button>
<button class="tb mono" @click="markDirty">link</button>
<button class="tb mono" @click="markDirty">image</button>
</div>
<div
class="sig-editor"
contenteditable="true"
@input="markDirty"
spellcheck="false"
>
<div class="sig-line bold">Anne Baslund</div>
<div class="sig-line dim">Founder · CEO at <b>baslund</b></div>
<div class="sig-line mute">+45 21 47 88 02 · anne@dezky.com</div>
<div class="sig-foot">We collect personal data — see our <u>privacy policy</u>. Sent via <span class="mono">dezky</span>.</div>
</div>
<div class="vars">
<Mono>// available variables</Mono><br />
{{ AVAILABLE_VARS_TEXT }}
</div>
</Card>
<div class="sig-side">
<Card>
<header class="card-header">
<Eyebrow>When to use</Eyebrow>
<h2>Apply to</h2>
</header>
<ul class="apply-list">
<li>
<label><input type="checkbox" v-model="sigApply.newEmails" @change="markDirty" /><span>New emails</span></label>
</li>
<li>
<label><input type="checkbox" v-model="sigApply.replies" @change="markDirty" /><span>Replies and forwards</span></label>
</li>
<li>
<label><input type="checkbox" v-model="sigApply.invites" @change="markDirty" /><span>Calendar invites</span></label>
</li>
<li>
<label><input type="checkbox" v-model="sigApply.ooo" @change="markDirty" /><span>Out-of-office replies</span></label>
</li>
</ul>
</Card>
<Card>
<header class="card-header with-actions">
<div class="head-text">
<Eyebrow>Per-account</Eyebrow>
<h2>Account overrides</h2>
</div>
<UiButton size="sm" variant="ghost" @click="overrideOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add
</UiButton>
</header>
<Mono dim>// no overrides · all accounts use the default signature</Mono>
</Card>
</div>
</section>
<!-- Notifications tab -->
<section v-else-if="tab === 'notifications'">
<Card :pad="0" style="max-width: 920px;">
<div class="notif-head">
<Eyebrow>Channels · per event</Eyebrow>
<div class="notif-title">Notification preferences</div>
</div>
<table class="notif">
<thead>
<tr>
<th class="lhs">Event</th>
<th v-for="c in notifChannels" :key="c">{{ c }}</th>
</tr>
</thead>
<tbody>
<tr v-for="e in notifEvents" :key="e.id">
<td class="lhs">{{ e.label }}</td>
<td v-for="(v, j) in e.vals" :key="j">
<input type="checkbox" v-model="e.vals[j]" @change="markDirty" />
</td>
</tr>
</tbody>
</table>
<div class="notif-foot">
<Mono dim>// quiet hours · 22:00 07:00 · respects your working hours</Mono>
<UiButton size="sm" variant="ghost" @click="quietOpen = true">Edit quiet hours</UiButton>
</div>
</Card>
</section>
</div>
<EnduserSaveBar :dirty="dirty" @discard="discard" @save="save" />
<!-- Change photo modal -->
<Modal :open="photoOpen" eyebrow="Account · photo" title="Change profile photo" size="md" @close="photoOpen = false">
<div class="photo-modal">
<button v-if="!photo.uploaded" class="photo-upload" @click="uploadPhoto">
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
<div class="pu-text">
<div class="pu-l">Drop a photo here, or click to browse</div>
<Mono dim style="margin-top: 6px; display: block;">jpg · png · webp · up to 5 MB · best at 512×512</Mono>
</div>
</button>
<template v-else>
<div class="crop-row">
<div class="crop-main">
<Eyebrow style="display: block; margin-bottom: 8px;">Crop preview</Eyebrow>
<div class="crop-stage">
<div class="crop-mask" />
<span class="crop-letter" :style="{ transform: `translate(${(photo.crop - 50) * 0.4}px, 0)` }">A</span>
</div>
<div style="margin-top: 12px;">
<Mono dim style="display: block; margin-bottom: 4px;">Horizontal position</Mono>
<input type="range" min="0" max="100" v-model.number="photo.crop" />
</div>
</div>
<div class="crop-side">
<Eyebrow style="display: block; margin-bottom: 8px;">Final result</Eyebrow>
<div class="circle large"><span :style="{ transform: `translate(${(photo.crop - 50) * 0.1}px, 0)` }">A</span></div>
<Mono dim>large · 88×88</Mono>
<div class="circle small"><span>A</span></div>
<Mono dim>chat · 32×32</Mono>
</div>
</div>
<UiButton size="sm" variant="ghost" @click="removePhoto">Replace photo</UiButton>
</template>
<div class="def-block">
<Mono dim>// where it appears</Mono>
<div class="def-body">Profile pages, Chat avatars, mail headers, meeting tiles, and the partner console. Co-workers in your workspace can see it.</div>
</div>
</div>
<template #footer>
<UiButton v-if="photo.uploaded" variant="danger" @click="removePhoto">
<template #leading><UiIcon name="trash" :size="13" /></template>
Remove
</UiButton>
<div style="flex: 1;" />
<UiButton variant="ghost" @click="photoOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="!photo.uploaded" @click="photoOpen = false; markDirty(); toast.ok('Photo queued for upload')">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ photo.uploaded ? 'Use this photo' : 'Select a photo' }}
</UiButton>
</template>
</Modal>
<!-- Edit quiet hours modal -->
<Modal :open="quietOpen" eyebrow="Notifications · quiet hours" title="Edit quiet hours" size="md" @close="quietOpen = false">
<div class="quiet-stack">
<div class="callout-info">
<UiIcon name="bell" :size="14" />
<span>Push and email notifications are silenced during quiet hours. In-app indicators still update so nothing is missed — just no pings.</span>
</div>
<div>
<Eyebrow style="display: block; margin-bottom: 8px;">Weekdays · MonFri</Eyebrow>
<div class="grid-2">
<EnduserFormField label="From"><input v-model="quiet.weekFrom" /></EnduserFormField>
<EnduserFormField label="Until"><input v-model="quiet.weekTo" /></EnduserFormField>
</div>
</div>
<div>
<div class="weekend-head">
<Eyebrow>Weekends · SatSun</Eyebrow>
<label class="inline-check">
<input type="checkbox" v-model="quiet.weekendDifferent" />
different from weekdays
</label>
</div>
<div v-if="quiet.weekendDifferent" class="grid-2">
<EnduserFormField label="From"><input v-model="quiet.wkndFrom" /></EnduserFormField>
<EnduserFormField label="Until"><input v-model="quiet.wkndTo" /></EnduserFormField>
</div>
<Mono v-else dim>same as weekdays · {{ quiet.weekFrom }} {{ quiet.weekTo }}</Mono>
</div>
<div class="quiet-rules">
<div class="qr-row">
<div>
<div class="qr-l">Pause on public holidays</div>
<Mono dim>silence all day on Danish public holidays</Mono>
</div>
<EnduserToggle v-model="quiet.pauseHolidays" />
</div>
<div class="qr-row">
<div>
<div class="qr-l">Always allow your manager</div>
<Mono dim>Mikkel Nørgaard can still reach you during quiet hours</Mono>
</div>
<EnduserToggle v-model="quiet.allowManager" />
</div>
<div class="qr-row">
<div>
<div class="qr-l">Override for P1 incidents</div>
<Mono dim>always notify for on-call pages and security alerts</Mono>
</div>
<EnduserToggle :model-value="true" />
</div>
</div>
<Mono dim style="display: block; text-align: center;">shown on your profile card so teammates know when not to expect you</Mono>
</div>
<template #footer>
<UiButton variant="ghost" @click="quietOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="quietOpen = false; toast.ok('Quiet hours saved')">
<template #leading><UiIcon name="check" :size="13" /></template>
Save quiet hours
</UiButton>
</template>
</Modal>
<!-- Add signature override modal -->
<Modal :open="overrideOpen" eyebrow="Email signature · override" title="New signature override" size="md" @close="overrideOpen = false">
<div class="override-stack">
<EnduserFormField label="Override name">
<input v-model="override.name" placeholder="e.g. External communications" />
</EnduserFormField>
<div>
<Eyebrow style="display: block; margin-bottom: 8px;">Applies to</Eyebrow>
<div class="scope-grid">
<button
v-for="o in [
{ v: 'domain' as const, l: 'Sending domain', d: 'e.g. mail from @baslund.dk' },
{ v: 'recipient' as const, l: 'Recipients', d: 'mail to specific domains' },
{ v: 'category' as const, l: 'Category', d: 'replies / forwards / OOO' },
]"
:key="o.v"
class="scope-card"
:class="{ active: override.scope === o.v }"
@click="override.scope = o.v"
>
<div class="sc-l">{{ o.l }}</div>
<Mono dim>{{ o.d }}</Mono>
</button>
</div>
</div>
<EnduserFormField v-if="override.scope === 'domain'" label="Send from">
<div class="domain-input">
<span>*@</span>
<input v-model="override.domain" />
</div>
</EnduserFormField>
<EnduserFormField v-else-if="override.scope === 'recipient'" label="When sending to">
<input value="*.dk, *.no, *.se" placeholder="comma-separated domains" />
</EnduserFormField>
<EnduserFormField v-else label="On message type">
<input value="Replies and forwards" />
</EnduserFormField>
<EnduserFormField label="Signature">
<textarea v-model="override.body" rows="6" />
<Mono dim style="display: block; margin-top: 6px;">plain text · supports the same {{ MERGE_TAG_LITERAL }} variables as your default signature</Mono>
</EnduserFormField>
</div>
<template #footer>
<UiButton variant="ghost" @click="overrideOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="overrideOpen = false; toast.ok('Override saved'); markDirty()">
<template #leading><UiIcon name="check" :size="13" /></template>
Save override
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.content { padding: 20px 40px 96px 40px; max-width: 1100px; }
.stack { display: flex; flex-direction: column; gap: 16px; max-width: 720px; }
/* Card header */
.card-header { margin-bottom: 16px; }
.card-header.with-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.card-header .head-text { min-width: 0; flex: 1; }
.card-header h2 {
font-family: var(--font-display); font-weight: 600;
font-size: 17px; letter-spacing: -0.015em;
margin: 6px 0 0 0;
}
.card-header p { margin: 8px 0 0 0; font-size: 13px; color: var(--text-mute); line-height: 1.5; max-width: 600px; }
/* Profile tab grid */
.profile-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
.photo { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 8px; }
.photo-meta { text-align: center; margin-top: 6px; }
.display { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.photo-meta :deep(.mono) { display: block; margin-top: 4px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* Connections */
.conn, .tokens { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
.conn li, .tokens li {
display: flex; align-items: center; gap: 14px;
padding: 14px;
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
}
.tokens li { padding: 12px; gap: 12px; }
.conn-tile {
width: 36px; height: 36px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
color: #fff; font-family: var(--font-mono); font-weight: 700; font-size: 16px;
flex-shrink: 0;
}
.conn-text { flex: 1; min-width: 0; }
.conn-name { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; }
.conn-text :deep(.mono) { display: block; margin-top: 2px; }
.conn-actions { display: flex; gap: 4px; }
/* OOO */
.ooo { display: flex; flex-direction: column; gap: 14px; transition: opacity 0.2s; }
.ooo[data-on='false'] { opacity: 0.5; pointer-events: none; }
.ooo textarea {
width: 100%; min-height: 120px; 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;
}
.redirect-row {
padding: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
}
.rr-l { font-size: 13px; font-weight: 500; }
/* Appearance */
.appearance { display: flex; flex-direction: column; gap: 16px; }
.theme-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; max-width: 460px; }
.theme-tile {
padding: 14px;
background: var(--surface);
border: 1px solid var(--border); border-radius: 8px;
display: flex; flex-direction: column; gap: 10px; align-items: center;
cursor: pointer; font-family: inherit; color: var(--text); font-size: 12px; font-weight: 500;
}
.theme-tile.active { border-color: var(--text); }
.theme-swatch { width: 100%; height: 56px; border-radius: 6px; border: 1px solid var(--border); }
.density {
display: flex; gap: 0;
border: 1px solid var(--border); border-radius: 6px; padding: 2px;
width: fit-content;
}
.density button {
padding: 6px 14px; border: none; border-radius: 4px;
background: transparent; color: var(--text);
font-size: 12px; font-weight: 500; cursor: pointer; font-family: inherit;
}
.density button.active { background: var(--text); color: var(--bg); }
.row-toggle { display: inline-flex; align-items: center; gap: 10px; font-size: 12px; color: var(--text-mute); }
/* Signature */
.sig-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
.sig-side { display: flex; flex-direction: column; gap: 12px; }
.toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.tb {
height: 30px; padding: 0 10px;
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
color: var(--text); cursor: pointer; font-family: var(--font-sans); font-size: 13px;
font-weight: 500;
}
.tb.b { font-weight: 700; }
.tb.i { font-style: italic; }
.tb.u { text-decoration: underline; }
.tb.mono { font-family: var(--font-mono); font-size: 11px; }
.tb:hover { background: var(--row-hover); }
.sig-editor {
min-height: 220px; padding: 18px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px;
font-family: var(--font-sans); font-size: 14px; color: var(--text);
line-height: 1.55; outline: none;
}
.sig-line.bold { font-weight: 600; }
.sig-line.dim { color: var(--text-dim); }
.sig-line.mute { margin-top: 8px; color: var(--text-mute); font-size: 13px; }
.sig-foot {
margin-top: 14px; padding-top: 12px;
border-top: 1px dashed var(--border);
color: var(--text-mute); font-size: 12px;
}
.sig-foot .mono { font-family: var(--font-mono); }
.vars {
margin-top: 16px; padding: 14px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
font-size: 12px; color: var(--text-mute); line-height: 1.6;
}
.apply-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
.apply-list label { display: flex; gap: 8px; align-items: center; font-size: 13px; cursor: pointer; }
.apply-list input { accent-color: var(--text); }
/* Notifications matrix */
.notif-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.notif-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin-top: 4px; }
.notif { width: 100%; border-collapse: collapse; }
.notif thead tr { border-bottom: 1px solid var(--border); }
.notif thead th {
padding: 10px 14px;
text-align: center;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
width: 110px;
}
.notif thead th.lhs, .notif tbody td.lhs { text-align: left; padding-left: 20px; width: auto; letter-spacing: 0.12em; }
.notif tbody tr { border-bottom: 1px solid var(--border); }
.notif tbody tr:last-child { border-bottom: none; }
.notif tbody td { padding: 12px 14px; text-align: center; font-size: 13px; }
.notif tbody td.lhs { padding-left: 20px; padding-right: 14px; font-weight: 500; }
.notif input[type='checkbox'] { width: 16px; height: 16px; accent-color: var(--text); }
.notif-foot {
padding: 14px 20px;
background: var(--bg);
border-top: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
/* Modal · photo upload */
.photo-modal { display: flex; flex-direction: column; gap: 16px; }
.photo-upload {
padding: 48px 24px;
background: var(--bg); border: 2px dashed var(--border-hi); border-radius: 10px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
cursor: pointer; text-align: center;
font-family: inherit;
transition: background 0.12s, border-color 0.12s;
}
.photo-upload:hover { background: var(--row-hover); border-color: var(--text); }
.pu-l { font-size: 14px; font-weight: 500; color: var(--text); }
.pu-text { text-align: center; }
.crop-row { display: flex; gap: 20px; align-items: flex-start; }
.crop-main { flex: 1; }
.crop-stage {
position: relative; width: 100%; aspect-ratio: 1 / 1;
background: linear-gradient(135deg, #D4FF3A, #5B8C5A);
border-radius: 8px; overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
.crop-mask {
position: absolute; inset: 20%;
border: 2px solid #fff; border-radius: 50%;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
}
.crop-letter {
color: #fff; font-family: var(--font-display); font-weight: 700; font-size: 80px;
}
.crop-side { width: 140px; display: flex; flex-direction: column; gap: 10px; align-items: center; }
.circle {
background: linear-gradient(135deg, #D4FF3A, #5B8C5A);
border-radius: 999px; overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
.circle span { color: #fff; font-family: var(--font-display); font-weight: 700; }
.circle.large { width: 88px; height: 88px; }
.circle.large span { font-size: 36px; }
.circle.small { width: 32px; height: 32px; }
.circle.small span { font-size: 14px; }
.def-block { padding: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 12px; color: var(--text-mute); line-height: 1.55; }
.def-body { margin-top: 6px; color: var(--text-dim); }
/* Quiet hours modal */
.quiet-stack { display: flex; flex-direction: column; gap: 16px; }
.callout-info {
padding: 12px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
display: flex; gap: 10px; align-items: flex-start;
font-size: 12px; color: var(--text-dim); line-height: 1.55;
}
.callout-info :deep(svg) { color: var(--text-mute); margin-top: 2px; flex-shrink: 0; }
.weekend-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.inline-check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12px; color: var(--text-mute); }
.inline-check input { accent-color: var(--text); }
.quiet-rules { padding: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; display: flex; flex-direction: column; gap: 12px; }
.qr-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.qr-row + .qr-row { border-top: 1px solid var(--border); padding-top: 12px; }
.qr-l { font-size: 13px; font-weight: 500; }
/* Override modal */
.override-stack { display: flex; flex-direction: column; gap: 14px; }
.scope-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.scope-card {
padding: 12px; border-radius: 6px;
background: var(--surface); border: 1px solid var(--border);
cursor: pointer; font-family: inherit; color: var(--text); text-align: left;
}
.scope-card.active { border-color: var(--text); background: var(--bg); }
.sc-l { font-size: 13px; font-weight: 500; }
.scope-card :deep(.mono) { display: block; margin-top: 4px; }
.domain-input {
display: flex; align-items: center; gap: 0;
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: 0 12px; height: 36px;
}
.domain-input span { font-family: var(--font-mono); font-size: 12px; color: var(--text-mute); }
.domain-input input {
flex: 1; border: none; outline: none; background: transparent;
font-size: 13px; color: var(--text); font-family: var(--font-mono);
}
.override-stack 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: var(--font-sans); resize: vertical; line-height: 1.55;
box-sizing: border-box;
}
</style>