All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Final re-skin batch: admin ops (Console/FileManager[VueFinder preserved]/WipeCalendar/WipeHistory/Changelog/Migration), platform-admin (Dashboard/Licenses/Servers/Subscriptions/Users), public product pages (ServerInfo/StatusPage/StoreView) + PublicLayout, WarpEditor, ErrorBoundary. All logic/store/router/WebSocket/handlers preserved. Marketing views (Landing/Pricing/FAQ/HowItWorks/Roadmap/EarlyAccess + MarketingLayout) intentionally deferred to the dedicated marketing-site redesign. Build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
209 lines
6.8 KiB
Vue
209 lines
6.8 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useApi } from '@/composables/useApi'
|
|
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
|
import Panel from '@/components/ds/data/Panel.vue'
|
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
|
import Badge from '@/components/ds/core/Badge.vue'
|
|
import Icon from '@/components/ds/core/Icon.vue'
|
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
|
|
|
const api = useApi()
|
|
|
|
interface Subscription {
|
|
owner_email: string
|
|
module_name: string
|
|
license_id: string
|
|
}
|
|
|
|
interface SubscriptionResponse {
|
|
subscriptions: Subscription[]
|
|
}
|
|
|
|
const subscriptions = ref<Subscription[]>([])
|
|
const isLoading = ref(false)
|
|
|
|
const MODULE_PRICE = 9.99
|
|
|
|
const totalSubscribers = computed(() => {
|
|
const emails = new Set(subscriptions.value.map(s => s.owner_email))
|
|
return emails.size
|
|
})
|
|
|
|
const totalMrr = computed(() => {
|
|
return subscriptions.value.length * MODULE_PRICE
|
|
})
|
|
|
|
const moduleBreakdown = computed(() => {
|
|
const counts: Record<string, number> = {}
|
|
for (const sub of subscriptions.value) {
|
|
counts[sub.module_name] = (counts[sub.module_name] || 0) + 1
|
|
}
|
|
return Object.entries(counts)
|
|
.map(([name, count]) => ({ name, count }))
|
|
.sort((a, b) => b.count - a.count)
|
|
})
|
|
|
|
async function fetchSubscriptions() {
|
|
isLoading.value = true
|
|
try {
|
|
const data = await api.get<SubscriptionResponse>('/admin/subscriptions')
|
|
subscriptions.value = data.subscriptions
|
|
} catch {
|
|
// API not wired yet
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchSubscriptions()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="pasub">
|
|
<!-- Page head -->
|
|
<div class="pasub__head">
|
|
<div class="pasub__head-id">
|
|
<div class="pasub__chip">
|
|
<Icon name="credit-card" :size="20" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<div class="t-eyebrow">Platform admin</div>
|
|
<h1 class="pasub__title">Subscriptions</h1>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPI row -->
|
|
<div class="pasub__kpis">
|
|
<StatCard
|
|
icon="users"
|
|
label="Total subscribers"
|
|
:value="isLoading ? '—' : safeLocaleString(totalSubscribers)"
|
|
/>
|
|
<StatCard
|
|
icon="dollar-sign"
|
|
label="Total MRR"
|
|
:value="isLoading ? '—' : safeCurrency(totalMrr, '$')"
|
|
/>
|
|
<StatCard
|
|
v-for="mod in moduleBreakdown"
|
|
:key="mod.name"
|
|
icon="package"
|
|
:label="mod.name"
|
|
:value="String(mod.count)"
|
|
note="subscribers"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Module breakdown summary -->
|
|
<Panel v-if="moduleBreakdown.length > 0" title="Module breakdown" subtitle="Subscribers per module">
|
|
<div class="pasub__mods">
|
|
<div
|
|
v-for="mod in moduleBreakdown"
|
|
:key="mod.name"
|
|
class="pasub__mod-row"
|
|
>
|
|
<div class="pasub__mod-icon">
|
|
<Icon name="package" :size="14" :stroke-width="2" />
|
|
</div>
|
|
<span class="pasub__mod-name">{{ mod.name }}</span>
|
|
<Badge tone="neutral" :mono="true">{{ mod.count }}</Badge>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Table -->
|
|
<Panel :flush-body="true" title="All subscriptions" :subtitle="subscriptions.length + ' total'">
|
|
<table class="pasub__table">
|
|
<thead>
|
|
<tr class="pasub__thead-row">
|
|
<th class="pasub__th">Owner email</th>
|
|
<th class="pasub__th">Module name</th>
|
|
<th class="pasub__th">License ID</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="isLoading">
|
|
<td colspan="3" class="pasub__td-empty">Loading subscriptions...</td>
|
|
</tr>
|
|
<tr v-else-if="subscriptions.length === 0">
|
|
<td colspan="3" class="pasub__td-es">
|
|
<EmptyState
|
|
icon="credit-card"
|
|
title="No subscriptions found"
|
|
description="Module subscriptions will appear here once customers subscribe."
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr
|
|
v-for="(sub, idx) in subscriptions"
|
|
:key="`${sub.license_id}-${sub.module_name}-${idx}`"
|
|
class="pasub__tr"
|
|
>
|
|
<td class="pasub__td">{{ sub.owner_email }}</td>
|
|
<td class="pasub__td pasub__td--secondary">{{ sub.module_name }}</td>
|
|
<td class="pasub__td pasub__td--mono">{{ sub.license_id }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Panel>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.pasub { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
|
|
|
/* Page head */
|
|
.pasub__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
|
.pasub__head-id { display: flex; align-items: center; gap: 12px; }
|
|
.pasub__chip {
|
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--accent); background: var(--accent-soft);
|
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
|
}
|
|
.pasub__title {
|
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
|
color: var(--text-primary); margin-top: 3px;
|
|
}
|
|
|
|
/* KPI row */
|
|
.pasub__kpis { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 13px; }
|
|
|
|
/* Module breakdown */
|
|
.pasub__mods { display: flex; flex-direction: column; gap: 2px; }
|
|
.pasub__mod-row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 9px 6px; border-radius: var(--radius-md);
|
|
transition: var(--transition-colors);
|
|
}
|
|
.pasub__mod-row:hover { background: var(--surface-hover); }
|
|
.pasub__mod-icon {
|
|
width: 26px; height: 26px; flex: none; border-radius: var(--radius-sm);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--accent-text); background: var(--accent-soft);
|
|
}
|
|
.pasub__mod-name { flex: 1; font-size: var(--text-sm); color: var(--text-primary); }
|
|
|
|
/* Table */
|
|
.pasub__table { width: 100%; border-collapse: collapse; }
|
|
.pasub__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
|
.pasub__th {
|
|
padding: 10px 16px; text-align: left;
|
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
|
white-space: nowrap;
|
|
}
|
|
.pasub__tr { border-bottom: 1px solid var(--border-subtle); transition: var(--transition-colors); }
|
|
.pasub__tr:last-child { border-bottom: 0; }
|
|
.pasub__tr:hover { background: var(--surface-hover); }
|
|
.pasub__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
|
.pasub__td--secondary { color: var(--text-secondary); }
|
|
.pasub__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); color: var(--text-secondary); }
|
|
.pasub__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
|
.pasub__td-es { padding: 0; }
|
|
</style>
|