Files
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

905 lines
39 KiB
Vue
Raw Permalink 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">
// 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>