3288fde693
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.
Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.
Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.
Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
with YIQ auto-contrast (readableOn util).
Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
drag-select-to-close).
533 lines
22 KiB
Vue
533 lines
22 KiB
Vue
<script setup lang="ts">
|
||
// Customer whitelabel branding. Real data: product name + accent colour (on the
|
||
// Tenant doc) and email-template overrides (TenantBranding), saved together via
|
||
// PUT /api/tenants/:slug/branding. No backend yet for logo upload or custom-
|
||
// domain verification, so those show honest "coming soon" states.
|
||
//
|
||
// Edit model: name/colour + the open template editor mutate LOCAL state; the
|
||
// header "Save changes" persists everything in one PUT. The per-template
|
||
// "Apply" just commits that template into local overrides.
|
||
|
||
import type { TenantBrandingView } from '~/types/workspace'
|
||
|
||
const toast = useToast()
|
||
const { request } = useApiFetch()
|
||
const { fetchMe } = useMe()
|
||
await fetchMe()
|
||
const { tenant } = useTenant()
|
||
const slug = computed(() => tenant.value?.slug ?? '')
|
||
|
||
const DEFAULT_COLOR = '#D4FF3A'
|
||
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
|
||
|
||
const { data: branding } = await useFetch<TenantBrandingView>(
|
||
() => `/api/tenants/${slug.value}/branding`,
|
||
{ key: 'admin-branding', default: () => ({ name: '', emailTemplates: [] }), immediate: !!slug.value, watch: [slug] },
|
||
)
|
||
|
||
// Local editable state, seeded from the fetched branding.
|
||
const name = ref('')
|
||
const color = ref(DEFAULT_COLOR)
|
||
const overrides = reactive<Record<string, { subject: string; body: string }>>({})
|
||
function seed() {
|
||
const b = branding.value
|
||
name.value = b?.name ?? ''
|
||
color.value = b?.brandColor || DEFAULT_COLOR
|
||
for (const k of Object.keys(overrides)) delete overrides[k]
|
||
for (const t of b?.emailTemplates ?? []) overrides[t.key] = { subject: t.subject, body: t.body }
|
||
}
|
||
seed()
|
||
watch(branding, seed)
|
||
|
||
const primaryDomain = computed(() => branding.value?.primaryDomain ?? '')
|
||
|
||
// Auto-contrast foreground for surfaces painted in the accent colour, so the
|
||
// preview stays legible whether the accent is bright or dark.
|
||
const accentFg = computed(() => readableOn(color.value))
|
||
const accentBtnText = computed(() => readableOn(accentFg.value))
|
||
|
||
// Canonical template defaults. Overrides (saved per tenant) win over these.
|
||
const TEMPLATES = [
|
||
{ id: 'invitation', name: 'User invitation', subject: 'You’ve been invited to {{workspace.name}}', desc: 'sent when an admin invites a new user' },
|
||
{ id: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests' },
|
||
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests' },
|
||
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry' },
|
||
] as const
|
||
|
||
const TEMPLATE_BODIES: Record<string, string> = {
|
||
invitation: `Hi {{user.first_name}},
|
||
|
||
{{inviter.name}} has invited you to join {{workspace.name}}.
|
||
|
||
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'],
|
||
}
|
||
|
||
function isEdited(id: string): boolean {
|
||
return !!overrides[id]
|
||
}
|
||
|
||
// ── Template editor (side panel) ─────────────────────────────────────────
|
||
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
|
||
const subject = ref('')
|
||
const body = ref('')
|
||
const testSent = ref(false)
|
||
|
||
function openTemplate(t: typeof TEMPLATES[number]) {
|
||
editTemplate.value = t
|
||
subject.value = overrides[t.id]?.subject ?? t.subject
|
||
body.value = overrides[t.id]?.body ?? (TEMPLATE_BODIES[t.id] || '')
|
||
testSent.value = false
|
||
}
|
||
|
||
// Wrap a merge-tag name in mustaches via JS so the template never nests
|
||
// `{{ ... }}` inside `{{ ... }}` (Vue's parser scans positionally and breaks).
|
||
function wrapTag(tag: string) {
|
||
return '{' + '{' + tag + '}' + '}'
|
||
}
|
||
function insertTag(tag: string) {
|
||
body.value += wrapTag(tag)
|
||
}
|
||
|
||
// Commit the open template into local overrides (header Save persists).
|
||
function applyTemplate() {
|
||
if (!editTemplate.value) return
|
||
overrides[editTemplate.value.id] = { subject: subject.value, body: body.value }
|
||
editTemplate.value = null
|
||
}
|
||
|
||
// Drop the override → revert to the canonical default (persisted on Save).
|
||
function resetTemplate() {
|
||
if (!editTemplate.value) return
|
||
delete overrides[editTemplate.value.id]
|
||
subject.value = editTemplate.value.subject
|
||
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
|
||
toast.info('Reverted to default', 'Save changes to apply')
|
||
}
|
||
|
||
function sendTest() {
|
||
testSent.value = true
|
||
setTimeout(() => (testSent.value = false), 2500)
|
||
}
|
||
|
||
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, primaryDomain.value || 'your-workspace')
|
||
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
|
||
.replace(/\{\{user\.email\}\}/g, 'anne@example.com')
|
||
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
|
||
.replace(/\{\{invite\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/accept/x9k2a`)
|
||
.replace(/\{\{reset\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/reset/p2b7c`)
|
||
.replace(/\{\{billing\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/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'),
|
||
)
|
||
|
||
// ── Save ─────────────────────────────────────────────────────────────────
|
||
const saving = ref(false)
|
||
async function save() {
|
||
if (!slug.value) return
|
||
saving.value = true
|
||
try {
|
||
const payload = {
|
||
name: name.value,
|
||
brandColor: color.value,
|
||
emailTemplates: Object.entries(overrides).map(([key, v]) => ({ key, subject: v.subject, body: v.body })),
|
||
}
|
||
branding.value = await request<TenantBrandingView>(`/api/tenants/${slug.value}/branding`, {
|
||
method: 'PUT',
|
||
body: payload,
|
||
})
|
||
await fetchMe(true) // name lives on the tenant → refresh dashboard/sidebar identity
|
||
toast.ok('Branding saved')
|
||
} catch (e: unknown) {
|
||
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
|
||
toast.bad('Could not save branding', Array.isArray(msg) ? msg.join(', ') : msg)
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<PageHeader
|
||
eyebrow="Whitelabel"
|
||
title="Branding"
|
||
subtitle="Give your workspace its own name, accent colour, and email copy."
|
||
>
|
||
<template #actions>
|
||
<UiButton variant="primary" :disabled="saving || !slug" @click="save">
|
||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||
{{ saving ? 'Saving…' : 'Save changes' }}
|
||
</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 v-if="primaryDomain" class="input-row">
|
||
<input :value="primaryDomain" readonly />
|
||
</div>
|
||
<div v-else class="soon-inline">
|
||
<Mono dim>Custom domains coming soon</Mono>
|
||
</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="soon-box">
|
||
<UiIcon name="upload" :size="16" stroke="var(--text-mute)" />
|
||
<div>Logo, square mark and favicon upload is coming soon. For now your accent colour and product name drive the workspace look.</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)">
|
||
<div class="tmpl-meta">
|
||
<div class="tmpl-name-row">
|
||
<span class="tmpl-name">{{ t.name }}</span>
|
||
<Badge :tone="isEdited(t.id) ? 'info' : 'neutral'">{{ isEdited(t.id) ? 'edited' : 'default' }}</Badge>
|
||
</div>
|
||
<Mono dim>{{ t.desc }}</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>{{ primaryDomain || slug }}</Mono>
|
||
</div>
|
||
<div class="preview-frame">
|
||
<div class="frame-topbar">
|
||
<div class="frame-mark" :style="{ background: color, color: accentFg }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
||
<div class="frame-brand">{{ name.toLowerCase() }}</div>
|
||
<div class="frame-spacer" />
|
||
<div class="frame-user">{{ primaryDomain ? `you@${primaryDomain}` : 'your team' }}</div>
|
||
</div>
|
||
<div class="frame-hero">
|
||
<div class="frame-eyebrow">Dashboard</div>
|
||
<div class="frame-title">Good morning.</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" :style="{ color: accentFg }">Welcome to {{ name || 'your workspace' }}.</div>
|
||
<div class="frame-cta-sub" :style="{ color: accentFg, opacity: 0.75 }">Your team's workspace is ready.</div>
|
||
</div>
|
||
<button class="frame-cta-btn" :style="{ background: accentFg, color: accentBtnText }">Get started</button>
|
||
</div>
|
||
</div>
|
||
<div class="frame-foot">
|
||
<span>powered by dezky</span>
|
||
<span>v1.0 · light</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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 || 'workspace').toLowerCase().replace(/\s+/g, '-') }}</Mono>
|
||
</div>
|
||
<div class="email-subj">{{ renderedSubject }}</div>
|
||
</div>
|
||
<div class="email-body">{{ renderedBody }}</div>
|
||
<div class="email-foot" :style="{ background: color, color: accentFg }">{{ name || 'Your workspace' }}{{ primaryDomain ? ` · ${primaryDomain}` : '' }}</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="sendTest">
|
||
<template #leading><UiIcon name="mail" :size="13" /></template>
|
||
{{ testSent ? 'Test queued ✓' : 'Send test to me' }}
|
||
</UiButton>
|
||
<UiButton variant="primary" @click="applyTemplate">
|
||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||
Apply
|
||
</UiButton>
|
||
</template>
|
||
</SidePanel>
|
||
</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; }
|
||
.soon-inline { padding: 8px 0; }
|
||
|
||
.soon-box {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
padding: 14px;
|
||
background: var(--bg);
|
||
border: 1px dashed var(--border-hi, var(--border));
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
color: var(--text-dim);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.swatches { display: flex; gap: 10px; margin-bottom: 14px; }
|
||
.swatches button { width: 38px; height: 38px; border-radius: 6px; cursor: pointer; }
|
||
|
||
.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; }
|
||
|
||
/* 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; }
|
||
</style>
|