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,784 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-screens.jsx `BrandingScreen` (lines 1542-1668)
|
||||
// with BrandingPreview (1669), UploadAssetModal (1733), EditEmailTemplatePanel
|
||||
// (1903), PublishBrandingModal (2031) and ResetBrandingModal (2148). Two-column
|
||||
// layout — controls on the left (420px), live preview on the right.
|
||||
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const color = ref('#D4FF3A')
|
||||
const name = ref('Acme Workspace')
|
||||
|
||||
const uploadAsset = ref<typeof ASSETS[number] | null>(null)
|
||||
const uploaded = ref(false)
|
||||
const dragOver = ref(false)
|
||||
|
||||
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
|
||||
const subject = ref('')
|
||||
const body = ref('')
|
||||
const testSent = ref(false)
|
||||
|
||||
const publishOpen = ref(false)
|
||||
const publishState = ref<'confirm' | 'publishing' | 'done'>('confirm')
|
||||
const resetOpen = ref(false)
|
||||
|
||||
const ASSETS = [
|
||||
{ id: 'full', l: 'Full logo', d: 'horizontal · 4:1 · png/svg', ratio: '4:1', formats: 'png · svg', maxKb: 400, current: false, currentName: '', currentSize: '' },
|
||||
{ id: 'mark', l: 'Square mark', d: '1:1 · transparent · png/svg', ratio: '1:1', formats: 'png · svg', maxKb: 200, current: true, currentName: 'acme-mark.svg', currentSize: '12 KB' },
|
||||
{ id: 'favicon', l: 'Favicon', d: '32×32 · ico/png', ratio: '1:1', formats: 'ico · png', maxKb: 50, current: true, currentName: 'favicon.ico', currentSize: '4 KB' },
|
||||
] as const
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: 'invitation', name: 'User invitation', subject: 'You’ve been invited to {{workspace.name}}', desc: 'sent when an admin invites a new user', edited: '3 days ago' },
|
||||
{ id: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests', edited: 'default' },
|
||||
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests', edited: '2 weeks ago' },
|
||||
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry', edited: 'default' },
|
||||
] as const
|
||||
|
||||
const TEMPLATE_BODIES: Record<string, string> = {
|
||||
invitation: `Hi {{user.first_name}},
|
||||
|
||||
{{inviter.name}} has invited you to join {{workspace.name}} on dezky.
|
||||
|
||||
Click below to set up your account — the link expires in 7 days.
|
||||
|
||||
→ {{invite.url}}
|
||||
|
||||
If you have any questions, reply to this email and we'll help out.
|
||||
|
||||
— The {{workspace.name}} team`,
|
||||
reset: `Hi {{user.first_name}},
|
||||
|
||||
Someone (hopefully you) asked to reset your {{workspace.name}} password.
|
||||
|
||||
Click the link below within the next 60 minutes to choose a new one:
|
||||
|
||||
→ {{reset.url}}
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
||||
— {{workspace.name}} security`,
|
||||
digest: `Hi {{user.first_name}},
|
||||
|
||||
Here's what happened in {{workspace.name}} this week:
|
||||
|
||||
· {{stats.messages}} new messages across your channels
|
||||
· {{stats.files}} files shared
|
||||
· {{stats.meetings}} meetings recorded
|
||||
|
||||
→ Open dashboard: {{workspace.url}}
|
||||
|
||||
Manage how often you receive these from your profile.`,
|
||||
trial: `Hi {{user.first_name}},
|
||||
|
||||
Your {{workspace.name}} trial ends in {{trial.days_left}} days.
|
||||
|
||||
You've added {{stats.users}} users and uploaded {{stats.gb}} GB of files. To keep everything running smoothly, upgrade to Business or Enterprise.
|
||||
|
||||
→ Choose a plan: {{billing.url}}
|
||||
|
||||
— {{workspace.name}}`,
|
||||
}
|
||||
|
||||
const TEMPLATE_MERGE_TAGS: Record<string, string[]> = {
|
||||
invitation: ['user.first_name', 'user.email', 'inviter.name', 'workspace.name', 'invite.url', 'invite.expires_at'],
|
||||
reset: ['user.first_name', 'workspace.name', 'reset.url', 'reset.expires_at', 'security.ip'],
|
||||
digest: ['user.first_name', 'workspace.name', 'workspace.url', 'stats.messages', 'stats.files', 'stats.meetings'],
|
||||
trial: ['user.first_name', 'workspace.name', 'trial.days_left', 'stats.users', 'stats.gb', 'billing.url'],
|
||||
}
|
||||
|
||||
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
|
||||
|
||||
function openTemplate(t: typeof TEMPLATES[number]) {
|
||||
editTemplate.value = t
|
||||
subject.value = t.subject
|
||||
body.value = TEMPLATE_BODIES[t.id] || ''
|
||||
testSent.value = false
|
||||
}
|
||||
|
||||
function insertTag(tag: string) {
|
||||
body.value += `{{${tag}}}`
|
||||
}
|
||||
|
||||
// Reset the currently-open template's subject + body to the canonical default.
|
||||
function resetTemplate() {
|
||||
if (!editTemplate.value) return
|
||||
subject.value = editTemplate.value.subject
|
||||
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
|
||||
toast.info('Template reset to default')
|
||||
}
|
||||
|
||||
// Wrap a merge-tag name in mustaches via JS so the template doesn't have to
|
||||
// nest `{{ ... }}` inside `{{ ... }}` (which Vue's parser scans positionally
|
||||
// and breaks on).
|
||||
function wrapTag(tag: string) {
|
||||
return '{' + '{' + tag + '}' + '}'
|
||||
}
|
||||
|
||||
function startPublish() {
|
||||
publishState.value = 'publishing'
|
||||
setTimeout(() => { publishState.value = 'done' }, 1800)
|
||||
}
|
||||
|
||||
function openPublish() {
|
||||
publishOpen.value = true
|
||||
publishState.value = 'confirm'
|
||||
}
|
||||
|
||||
const renderedSubject = computed(() =>
|
||||
subject.value
|
||||
.replace(/\{\{workspace\.name\}\}/g, name.value)
|
||||
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
|
||||
.replace(/\{\{trial\.days_left\}\}/g, '3'),
|
||||
)
|
||||
const renderedBody = computed(() =>
|
||||
body.value
|
||||
.replace(/\{\{workspace\.name\}\}/g, name.value)
|
||||
.replace(/\{\{workspace\.url\}\}/g, 'workspace.acme.dk')
|
||||
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
|
||||
.replace(/\{\{user\.email\}\}/g, 'anne@acme.dk')
|
||||
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
|
||||
.replace(/\{\{invite\.url\}\}/g, 'workspace.acme.dk/accept/x9k2a')
|
||||
.replace(/\{\{reset\.url\}\}/g, 'workspace.acme.dk/reset/p2b7c')
|
||||
.replace(/\{\{billing\.url\}\}/g, 'workspace.acme.dk/billing')
|
||||
.replace(/\{\{trial\.days_left\}\}/g, '3')
|
||||
.replace(/\{\{stats\.messages\}\}/g, '1.840')
|
||||
.replace(/\{\{stats\.files\}\}/g, '24')
|
||||
.replace(/\{\{stats\.meetings\}\}/g, '6')
|
||||
.replace(/\{\{stats\.users\}\}/g, '8')
|
||||
.replace(/\{\{stats\.gb\}\}/g, '14'),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Whitelabel"
|
||||
title="Branding"
|
||||
subtitle="Replace the dezky shell with your own logo, color, and product name. Changes propagate everywhere."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" @click="resetOpen = true">Reset</UiButton>
|
||||
<UiButton variant="primary" @click="openPublish">Publish</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<!-- Controls -->
|
||||
<div class="controls">
|
||||
<Card>
|
||||
<div class="card-head"><Eyebrow>Identity</Eyebrow><div class="card-title">Product identity</div></div>
|
||||
<label class="field"><Eyebrow>Product name (shown to users)</Eyebrow><input class="input" v-model="name" /></label>
|
||||
<label class="field"><Eyebrow>Custom domain</Eyebrow>
|
||||
<div class="input-row">
|
||||
<input value="workspace.acme.dk" readonly />
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
</div>
|
||||
</label>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Color</Eyebrow>
|
||||
<div class="card-title">Primary accent</div>
|
||||
<div class="card-sub">Propagates to buttons, links, focus rings, and active states.</div>
|
||||
</div>
|
||||
<div class="swatches">
|
||||
<button v-for="c in colorPalette" :key="c" :style="{ background: c, borderColor: color === c ? 'var(--text)' : 'var(--border)', borderWidth: color === c ? '2px' : '1px' }" @click="color = c" />
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<input v-model="color" />
|
||||
<div class="color-preview" :style="{ background: color }" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head"><Eyebrow>Assets</Eyebrow><div class="card-title">Logo upload</div></div>
|
||||
<div class="assets">
|
||||
<div v-for="a in ASSETS" :key="a.id" class="asset" :class="{ has: a.current }">
|
||||
<div class="asset-icon" :style="{ color: a.current ? 'var(--ok)' : 'var(--text-mute)' }">
|
||||
<UiIcon :name="a.current ? 'check' : 'upload'" :size="16" :stroke-width="a.current ? 2.5 : 2" />
|
||||
</div>
|
||||
<div class="asset-meta">
|
||||
<div class="asset-l">{{ a.l }}</div>
|
||||
<Mono dim>{{ a.current ? `${a.currentName} · ${a.currentSize}` : a.d }}</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" :variant="a.current ? 'ghost' : 'secondary'" @click="uploadAsset = a as any; uploaded = false">
|
||||
{{ a.current ? 'Replace' : 'Upload' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head"><Eyebrow>Templates</Eyebrow><div class="card-title">Email templates</div></div>
|
||||
<div class="templates">
|
||||
<button v-for="t in TEMPLATES" :key="t.id" class="tmpl-row" @click="openTemplate(t as any)">
|
||||
<div class="tmpl-meta">
|
||||
<div class="tmpl-name-row">
|
||||
<span class="tmpl-name">{{ t.name }}</span>
|
||||
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
|
||||
</div>
|
||||
<Mono dim>edited {{ t.edited }}</Mono>
|
||||
</div>
|
||||
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="preview-col">
|
||||
<div class="preview-head">
|
||||
<Eyebrow>Live preview</Eyebrow>
|
||||
<Mono dim>workspace.acme.dk</Mono>
|
||||
</div>
|
||||
<div class="preview-frame">
|
||||
<div class="frame-topbar">
|
||||
<div class="frame-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
||||
<div class="frame-brand">{{ name.toLowerCase() }}</div>
|
||||
<div class="frame-spacer" />
|
||||
<div class="frame-user">anne@acme.dk</div>
|
||||
</div>
|
||||
<div class="frame-hero">
|
||||
<div class="frame-eyebrow">Dashboard</div>
|
||||
<div class="frame-title">Good morning, Anne.</div>
|
||||
<div class="frame-tiles">
|
||||
<div v-for="n in ['Mail', 'Drev', 'Møder', 'Chat']" :key="n" class="frame-tile">
|
||||
<div class="frame-tile-icon">{{ n[0] }}</div>
|
||||
<div class="frame-tile-name">{{ n }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="frame-cta" :style="{ background: color }">
|
||||
<div>
|
||||
<div class="frame-cta-title">Welcome to {{ name }}.</div>
|
||||
<div class="frame-cta-sub">Your team's workspace is ready.</div>
|
||||
</div>
|
||||
<button class="frame-cta-btn">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="frame-foot">
|
||||
<span>powered by dezky</span>
|
||||
<span>v1.0 · light</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload asset modal -->
|
||||
<Modal :open="!!uploadAsset" :eyebrow="uploadAsset ? `Branding · ${uploadAsset.l.toLowerCase()}` : ''" :title="uploadAsset ? `Upload ${uploadAsset.l.toLowerCase()}` : ''" size="md" @close="uploadAsset = null">
|
||||
<div v-if="uploadAsset" class="upload">
|
||||
<button v-if="!uploaded" class="dropzone" :class="{ over: dragOver }"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave="dragOver = false"
|
||||
@drop.prevent="dragOver = false; uploaded = true"
|
||||
@click="uploaded = true">
|
||||
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="drop-text">
|
||||
<div class="drop-title">Drop {{ uploadAsset.l.toLowerCase() }} here, or click to browse</div>
|
||||
<Mono dim>{{ uploadAsset.formats }} · {{ uploadAsset.ratio }} ratio · up to {{ uploadAsset.maxKb }} KB</Mono>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<template v-if="uploaded">
|
||||
<div class="upload-preview">
|
||||
<div class="upload-mark" :style="{ width: uploadAsset.id === 'full' ? '96px' : '56px' }">
|
||||
{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}
|
||||
</div>
|
||||
<div class="upload-meta">
|
||||
<div class="upload-name">{{ uploadAsset.id === 'favicon' ? 'favicon-new.png' : uploadAsset.id === 'mark' ? 'acme-mark-v2.svg' : 'acme-logo.svg' }}</div>
|
||||
<Mono dim>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} · {{ uploadAsset.id === 'favicon' ? '32×32' : uploadAsset.id === 'mark' ? '512×512' : '1200×300' }} · clean alpha</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="uploaded = false">Replace</UiButton>
|
||||
</div>
|
||||
<Eyebrow>Looks good</Eyebrow>
|
||||
<div class="check-list">
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Format</Mono>
|
||||
<span>{{ uploadAsset.formats.split(' · ')[0] }} ✓</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Dimensions</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? '32×32 ✓' : uploadAsset.ratio + ' ✓' }}</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Size</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} (under {{ uploadAsset.maxKb }} KB)</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Transparency</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? 'opaque background OK' : 'transparent background ✓' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ld-preview">
|
||||
<Eyebrow>Preview · on light + dark</Eyebrow>
|
||||
<div class="ld-grid">
|
||||
<div class="ld-light">
|
||||
<div class="ld-mark dark" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
|
||||
</div>
|
||||
<div class="ld-dark">
|
||||
<div class="ld-mark light" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="req-box">
|
||||
<Mono dim>// requirements</Mono>
|
||||
<div class="req-body">
|
||||
<template v-if="uploadAsset.id === 'full'">Used in the top navigation bar, login screen, and email headers. Roughly 200×50 displayed — supply at 2× minimum.</template>
|
||||
<template v-else-if="uploadAsset.id === 'mark'">Used as the app icon, favicon fallback, and any compact context (PWA install, notifications). Must read at 24×24.</template>
|
||||
<template v-else>Browser tab icon and bookmark badge. 32×32 is the standard size — modern browsers use the same file at 16×16.</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="uploadAsset = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="!uploaded" @click="uploadAsset = null">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
{{ uploaded ? 'Use this asset' : 'Select a file to continue' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit email template side panel -->
|
||||
<SidePanel :open="!!editTemplate" :eyebrow="'Email template'" :title="editTemplate?.name || ''" width="lg" @close="editTemplate = null">
|
||||
<div v-if="editTemplate" class="tmpl-edit">
|
||||
<div class="tmpl-col">
|
||||
<label class="field"><Eyebrow>Subject</Eyebrow><input class="input" v-model="subject" /></label>
|
||||
<div>
|
||||
<Eyebrow>Body</Eyebrow>
|
||||
<textarea v-model="body" class="body-area" />
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Merge tags · click to insert</Eyebrow>
|
||||
<div class="merge-tags">
|
||||
<button v-for="tag in (TEMPLATE_MERGE_TAGS[editTemplate.id] || [])" :key="tag" @click="insertTag(tag)">{{ wrapTag(tag) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tmpl-prev">
|
||||
<Eyebrow>Preview</Eyebrow>
|
||||
<div class="email-frame">
|
||||
<div class="email-head">
|
||||
<div class="from-row">
|
||||
<div class="from-mark" :style="{ background: '#0A0A0A', color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
||||
<Mono dim>From: {{ name.toLowerCase().replace(/\s+/g, '-') }}@dezky.com</Mono>
|
||||
</div>
|
||||
<div class="email-subj">{{ renderedSubject }}</div>
|
||||
</div>
|
||||
<div class="email-body">{{ renderedBody }}</div>
|
||||
<div class="email-foot" :style="{ background: color }">{{ name }} · workspace.acme.dk</div>
|
||||
</div>
|
||||
<Mono dim style="text-align: center; display: block;">preview substitutes sample data · real send uses recipient's data</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="resetTemplate">
|
||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||
Reset to default
|
||||
</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="testSent = true; setTimeout(() => testSent = false, 2500)">
|
||||
<template #leading><UiIcon name="mail" :size="13" /></template>
|
||||
{{ testSent ? 'Sent to anne@dezky.com ✓' : 'Send test to me' }}
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="editTemplate = null">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save template
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
|
||||
<!-- Publish modal -->
|
||||
<Modal :open="publishOpen" eyebrow="Branding · publish" :title="publishState === 'done' ? 'Branding published' : 'Publish branding changes?'" size="md" @close="publishState !== 'publishing' ? (publishOpen = false) : null">
|
||||
<template v-if="publishState === 'confirm'">
|
||||
<div class="publish-intro">These changes will replace dezky's branding for everyone in your workspace within ~30 seconds.</div>
|
||||
<Eyebrow>Will go live</Eyebrow>
|
||||
<div class="publish-summary">
|
||||
<div class="ps-row"><Mono dim>Product name</Mono><span>{{ name }}</span></div>
|
||||
<div class="ps-row">
|
||||
<Mono dim>Primary color</Mono>
|
||||
<span class="color-line">
|
||||
<span class="color-chip" :style="{ background: color }" />
|
||||
<Mono>{{ color }}</Mono>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ps-row">
|
||||
<Mono dim>Custom domain</Mono>
|
||||
<Mono>workspace.acme.dk</Mono>
|
||||
<Badge tone="ok" dot>verified</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Eyebrow>Propagates to</Eyebrow>
|
||||
<div class="prop-grid">
|
||||
<div v-for="[k, t] in [
|
||||
['Web app · workspace shell', '~10s'],
|
||||
['Login + auth pages', '~10s'],
|
||||
['Outbound email templates', '~30s'],
|
||||
['Mobile app · next session', 'on next launch'],
|
||||
['Status page', '~30s'],
|
||||
['PDF invoices', 'next billing cycle'],
|
||||
]" :key="k" class="prop-cell">
|
||||
<UiIcon name="check" :size="11" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<span>{{ k }}</span>
|
||||
<Mono dim>{{ t }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="publish-warn">
|
||||
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
|
||||
<div>Users may need to hard-refresh to see the new branding immediately. You can revert with one click for the next 7 days.</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="publishState === 'publishing'">
|
||||
<div class="publishing">
|
||||
<div class="spinner" />
|
||||
<div class="publish-title">Publishing across services…</div>
|
||||
<Mono dim>web shell · auth · mail templates · CDN</Mono>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="done-head">
|
||||
<div class="done-badge" :style="{ background: color }">
|
||||
<UiIcon name="check" :size="20" :stroke-width="2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="publish-title">{{ name }} branding is live</div>
|
||||
<Mono dim>5 services updated · 1 queued for next cycle</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="done-list">
|
||||
<dl class="def">
|
||||
<div><dt>Web app + auth</dt><dd>live · 8 seconds</dd></div>
|
||||
<div><dt>Email templates</dt><dd>live · 18 seconds</dd></div>
|
||||
<div><dt>Mobile · status · CDN</dt><dd>queued · ~30s</dd></div>
|
||||
<div><dt>PDF invoices</dt><dd>starts 01 Jun 2026</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="publishState === 'confirm'">
|
||||
<UiButton variant="ghost" @click="publishOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="startPublish">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Publish now
|
||||
</UiButton>
|
||||
</template>
|
||||
<template v-else-if="publishState === 'publishing'">
|
||||
<UiButton variant="ghost" disabled>Publishing…</UiButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UiButton variant="primary" @click="publishOpen = false">Done</UiButton>
|
||||
</template>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Reset branding modal -->
|
||||
<Modal :open="resetOpen" eyebrow="Destructive · reverts to defaults" title="Reset branding to dezky defaults?" size="sm" @close="resetOpen = false">
|
||||
<div class="reset-box bad">
|
||||
<UiIcon name="shield" :size="16" stroke="var(--bad)" />
|
||||
<div>Reverts product name, colors, logos, and email templates to dezky defaults. Your custom domain stays connected. Edits made today are kept for 7 days and can be restored from your audit log.</div>
|
||||
</div>
|
||||
<div class="reset-list">
|
||||
<dl class="def">
|
||||
<div><dt>Product name</dt><dd>Acme Workspace → dezky</dd></div>
|
||||
<div><dt>Primary color</dt><dd>#D4FF3A → #D4FF3A (default)</dd></div>
|
||||
<div><dt>Full logo</dt><dd>will be removed</dd></div>
|
||||
<div><dt>Square mark</dt><dd>will be removed</dd></div>
|
||||
<div><dt>Favicon</dt><dd>will be removed</dd></div>
|
||||
<div><dt>Email templates</dt><dd>2 edited templates → defaults</dd></div>
|
||||
<div><dt>Custom domain</dt><dd>workspace.acme.dk · kept</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="resetOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="danger" @click="resetOpen = false">
|
||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||
Reset everything
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px 40px; display: grid; grid-template-columns: 420px 1fr; gap: 24px; }
|
||||
.controls { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.card-head { margin-bottom: 14px; }
|
||||
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
|
||||
.input:focus { border-color: var(--text); }
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||
.color-preview { width: 36px; height: 36px; border-radius: 6px; border: 1px solid var(--border); flex-shrink: 0; }
|
||||
|
||||
.swatches { display: flex; gap: 10px; margin-bottom: 14px; }
|
||||
.swatches button { width: 38px; height: 38px; border-radius: 6px; cursor: pointer; }
|
||||
|
||||
.assets { display: flex; flex-direction: column; gap: 10px; }
|
||||
.asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.asset.has { background: var(--surface); border-style: solid; }
|
||||
.asset-icon { width: 40px; height: 40px; border-radius: 6px; background: var(--bg); display: inline-flex; align-items: center; justify-content: center; }
|
||||
.asset-meta { flex: 1; min-width: 0; }
|
||||
.asset-l { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.templates { display: flex; flex-direction: column; }
|
||||
.tmpl-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); background: transparent; border-left: none; border-right: none; border-top: none; text-align: left; color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; }
|
||||
.tmpl-row:last-child { border-bottom: none; }
|
||||
.tmpl-meta { flex: 1; min-width: 0; }
|
||||
.tmpl-name-row { display: flex; align-items: center; gap: 8px; }
|
||||
.tmpl-name { font-weight: 500; }
|
||||
|
||||
.preview-col { min-width: 0; }
|
||||
.preview-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.preview-frame {
|
||||
background: #FAFAF7;
|
||||
color: #0A0A0A;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.frame-topbar {
|
||||
height: 52px;
|
||||
background: #0A0A0A;
|
||||
color: #F4F3EE;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.frame-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: #0A0A0A;
|
||||
}
|
||||
.frame-brand { font-family: var(--font-mono); font-size: 13px; font-weight: 600; }
|
||||
.frame-spacer { flex: 1; }
|
||||
.frame-user { font-size: 11px; font-family: var(--font-mono); opacity: 0.6; }
|
||||
.frame-hero { padding: 36px 32px 24px 32px; }
|
||||
.frame-eyebrow { font-family: var(--font-mono); font-size: 10px; color: #5A5A55; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.frame-title { font-family: var(--font-display); font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-top: 8px; }
|
||||
.frame-tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 20px; }
|
||||
.frame-tile { background: #fff; border: 1px solid #E6E4DC; border-radius: 6px; padding: 14px; }
|
||||
.frame-tile-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
background: #0A0A0A;
|
||||
color: #F4F3EE;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.frame-tile-name { font-family: var(--font-display); font-weight: 600; font-size: 14px; margin-top: 12px; }
|
||||
.frame-cta { margin-top: 24px; padding: 18px 20px; border-radius: 6px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.frame-cta-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: #0A0A0A; }
|
||||
.frame-cta-sub { font-size: 12px; color: rgba(10, 10, 10, 0.7); margin-top: 4px; }
|
||||
.frame-cta-btn { height: 32px; padding: 0 14px; border-radius: 5px; border: none; background: #0A0A0A; color: #F4F3EE; font-weight: 600; font-size: 12px; cursor: pointer; }
|
||||
.frame-foot { padding: 12px 32px; border-top: 1px solid #E6E4DC; background: #F4F3EE; font-size: 11px; color: #5A5A55; font-family: var(--font-mono); display: flex; justify-content: space-between; }
|
||||
|
||||
/* Upload modal */
|
||||
.upload { display: flex; flex-direction: column; gap: 14px; }
|
||||
.dropzone {
|
||||
padding: 48px 24px;
|
||||
background: var(--bg);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.dropzone.over { background: var(--surface); border-color: var(--text); }
|
||||
.drop-text { text-align: center; }
|
||||
.drop-title { font-size: 14px; font-weight: 500; color: var(--text); }
|
||||
.upload-preview {
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.upload-mark {
|
||||
height: 56px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.upload-meta { flex: 1; min-width: 0; }
|
||||
.upload-name { font-size: 13px; font-weight: 500; }
|
||||
.check-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.check-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; }
|
||||
.check-row > :first-of-type { flex-shrink: 0; }
|
||||
.ld-preview { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
||||
.ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
||||
.ld-light, .ld-dark { border-radius: 6px; padding: 18px; display: flex; align-items: center; justify-content: center; }
|
||||
.ld-light { background: #FAFAF7; }
|
||||
.ld-dark { background: #0A0A0A; }
|
||||
.ld-mark {
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ld-mark.dark { background: #0A0A0A; color: #F4F3EE; }
|
||||
.ld-mark.light { background: #F4F3EE; color: #0A0A0A; }
|
||||
.req-box { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-mute); line-height: 1.55; }
|
||||
.req-body { margin-top: 6px; }
|
||||
|
||||
/* Email template editor */
|
||||
.tmpl-edit { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; height: 100%; }
|
||||
.tmpl-col { padding: 24px; border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 14px; }
|
||||
.tmpl-prev { padding: 24px; background: var(--bg); display: flex; flex-direction: column; gap: 12px; }
|
||||
.body-area {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.merge-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.merge-tags button {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-frame {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #E6E4DC;
|
||||
color: #0A0A0A;
|
||||
font-family: 'Inter', sans-serif;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.email-head { padding: 16px 20px; border-bottom: 1px solid #E6E4DC; background: #FAFAF7; }
|
||||
.from-row { display: flex; align-items: center; gap: 8px; }
|
||||
.from-mark {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
.email-subj { font-family: var(--font-display); font-weight: 600; font-size: 16px; margin-top: 10px; color: #0A0A0A; }
|
||||
.email-body { padding: 20px; font-size: 13px; line-height: 1.65; color: #3A3A35; white-space: pre-wrap; flex: 1; overflow-y: auto; }
|
||||
.email-foot { padding: 14px 20px; border-top: 1px solid #E6E4DC; color: #0A0A0A; font-size: 11px; font-family: var(--font-mono); text-align: center; }
|
||||
|
||||
/* Publish modal */
|
||||
.publish-intro { font-size: 13px; color: var(--text-dim); line-height: 1.55; margin-bottom: 14px; }
|
||||
.publish-summary { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; margin-top: 8px; margin-bottom: 14px; }
|
||||
.ps-row { display: flex; align-items: center; gap: 12px; }
|
||||
.ps-row > :first-child { width: 100px; }
|
||||
.color-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.color-chip { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border); }
|
||||
.prop-grid { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 12px; margin-top: 8px; margin-bottom: 14px; }
|
||||
.prop-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.prop-cell span:first-of-type { flex: 1; }
|
||||
.publish-warn { padding: 12px; background: rgba(232, 154, 31, 0.06); border-radius: 6px; border: 1px solid rgba(232, 154, 31, 0.2); font-size: 12px; color: var(--text-dim); line-height: 1.55; display: flex; gap: 10px; }
|
||||
.publishing { padding: 32px 0; text-align: center; }
|
||||
.spinner {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 18px auto;
|
||||
border-radius: 999px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
.publish-title { font-family: var(--font-display); font-size: 20px; font-weight: 600; }
|
||||
.done-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
||||
.done-badge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
color: #0A0A0A;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.done-list { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
||||
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
|
||||
.def > div { display: contents; }
|
||||
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
|
||||
.def dd { margin: 0; font-size: 13px; color: var(--text); }
|
||||
|
||||
/* Reset modal */
|
||||
.reset-box { padding: 14px; border-radius: 6px; display: flex; gap: 10px; align-items: flex-start; margin-bottom: 14px; }
|
||||
.reset-box.bad { background: rgba(226, 48, 48, 0.06); border: 1px solid rgba(226, 48, 48, 0.2); }
|
||||
.reset-box > div { font-size: 13px; color: var(--text-dim); line-height: 1.5; }
|
||||
.reset-list { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user