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:
@@ -225,6 +225,36 @@ async function confirmDetach() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team (partner users) ──────────────────────────────────────────────────
|
||||
// Lists users whose User.partnerId === this partner. Invite flow surfaces a
|
||||
// modal that POSTs to /api/partners/:slug/users, which proxies platform-api
|
||||
// and creates the Authentik user + group + local User doc atomically.
|
||||
|
||||
interface PartnerUser {
|
||||
_id: string
|
||||
authentikSubjectId: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
active: boolean
|
||||
lastLoginAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const { data: team, refresh: refreshTeam } = await useFetch<PartnerUser[]>(
|
||||
() => `/api/partners/${slug.value}/users`,
|
||||
{ default: () => [], watch: [slug] },
|
||||
)
|
||||
|
||||
const inviteOpen = ref(false)
|
||||
|
||||
function onInvited() {
|
||||
// Don't close the modal — the user needs to see the recovery link / temp
|
||||
// password. Just refresh the team list in the background so the new user
|
||||
// is visible once they click Done.
|
||||
void refreshTeam()
|
||||
}
|
||||
|
||||
// ── Soft-terminate partner ────────────────────────────────────────────────
|
||||
const terminateOpen = ref(false)
|
||||
const terminateBusy = ref(false)
|
||||
@@ -371,7 +401,7 @@ async function confirmTerminate() {
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>Country</dt>
|
||||
<dd><input v-model="draft.billingInfo.country" class="field mono country" type="text" maxlength="2" placeholder="DK" :disabled="saving" /></dd>
|
||||
<dd><CountrySelect v-model="draft.billingInfo.country" :disabled="saving" /></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -420,6 +450,43 @@ async function confirmTerminate() {
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head padded">
|
||||
<div>
|
||||
<h2>Team</h2>
|
||||
<p class="hint">People at <Mono>{{ partner.name }}</Mono> who can sign in. <Mono dim>partnerId</Mono> on the user record points here.</p>
|
||||
</div>
|
||||
<UiButton variant="primary" @click="inviteOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Invite team member
|
||||
</UiButton>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Email</th><th>Role</th><th>Last login</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="(team ?? []).length === 0" class="empty">
|
||||
<td colspan="5">
|
||||
<span class="empty-inner">No team members yet. Click <Mono>Invite team member</Mono> to add one.</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="u in (team ?? [])" :key="u._id">
|
||||
<td>
|
||||
<div class="cell-name">{{ u.name }}</div>
|
||||
<Mono dim>{{ u.authentikSubjectId }}</Mono>
|
||||
</td>
|
||||
<td><Mono>{{ u.email }}</Mono></td>
|
||||
<td><Badge tone="neutral">{{ u.role }}</Badge></td>
|
||||
<td><Mono :dim="!u.lastLoginAt">{{ u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'never' }}</Mono></td>
|
||||
<td><Badge :tone="u.active ? 'ok' : 'bad'" dot>{{ u.active ? 'active' : 'disabled' }}</Badge></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 class="danger">Soft-terminate partner</h2>
|
||||
<p>
|
||||
@@ -492,6 +559,15 @@ async function confirmTerminate() {
|
||||
</p>
|
||||
<p v-if="terminateError" class="err">{{ terminateError }}</p>
|
||||
</ConfirmDialog>
|
||||
|
||||
<!-- Invite partner team-member modal -->
|
||||
<InvitePartnerUserModal
|
||||
:open="inviteOpen"
|
||||
:partner-slug="partner.slug"
|
||||
:partner-name="partner.name"
|
||||
@close="inviteOpen = false"
|
||||
@invited="onInvited"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
// Pricing catalog editor. Operator-only. Each (plan, cycle) is a single row
|
||||
// with three independent per-currency amounts (DKK / EUR / USD). Operator
|
||||
// types clean round numbers in each currency — no FX derivation. Empty cells
|
||||
// mean "we don't sell this plan/cycle in that currency."
|
||||
//
|
||||
// Backed by /prices in platform-api. Amounts are stored in MINOR units
|
||||
// (4900 = 49.00); display uses major-unit strings with 2 decimals.
|
||||
|
||||
const CURRENCIES = ['DKK', 'EUR', 'USD'] as const
|
||||
type Currency = (typeof CURRENCIES)[number]
|
||||
|
||||
interface PriceRow {
|
||||
_id: string
|
||||
plan: 'mvp' | 'pro' | 'enterprise'
|
||||
cycle: 'monthly' | 'quarterly' | 'yearly'
|
||||
amounts: Partial<Record<Currency, number>>
|
||||
active: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const showInactive = ref(false)
|
||||
|
||||
const { data: prices, refresh } = await useFetch<PriceRow[]>(
|
||||
() => `/api/prices${showInactive.value ? '?includeInactive=true' : ''}`,
|
||||
{ key: 'pricing-catalog', default: () => [], watch: [showInactive] },
|
||||
)
|
||||
|
||||
const PLAN_LABEL: Record<PriceRow['plan'], string> = {
|
||||
mvp: 'Starter',
|
||||
pro: 'Business',
|
||||
enterprise: 'Enterprise',
|
||||
}
|
||||
const CYCLE_LABEL: Record<PriceRow['cycle'], string> = {
|
||||
monthly: 'Monthly',
|
||||
quarterly: 'Quarterly',
|
||||
yearly: 'Yearly',
|
||||
}
|
||||
|
||||
function toMajor(minor?: number): string {
|
||||
if (typeof minor !== 'number') return ''
|
||||
return (minor / 100).toFixed(2)
|
||||
}
|
||||
function toMinor(major: string): number | undefined {
|
||||
if (!major.trim()) return undefined // empty → unset that currency
|
||||
const cleaned = major.replace(/\s+/g, '').replace(',', '.')
|
||||
const n = Number(cleaned)
|
||||
if (!Number.isFinite(n) || n < 0) return NaN
|
||||
return Math.round(n * 100)
|
||||
}
|
||||
|
||||
// Per-row draft. Keyed by row._id, holds one major-unit string per currency.
|
||||
// undefined draft = not currently editing.
|
||||
type Draft = Record<Currency, string>
|
||||
const drafts = reactive<Record<string, Draft>>({})
|
||||
const saving = ref<string | null>(null)
|
||||
|
||||
function startEdit(row: PriceRow) {
|
||||
drafts[row._id] = {
|
||||
DKK: toMajor(row.amounts.DKK),
|
||||
EUR: toMajor(row.amounts.EUR),
|
||||
USD: toMajor(row.amounts.USD),
|
||||
}
|
||||
}
|
||||
function cancelEdit(id: string) {
|
||||
delete drafts[id]
|
||||
}
|
||||
|
||||
async function saveEdit(row: PriceRow) {
|
||||
const draft = drafts[row._id]
|
||||
if (!draft) return
|
||||
const next: Partial<Record<Currency, number>> = {}
|
||||
for (const c of CURRENCIES) {
|
||||
const parsed = toMinor(draft[c])
|
||||
if (parsed === undefined) continue // empty input → currency stays unset
|
||||
if (Number.isNaN(parsed)) {
|
||||
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0, or blank to leave unset')
|
||||
return
|
||||
}
|
||||
next[c] = parsed
|
||||
}
|
||||
saving.value = row._id
|
||||
try {
|
||||
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { amounts: next } })
|
||||
toast.ok('Prices updated', `${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`)
|
||||
cancelEdit(row._id)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
||||
toast.warn('Update failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(row: PriceRow) {
|
||||
saving.value = row._id
|
||||
try {
|
||||
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { active: !row.active } })
|
||||
toast.ok(
|
||||
row.active ? 'Price deactivated' : 'Price reactivated',
|
||||
`${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`,
|
||||
)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
||||
toast.warn('Toggle failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Add-row form. Used to insert an Enterprise row or replace a deactivated one.
|
||||
const addForm = reactive<{
|
||||
plan: PriceRow['plan']
|
||||
cycle: PriceRow['cycle']
|
||||
amounts: Record<Currency, string>
|
||||
}>({
|
||||
plan: 'enterprise',
|
||||
cycle: 'monthly',
|
||||
amounts: { DKK: '', EUR: '', USD: '' },
|
||||
})
|
||||
const adding = ref(false)
|
||||
|
||||
async function addRow() {
|
||||
const amounts: Partial<Record<Currency, number>> = {}
|
||||
for (const c of CURRENCIES) {
|
||||
const parsed = toMinor(addForm.amounts[c])
|
||||
if (parsed === undefined) continue
|
||||
if (Number.isNaN(parsed)) {
|
||||
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0')
|
||||
return
|
||||
}
|
||||
amounts[c] = parsed
|
||||
}
|
||||
if (Object.keys(amounts).length === 0) {
|
||||
toast.warn('No prices entered', 'Set at least one currency amount')
|
||||
return
|
||||
}
|
||||
adding.value = true
|
||||
try {
|
||||
await $fetch('/api/prices', {
|
||||
method: 'POST',
|
||||
body: { plan: addForm.plan, cycle: addForm.cycle, amounts },
|
||||
})
|
||||
toast.ok('Row added', `${PLAN_LABEL[addForm.plan]} · ${CYCLE_LABEL[addForm.cycle]}`)
|
||||
for (const c of CURRENCIES) addForm.amounts[c] = ''
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
||||
toast.warn('Add failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: active first, then plan order (mvp/pro/enterprise), then cycle order.
|
||||
const PLAN_ORDER: Record<PriceRow['plan'], number> = { mvp: 0, pro: 1, enterprise: 2 }
|
||||
const CYCLE_ORDER: Record<PriceRow['cycle'], number> = { monthly: 0, quarterly: 1, yearly: 2 }
|
||||
const sortedPrices = computed<PriceRow[]>(() =>
|
||||
[...(prices.value ?? [])].sort((a, b) => {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1
|
||||
if (a.plan !== b.plan) return PLAN_ORDER[a.plan] - PLAN_ORDER[b.plan]
|
||||
return CYCLE_ORDER[a.cycle] - CYCLE_ORDER[b.cycle]
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stage">
|
||||
<PageHeader
|
||||
eyebrow="Operator · operator.dezky.local"
|
||||
title="Pricing catalog"
|
||||
subtitle="One row per plan + cycle, with independent prices per currency. Changes affect subscriptions provisioned from now on — existing customers keep the price snapshot taken at provisioning."
|
||||
>
|
||||
<template #actions>
|
||||
<label class="toggle">
|
||||
<input v-model="showInactive" type="checkbox" />
|
||||
Show inactive
|
||||
</label>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<Card :pad="0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plan</th>
|
||||
<th>Cycle</th>
|
||||
<th v-for="c in CURRENCIES" :key="c" class="th-amount">{{ c }} / seat</th>
|
||||
<th>Status</th>
|
||||
<th class="th-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="sortedPrices.length === 0" class="empty">
|
||||
<td :colspan="4 + CURRENCIES.length">
|
||||
<span class="empty-inner">No prices yet — add one below.</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="row in sortedPrices" :key="row._id" :class="{ inactive: !row.active }">
|
||||
<td>
|
||||
<div class="cell-name">{{ PLAN_LABEL[row.plan] }}</div>
|
||||
<Mono dim>{{ row.plan }}</Mono>
|
||||
</td>
|
||||
<td>{{ CYCLE_LABEL[row.cycle] }}</td>
|
||||
<td v-for="c in CURRENCIES" :key="c" class="cell-amount">
|
||||
<template v-if="drafts[row._id]">
|
||||
<input
|
||||
v-model="drafts[row._id][c]"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
class="amount-input"
|
||||
:placeholder="`— ${c}`"
|
||||
:disabled="saving === row._id"
|
||||
@keydown.enter="saveEdit(row)"
|
||||
@keydown.escape="cancelEdit(row._id)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="row.amounts[c] !== undefined">
|
||||
<span class="amount">{{ toMajor(row.amounts[c]) }}</span>
|
||||
</template>
|
||||
<Mono v-else dim>—</Mono>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<Badge :tone="row.active ? 'ok' : 'neutral'" dot>{{ row.active ? 'active' : 'inactive' }}</Badge>
|
||||
</td>
|
||||
<td class="cell-actions">
|
||||
<div class="actions">
|
||||
<template v-if="drafts[row._id]">
|
||||
<UiButton size="sm" variant="primary" :disabled="saving === row._id" @click="saveEdit(row)">
|
||||
{{ saving === row._id ? 'Saving…' : 'Save' }}
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="cancelEdit(row._id)">
|
||||
Cancel
|
||||
</UiButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UiButton size="sm" variant="secondary" @click="startEdit(row)">Edit</UiButton>
|
||||
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="toggleActive(row)">
|
||||
{{ row.active ? 'Deactivate' : 'Reactivate' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2>Add a row</h2>
|
||||
<p class="hint">
|
||||
Used for missing Enterprise rows or to replace a deactivated row.
|
||||
Fill in only the currencies you want to offer; blank cells mean
|
||||
"not sold in this currency."
|
||||
</p>
|
||||
<div class="add-form">
|
||||
<label class="field">
|
||||
<Eyebrow>Plan</Eyebrow>
|
||||
<select v-model="addForm.plan">
|
||||
<option value="mvp">Starter</option>
|
||||
<option value="pro">Business</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Cycle</Eyebrow>
|
||||
<select v-model="addForm.cycle">
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="quarterly">Quarterly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-for="c in CURRENCIES" :key="c" class="field">
|
||||
<Eyebrow>{{ c }} / seat</Eyebrow>
|
||||
<input
|
||||
v-model="addForm.amounts[c]"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
:placeholder="`e.g. ${c === 'DKK' ? '49.00' : '7.00'}`"
|
||||
/>
|
||||
</label>
|
||||
<UiButton variant="primary" :disabled="adding" @click="addRow">
|
||||
{{ adding ? 'Adding…' : 'Add row' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stage { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
||||
th { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; }
|
||||
.cell-name { font-size: 13px; font-weight: 500; }
|
||||
.empty .empty-inner { display: block; padding: 24px 0; text-align: center; color: var(--text-mute); }
|
||||
tr.inactive { opacity: 0.55; }
|
||||
|
||||
.th-amount, .cell-amount { text-align: right; width: 130px; white-space: nowrap; }
|
||||
.amount { font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.amount-input {
|
||||
width: 96px;
|
||||
padding: 6px 8px;
|
||||
text-align: right;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.th-actions, .cell-actions { text-align: right; width: 220px; }
|
||||
.actions { display: inline-flex; gap: 6px; align-items: center; justify-content: flex-end; }
|
||||
|
||||
.add-form { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: 12px; align-items: end; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field select, .field input {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: 0;
|
||||
}
|
||||
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
// Sign-out landing for the operator portal. /api/auth/sign-out cleared the
|
||||
// local session and bounced through Authentik's end-session endpoint, which
|
||||
// ended the IdP session. By the time we render here the user has no
|
||||
// session anywhere — clicking Sign in again forces fresh credentials.
|
||||
|
||||
definePageMeta({ layout: 'blank', auth: false, oidcAuth: { enabled: false } })
|
||||
|
||||
function signInAgain() {
|
||||
return navigateTo('/auth/oidc/login', { external: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="card">
|
||||
<div class="badge">
|
||||
<UiIcon name="shield" :size="22" />
|
||||
</div>
|
||||
<p class="eyebrow">dezky · ops</p>
|
||||
<h1>You're signed out</h1>
|
||||
<p class="lead">
|
||||
Your operator session has been ended. Sign in again whenever you're
|
||||
ready — Authentik will ask for fresh credentials.
|
||||
</p>
|
||||
<button class="primary" type="button" @click="signInAgain">Sign in again</button>
|
||||
<p class="hint">operator.dezky.local</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 36px 36px 32px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(52, 199, 123, 0.12);
|
||||
color: var(--ok);
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.55;
|
||||
margin: 14px 0 26px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:hover { filter: brightness(0.96); }
|
||||
|
||||
.hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-mute);
|
||||
margin: 22px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user