import { Component, Input } from '@angular/core'; import { VspBlock } from './blocks.component'; import { VspBadge, type BadgeTone } from './badge.component'; import { VspAvatar } from './media.component'; import { VspTabs } from './nav.component'; import { VspTooltip } from './tooltip.component'; import { VspSelect } from './select.component'; import { VspDropdownMenu, type MenuItem } from './anchored.component'; import { VspIcon } from './icon.component'; import { VspBlockSkeleton, VspBlockEmpty } from './block-state.component'; import { toast } from './toast.component'; export type OrderColumn = 'customer' | 'item' | 'status' | 'amount'; const ALL_ORDER_COLUMNS: OrderColumn[] = ['customer', 'item', 'status', 'amount']; /* ===================== Orders ===================== */ export type OrderState = 'fulfilled' | 'processing' | 'pending' | 'refunded'; export interface Order { id: string; company: string; hue: number; item: string; state: OrderState; amount: number; } const ORDER_TONE: Record = { fulfilled: 'pos', processing: 'info', pending: 'warn', refunded: 'neg', }; const DEFAULT_ORDERS: Order[] = [ { id: 'ORD-90210', company: 'Northwind', hue: 220, item: 'Annual license', state: 'fulfilled', amount: 2400, }, { id: 'ORD-90211', company: 'Halcyon', hue: 150, item: 'Seat add-on', state: 'processing', amount: 320, }, { id: 'ORD-90212', company: 'Vertex', hue: 280, item: 'Pro upgrade', state: 'pending', amount: 980, }, { id: 'ORD-90213', company: 'Cobalt', hue: 12, item: 'API overage', state: 'refunded', amount: 140, }, { id: 'ORD-90214', company: 'Beacon', hue: 95, item: 'Support plan', state: 'fulfilled', amount: 3120, }, { id: 'ORD-90215', company: 'Lumen', hue: 320, item: 'Onboarding', state: 'processing', amount: 1500, }, ]; const ROW_MENU: MenuItem[] = [ { label: 'View order', icon: 'eye' }, { label: 'Refund', icon: 'refresh' }, { sep: true }, { label: 'Cancel', icon: 'x', danger: true }, ]; /** Operational table: tab filters, bulk selection, status badges, row menu. */ @Component({ selector: 'vsp-orders-block', imports: [ VspBlock, VspBadge, VspAvatar, VspTabs, VspDropdownMenu, VspIcon, VspBlockSkeleton, VspBlockEmpty, ], template: `
@if (sel.size > 0) { {{ sel.size }} selected } @else { }
@if (loading) { } @else if (rows.length === 0) {
@if (!emptyWrap.children.length) { } } @else {
@if (col('customer')) { } @if (col('item')) { } @if (col('status')) { } @if (col('amount')) { } @for (o of rows; track o.id) { @if (col('customer')) { } @if (col('item')) { } @if (col('status')) { } @if (col('amount')) { } }
OrderCustomerItemStatusAmount
{{ o.id }}
{{ o.company }}
{{ o.item }} {{ o.state }} \${{ o.amount.toLocaleString() }}
}
`, }) export class VspOrdersBlock { @Input() orders: Order[] = DEFAULT_ORDERS; @Input() columns: OrderColumn[] = ALL_ORDER_COLUMNS; @Input() loading = false; tab = 'all'; sel = new Set(); rowMenu = ROW_MENU; col(c: OrderColumn): boolean { return this.columns.includes(c); } get tabs() { return [ { value: 'all', label: 'All', count: this.orders.length }, { value: 'processing', label: 'Processing' }, { value: 'pending', label: 'Pending' }, { value: 'refunded', label: 'Refunded' }, ]; } get rows(): Order[] { return this.orders.filter((o) => this.tab === 'all' || o.state === this.tab); } get allSel(): boolean { return this.rows.length > 0 && this.rows.every((r) => this.sel.has(r.id)); } orderTone(s: OrderState): BadgeTone { return ORDER_TONE[s]; } toggleAll(): void { const n = new Set(this.sel); if (this.allSel) this.rows.forEach((r) => n.delete(r.id)); else this.rows.forEach((r) => n.add(r.id)); this.sel = n; } toggle(id: string): void { const n = new Set(this.sel); if (n.has(id)) n.delete(id); else n.add(id); this.sel = n; } } /* ===================== Team & roles ===================== */ export interface TeamMember { id: number; name: string; email: string; hue: number; role: string; } const DEFAULT_MEMBERS: TeamMember[] = [ { id: 0, name: 'Avery Quinn', email: 'avery@vespera.dev', hue: 250, role: 'Owner' }, { id: 1, name: 'Maya Okafor', email: 'maya@northwind.com', hue: 220, role: 'Admin' }, { id: 2, name: 'Leo Vega', email: 'leo@halcyon.com', hue: 150, role: 'Editor' }, { id: 3, name: 'Noor Haddad', email: 'noor@beacon.com', hue: 40, role: 'Viewer' }, ]; const MEMBER_MENU: MenuItem[] = [ { label: 'Resend invite', icon: 'mail' }, { label: 'Transfer ownership', icon: 'shield' }, { sep: true }, { label: 'Remove', icon: 'x', danger: true }, ]; /** Member list with inline role selects and a per-row action menu. */ @Component({ selector: 'vsp-team-roles-block', imports: [ VspBlock, VspBadge, VspAvatar, VspSelect, VspDropdownMenu, VspIcon, VspBlockSkeleton, VspBlockEmpty, ], template: `
Members {{ members.length }}
@if (loading) { } @else if (members.length === 0) {
@if (!emptyWrap.children.length) { } } @else {
@for (m of members; track m.id) {
{{ m.name }}
{{ m.email }}
@if (m.role === 'Owner') { Owner } @else {
}
}
}
`, }) export class VspTeamRolesBlock { @Input() members: TeamMember[] = DEFAULT_MEMBERS.map((m) => ({ ...m })); @Input() loading = false; memberMenu = MEMBER_MENU; roleOptions = ['Admin', 'Editor', 'Viewer']; setRole(id: number, role: string | number | null): void { this.members = this.members.map((y) => (y.id === id ? { ...y, role: String(role) } : y)); } } /* ===================== API keys ===================== */ export interface ApiKey { id: number; name: string; token: string; /** Full secret shown when revealed. */ secret: string; created: string; last: string; } const DEFAULT_KEYS: ApiKey[] = [ { id: 1, name: 'Production', token: 'vsp_live_8f2a…d91c', secret: 'vsp_live_8f2a39c4e7b1d91c', created: 'Jan 14, 2026', last: '2m ago', }, { id: 2, name: 'Staging', token: 'vsp_test_4b7e…02fa', secret: 'vsp_test_4b7e1d9a55c302fa', created: 'Mar 02, 2026', last: '3d ago', }, { id: 3, name: 'CI / CD', token: 'vsp_live_19cc…7a4b', secret: 'vsp_live_19cc8e2f0b6d7a4b', created: 'Apr 21, 2026', last: '12h ago', }, ]; /** Reveal, copy, and revoke API credentials; secrets stay masked by default. */ @Component({ selector: 'vsp-api-keys-block', imports: [VspBlock, VspTooltip, VspIcon, VspBlockSkeleton, VspBlockEmpty], template: `
Secret keys
@if (loading) { } @else if (keys.length === 0) {
@if (!emptyWrap.children.length) { } } @else {
@for (k of keys; track k.id) {
{{ k.name }}
{{ k.created }}
{{ revealed.has(k.id) ? k.secret : k.token }} {{ k.last }}
}
}
`, }) export class VspApiKeysBlock { @Input() keys: ApiKey[] = DEFAULT_KEYS.map((k) => ({ ...k })); @Input() loading = false; revealed = new Set(); toggleReveal(id: number): void { const n = new Set(this.revealed); if (n.has(id)) n.delete(id); else n.add(id); this.revealed = n; } created(): void { toast({ tone: 'pos', title: 'API key created' }); } copied(): void { toast({ title: 'Copied to clipboard' }); } revoke(id: number): void { this.keys = this.keys.filter((y) => y.id !== id); toast({ tone: 'neg', title: 'Key revoked' }); } }