89691626f4
Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation. Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save. Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
603 lines
19 KiB
Vue
603 lines
19 KiB
Vue
<script setup lang="ts">
|
|
// Full portfolio list. Strict port of CustomersScreen in partner-screens.jsx
|
|
// (lines 366-494). Table + cards view toggle, status/plan filters, search.
|
|
// Click a row → confirm enter customer modal → partnerMode.enter() + /admin.
|
|
|
|
|
|
|
|
import type { CustomerOrg, CustomerStatus, PartnerTenantDoc } from '~/types/partner'
|
|
|
|
const toast = useToast()
|
|
const router = useRouter()
|
|
const partnerMode = usePartnerMode()
|
|
|
|
const view = ref<'table' | 'cards'>('table')
|
|
const query = ref('')
|
|
const statusFilter = ref<'all' | CustomerStatus>('all')
|
|
const planFilter = ref<'all' | 'starter' | 'business' | 'enterprise'>('all')
|
|
|
|
const wizardOpen = ref(false)
|
|
const entryCustomer = ref<CustomerOrg | null>(null)
|
|
|
|
// Real tenants attached to this partner, via the shared composable which owns
|
|
// the canonical PartnerTenantDoc and the 'partner-tenants' cache key (dashboard
|
|
// + customers reuse one fetch). Health score, industry, and brand colour now
|
|
// come from real fields on the response.
|
|
const { tenants: rawTenants, refresh: refreshTenants } = usePartnerTenants()
|
|
|
|
// Per-tenant MRR from the shared composable (same 'partner-mrr' cache key as the
|
|
// dashboard). mrrByTenant is the tenantId → breakdown-row lookup the table reads.
|
|
const { mrrByTenant, refresh: refreshMrr } = usePartnerMrr()
|
|
|
|
function mapTenantStatus(s: PartnerTenantDoc['status']): CustomerStatus {
|
|
// Tenant.status values overlap partially with the fixture's CustomerStatus.
|
|
// Best-effort map: deleted/suspended → suspended badge, pending → trial,
|
|
// active stays active (rendered as 'healthy').
|
|
if (s === 'active') return 'healthy'
|
|
if (s === 'pending') return 'trial'
|
|
return 'suspended'
|
|
}
|
|
|
|
// Backend plan enum (mvp/pro/enterprise) → fixture-friendly slug + label.
|
|
// Same mapping mirrored in CustomerCreateWizard.vue; keep both in sync if
|
|
// the backend enum ever changes.
|
|
const PLAN_INFO: Record<
|
|
'mvp' | 'pro' | 'enterprise',
|
|
{ slug: CustomerOrg['plan']; label: CustomerOrg['planLabel'] }
|
|
> = {
|
|
mvp: { slug: 'starter', label: 'Starter' },
|
|
pro: { slug: 'business', label: 'Business' },
|
|
enterprise: { slug: 'enterprise', label: 'Enterprise' },
|
|
}
|
|
|
|
// Type extension on the row so the table can show the currency next to
|
|
// the amount (the original fixture only had a DKK number, but real data
|
|
// can be in DKK/EUR/USD).
|
|
interface CustomerRow extends CustomerOrg {
|
|
slug: string
|
|
mrrCurrency: 'DKK' | 'EUR' | 'USD'
|
|
mrrCustom: boolean
|
|
}
|
|
|
|
const customers = computed<CustomerRow[]>(() =>
|
|
(rawTenants.value ?? []).map((t) => {
|
|
const info = PLAN_INFO[t.plan ?? 'pro']
|
|
const subMrr = mrrByTenant.value.get(t._id)
|
|
return {
|
|
id: t._id,
|
|
slug: t.slug,
|
|
name: t.name,
|
|
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
|
|
plan: info.slug,
|
|
planLabel: info.label,
|
|
// Real seat utilisation: count of active User docs attached to this
|
|
// tenant vs the contractual seat total from provisioning.
|
|
seats: { used: t.userCount ?? 0, total: t.seats ?? 0 },
|
|
health: t.healthScore ?? 100,
|
|
status: mapTenantStatus(t.status),
|
|
// MRR for this tenant, in major units. From /api/partner/mrr; 0 when
|
|
// the sub has no priced amount (Enterprise / pre-catalog tenants).
|
|
mrrDkk: subMrr ? Math.round(subMrr.monthlyMinor / 100) : 0,
|
|
mrrCurrency: subMrr?.currency ?? 'DKK',
|
|
mrrCustom: subMrr?.custom ?? false,
|
|
brandColor: t.brandColor || '#D4FF3A',
|
|
industry: t.industry ?? '—',
|
|
createdOn: t.createdAt ?? '',
|
|
since: t.createdAt ?? '',
|
|
}
|
|
}),
|
|
)
|
|
|
|
const filtered = computed(() => {
|
|
return customers.value.filter((c) => {
|
|
if (statusFilter.value !== 'all' && c.status !== statusFilter.value) return false
|
|
if (planFilter.value !== 'all' && c.plan !== planFilter.value) return false
|
|
if (query.value) {
|
|
const q = query.value.toLowerCase()
|
|
if (!(c.name + ' ' + c.domain).toLowerCase().includes(q)) return false
|
|
}
|
|
return true
|
|
})
|
|
})
|
|
|
|
const statusOpts = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'healthy', label: 'Healthy' },
|
|
{ value: 'attention', label: 'Attention' },
|
|
{ value: 'past_due', label: 'Past due' },
|
|
{ value: 'trial', label: 'Trial' },
|
|
] as const
|
|
|
|
const planOpts = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'starter', label: 'Starter' },
|
|
{ value: 'business', label: 'Business' },
|
|
{ value: 'enterprise', label: 'Enterprise' },
|
|
] as const
|
|
|
|
function statusBadge(s: CustomerStatus): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
|
|
switch (s) {
|
|
case 'healthy': return { tone: 'ok', label: 'healthy' }
|
|
case 'attention': return { tone: 'warn', label: 'attention' }
|
|
case 'past_due': return { tone: 'bad', label: 'past-due' }
|
|
case 'trial': return { tone: 'info', label: 'trial' }
|
|
case 'suspended': return { tone: 'neutral', label: 'suspended' }
|
|
}
|
|
}
|
|
|
|
function startEnter(c: CustomerOrg) {
|
|
entryCustomer.value = c
|
|
}
|
|
|
|
async function onProvisioned() {
|
|
toast.ok('Customer provisioned', 'Tenant created — provisioning runs in the background')
|
|
// Refetch so the new tenant + its subscription's MRR show up without a
|
|
// full reload. refreshNuxtData also nudges the dashboard's cached
|
|
// MRR card the next time the user navigates back to /partner.
|
|
await Promise.all([
|
|
refreshTenants(),
|
|
refreshMrr(),
|
|
refreshNuxtData('partner-tenants'),
|
|
refreshNuxtData('partner-mrr'),
|
|
])
|
|
}
|
|
|
|
function confirmEnter(reason: string) {
|
|
if (!entryCustomer.value) return
|
|
const c = entryCustomer.value
|
|
partnerMode.enter(c.id)
|
|
entryCustomer.value = null
|
|
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
|
|
router.push('/admin')
|
|
}
|
|
|
|
// ── Edit / suspend a customer ─────────────────────────────────────────────
|
|
const editCustomer = ref<CustomerRow | null>(null)
|
|
const editForm = reactive({ name: '', industry: '', brandColor: '#D4FF3A', seats: 0 })
|
|
const savingEdit = ref(false)
|
|
|
|
function startEdit(c: CustomerRow) {
|
|
editCustomer.value = c
|
|
editForm.name = c.name
|
|
editForm.industry = c.industry === '—' ? '' : c.industry
|
|
editForm.brandColor = c.brandColor
|
|
editForm.seats = c.seats.total
|
|
}
|
|
|
|
async function refreshAll() {
|
|
await Promise.all([
|
|
refreshTenants(),
|
|
refreshMrr(),
|
|
refreshNuxtData('partner-tenants'),
|
|
refreshNuxtData('partner-mrr'),
|
|
])
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (!editCustomer.value) return
|
|
if (!editForm.name.trim()) {
|
|
toast.bad('Name required', 'Customer name cannot be empty')
|
|
return
|
|
}
|
|
savingEdit.value = true
|
|
try {
|
|
await $fetch(`/api/partner/tenants/${editCustomer.value.slug}`, {
|
|
method: 'PATCH',
|
|
body: {
|
|
name: editForm.name,
|
|
industry: editForm.industry,
|
|
brandColor: editForm.brandColor,
|
|
seats: editForm.seats,
|
|
},
|
|
})
|
|
toast.ok('Saved', `${editForm.name} updated`)
|
|
editCustomer.value = null
|
|
await refreshAll()
|
|
} catch (e: unknown) {
|
|
const err = e as { data?: { message?: string }; statusMessage?: string }
|
|
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save customer')
|
|
} finally {
|
|
savingEdit.value = false
|
|
}
|
|
}
|
|
|
|
async function toggleSuspend(c: CustomerRow) {
|
|
const action = c.status === 'suspended' ? 'resume' : 'suspend'
|
|
try {
|
|
await $fetch(`/api/partner/tenants/${c.slug}/${action}`, { method: 'POST' })
|
|
toast.ok(action === 'suspend' ? 'Suspended' : 'Resumed', c.name)
|
|
editCustomer.value = null
|
|
await refreshAll()
|
|
} catch (e: unknown) {
|
|
const err = e as { data?: { message?: string }; statusMessage?: string }
|
|
toast.bad('Action failed', err.data?.message || err.statusMessage || 'Could not update customer')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<PageHeader
|
|
eyebrow="Portfolio"
|
|
title="Customer organizations"
|
|
subtitle="Every customer org under your reseller agreement. Click to manage as partner."
|
|
>
|
|
<template #actions>
|
|
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${customers.length} customers`)">
|
|
<template #leading><UiIcon name="download" :size="14" /></template>
|
|
Export CSV
|
|
</UiButton>
|
|
<UiButton variant="primary" @click="wizardOpen = true">
|
|
<template #leading><UiIcon name="plus" :size="14" /></template>
|
|
New customer
|
|
</UiButton>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<div class="content">
|
|
<!-- Filter bar -->
|
|
<div class="filters">
|
|
<div class="search">
|
|
<UiIcon name="search" :size="14" />
|
|
<input v-model="query" placeholder="Search customer or domain…" />
|
|
</div>
|
|
|
|
<div class="seg">
|
|
<span class="seg-label">Status</span>
|
|
<select v-model="statusFilter">
|
|
<option v-for="o in statusOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="seg">
|
|
<span class="seg-label">Plan</span>
|
|
<select v-model="planFilter">
|
|
<option v-for="o in planOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="spacer" />
|
|
|
|
<Mono dim>{{ filtered.length }} of {{ customers.length }}</Mono>
|
|
|
|
<div class="view-toggle">
|
|
<button
|
|
v-for="v in (['table','cards'] as const)"
|
|
:key="v"
|
|
:class="{ active: view === v }"
|
|
@click="view = v"
|
|
>{{ v }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table view -->
|
|
<Card v-if="view === 'table'" :pad="0">
|
|
<div class="table-wrap">
|
|
<table class="dtable">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable">Customer <UiIcon name="chevUpDown" :size="11" /></th>
|
|
<th class="sortable">Plan <UiIcon name="chevUpDown" :size="11" /></th>
|
|
<th class="sortable">Seats <UiIcon name="chevUpDown" :size="11" /></th>
|
|
<th class="sortable num">MRR <UiIcon name="chevUpDown" :size="11" /></th>
|
|
<th class="sortable">Status <UiIcon name="chevUpDown" :size="11" /></th>
|
|
<th>Customer since</th>
|
|
<th class="action-col" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="c in filtered" :key="c.id" @click="startEnter(c)">
|
|
<td>
|
|
<div class="cust-cell">
|
|
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
|
<div>
|
|
<div class="cust-name">{{ c.name }}</div>
|
|
<Mono dim>{{ c.domain }}</Mono>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<Badge :tone="c.plan === 'enterprise' ? 'invert' : 'neutral'">{{ c.planLabel }}</Badge>
|
|
</td>
|
|
<td>
|
|
<Mono>{{ c.seats.used }}/{{ c.seats.total }}</Mono>
|
|
</td>
|
|
<td class="num">
|
|
<span class="mrr">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</span>
|
|
</td>
|
|
<td>
|
|
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
|
|
</td>
|
|
<td><Mono dim>{{ c.since }}</Mono></td>
|
|
<td class="action-col" @click.stop>
|
|
<div class="row-actions">
|
|
<UiButton size="sm" variant="ghost" @click="startEdit(c)">Edit</UiButton>
|
|
<UiButton size="sm" variant="secondary" @click="startEnter(c)">
|
|
Manage
|
|
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
|
</UiButton>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="filtered.length === 0">
|
|
<td colspan="7" class="empty">No customers match these filters.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
<!-- Cards view -->
|
|
<div v-else class="cards-grid">
|
|
<button
|
|
v-for="c in filtered"
|
|
:key="c.id"
|
|
class="ccard"
|
|
@click="startEnter(c)"
|
|
>
|
|
<div class="ccard-stripe" :style="{ background: c.brandColor }" />
|
|
<div class="ccard-body">
|
|
<div class="ccard-head">
|
|
<div class="ccard-id">
|
|
<div class="ccard-name">{{ c.name }}</div>
|
|
<Mono dim>{{ c.domain }}</Mono>
|
|
</div>
|
|
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
|
|
</div>
|
|
<div class="ccard-meta">
|
|
<div>
|
|
<Eyebrow>Plan</Eyebrow>
|
|
<div class="cm-val">{{ c.planLabel }}</div>
|
|
</div>
|
|
<div>
|
|
<Eyebrow>Seats</Eyebrow>
|
|
<div class="cm-val mono">{{ c.seats.used }} / {{ c.seats.total }}</div>
|
|
</div>
|
|
<div>
|
|
<Eyebrow>MRR</Eyebrow>
|
|
<div class="cm-val mono">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</div>
|
|
</div>
|
|
<div>
|
|
<Eyebrow>Since</Eyebrow>
|
|
<div class="cm-val">{{ c.since }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<PartnerCustomerCreateWizard
|
|
:open="wizardOpen"
|
|
@close="wizardOpen = false"
|
|
@done="onProvisioned"
|
|
/>
|
|
<PartnerEnterCustomerConfirmModal
|
|
:customer="entryCustomer"
|
|
@close="entryCustomer = null"
|
|
@confirm="confirmEnter"
|
|
/>
|
|
|
|
<Modal
|
|
:open="!!editCustomer"
|
|
eyebrow="Customer"
|
|
:title="`Edit ${editCustomer?.name ?? ''}`"
|
|
size="md"
|
|
@close="editCustomer = null"
|
|
>
|
|
<div class="edit-form">
|
|
<label class="field"><Eyebrow>Name</Eyebrow><input v-model="editForm.name" /></label>
|
|
<label class="field"><Eyebrow>Industry</Eyebrow><input v-model="editForm.industry" placeholder="e.g. Logistics" /></label>
|
|
<div class="row-2">
|
|
<label class="field"><Eyebrow>Brand color</Eyebrow><input v-model="editForm.brandColor" placeholder="#3F6BFF" /></label>
|
|
<label class="field"><Eyebrow>Seats</Eyebrow><input v-model.number="editForm.seats" type="number" min="0" /></label>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<UiButton
|
|
v-if="editCustomer"
|
|
:variant="editCustomer.status === 'suspended' ? 'secondary' : 'danger'"
|
|
@click="toggleSuspend(editCustomer)"
|
|
>{{ editCustomer.status === 'suspended' ? 'Resume customer' : 'Suspend customer' }}</UiButton>
|
|
<div style="flex: 1" />
|
|
<UiButton variant="ghost" @click="editCustomer = null">Cancel</UiButton>
|
|
<UiButton variant="primary" :disabled="savingEdit" @click="saveEdit">{{ savingEdit ? 'Saving…' : 'Save changes' }}</UiButton>
|
|
</template>
|
|
</Modal>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.content { padding: 16px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
|
|
|
|
.filters {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 0 10px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
width: 320px;
|
|
color: var(--text-mute);
|
|
}
|
|
.search input {
|
|
flex: 1;
|
|
border: none;
|
|
background: transparent;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
padding: 9px 0;
|
|
color: var(--text);
|
|
}
|
|
.search input:focus { outline: none; }
|
|
|
|
.seg {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 0 10px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
}
|
|
.seg-label {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-mute);
|
|
}
|
|
.seg select {
|
|
border: none;
|
|
background: transparent;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
padding: 8px 4px;
|
|
cursor: pointer;
|
|
}
|
|
.seg select:focus { outline: none; }
|
|
|
|
.spacer { flex: 1; }
|
|
|
|
.view-toggle {
|
|
display: flex;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 2px;
|
|
background: var(--surface);
|
|
}
|
|
.view-toggle button {
|
|
padding: 6px 12px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: var(--text-mute);
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
.view-toggle button.active { background: var(--text); color: var(--bg); }
|
|
|
|
/* Table */
|
|
.table-wrap { overflow-x: auto; }
|
|
.dtable { width: 100%; border-collapse: collapse; }
|
|
.dtable th {
|
|
text-align: left;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-mute);
|
|
font-weight: 500;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.dtable th.sortable :deep(svg) { opacity: 0.5; margin-left: 4px; vertical-align: middle; }
|
|
.dtable th.num, .dtable td.num { text-align: right; }
|
|
.dtable th.action-col, .dtable td.action-col { width: 170px; text-align: right; }
|
|
.row-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; }
|
|
|
|
.edit-form { display: flex; flex-direction: column; gap: 14px; }
|
|
.edit-form .field { display: flex; flex-direction: column; gap: 6px; }
|
|
.edit-form .row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
.edit-form input {
|
|
padding: 9px 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
.edit-form input:focus { outline: none; border-color: var(--border-hi); }
|
|
|
|
.dtable td {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
vertical-align: middle;
|
|
}
|
|
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
|
|
.dtable tbody tr:hover { background: var(--row-hover); }
|
|
|
|
.cust-cell { display: flex; align-items: center; gap: 12px; }
|
|
.cust-swatch { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; }
|
|
.cust-name { font-size: 13px; font-weight: 500; }
|
|
|
|
.mrr {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.empty {
|
|
text-align: center;
|
|
color: var(--text-mute);
|
|
font-size: 13px;
|
|
padding: 48px 0;
|
|
}
|
|
|
|
/* Cards */
|
|
.cards-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.ccard {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
color: inherit;
|
|
font: inherit;
|
|
padding: 0;
|
|
transition: border-color 120ms;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.ccard:hover { border-color: var(--text); }
|
|
.ccard-stripe { height: 6px; }
|
|
.ccard-body { padding: 16px; }
|
|
.ccard-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
}
|
|
.ccard-id { min-width: 0; }
|
|
.ccard-name {
|
|
font-family: var(--font-display);
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.015em;
|
|
}
|
|
.ccard-meta {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
margin-top: 16px;
|
|
padding-top: 14px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.cm-val {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
margin-top: 4px;
|
|
}
|
|
.cm-val.mono { font-family: var(--font-mono); }
|
|
</style>
|