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:
@@ -0,0 +1,904 @@
|
||||
<script setup lang="ts">
|
||||
// My profile. Faithfully ports project/platform-enduser.jsx `ProfileScreenDeep`
|
||||
// (lines 972–1400) 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: "I’m on holiday until 21 June and will reply when I’m 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 · Mon–Fri</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 · Sat–Sun</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>
|
||||
Reference in New Issue
Block a user