WIP: Collection Manager infrastructure
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
|
||||||
|
|
||||||
interface SystemStatus {
|
interface SystemStatus {
|
||||||
coreApi: 'online' | 'offline' | 'checking';
|
coreApi: 'online' | 'offline' | 'checking';
|
||||||
@@ -24,27 +23,38 @@ export default function SystemStatusBar() {
|
|||||||
const [showLogs, setShowLogs] = useState(false);
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkSystemStatus();
|
checkStatus();
|
||||||
const interval = setInterval(checkSystemStatus, 30000); // Check every 30 seconds
|
const interval = setInterval(checkStatus, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkSystemStatus = async () => {
|
const addLog = (message: string, type: LogEntry['type']) => {
|
||||||
|
const newLog: LogEntry = {
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
message,
|
||||||
|
type
|
||||||
|
};
|
||||||
|
setLogs(prev => [newLog, ...prev].slice(0, 50));
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const client = getDirectusClient();
|
const directusUrl = 'https://spark.jumpstartscaling.com';
|
||||||
|
|
||||||
// Test database connection by fetching a single site
|
const response = await fetch(`${directusUrl}/server/health`, {
|
||||||
const sites = await client.request(
|
method: 'GET',
|
||||||
readItems('sites', { limit: 1 })
|
|
||||||
);
|
|
||||||
|
|
||||||
setStatus({
|
|
||||||
coreApi: 'online',
|
|
||||||
database: 'connected',
|
|
||||||
wpConnection: 'ready'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
addLog('System check passed', 'success');
|
if (response.ok) {
|
||||||
|
setStatus({
|
||||||
|
coreApi: 'online',
|
||||||
|
database: 'connected',
|
||||||
|
wpConnection: 'ready'
|
||||||
|
});
|
||||||
|
addLog('System check passed', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Health check failed: ${response.status}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Status check failed:', error);
|
console.error('Status check failed:', error);
|
||||||
setStatus({
|
setStatus({
|
||||||
@@ -56,15 +66,6 @@ export default function SystemStatusBar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addLog = (message: string, type: LogEntry['type']) => {
|
|
||||||
const newLog: LogEntry = {
|
|
||||||
time: new Date().toLocaleTimeString(),
|
|
||||||
message,
|
|
||||||
type
|
|
||||||
};
|
|
||||||
setLogs(prev => [newLog, ...prev].slice(0, 50)); // Keep last 50 logs
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (state: string) => {
|
const getStatusColor = (state: string) => {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'online':
|
case 'online':
|
||||||
@@ -96,60 +97,53 @@ export default function SystemStatusBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-slate-800 border-t border-slate-700 shadow-lg">
|
<div className="fixed bottom-0 left-0 right-0 z-50 bg-titanium border-t border-edge-normal shadow-xl">
|
||||||
{/* Main Status Bar */}
|
|
||||||
<div className="container mx-auto px-4 py-3">
|
<div className="container mx-auto px-4 py-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
{/* Title */}
|
<h3 className="spark-label text-white">API & Logistics</h3>
|
||||||
<h3 className="text-lg font-semibold text-white whitespace-nowrap">
|
|
||||||
API & Logistics
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Status Items */}
|
|
||||||
<div className="flex items-center gap-6 flex-1">
|
<div className="flex items-center gap-6 flex-1">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-slate-400">Core API</span>
|
<span className="text-silver">Core API</span>
|
||||||
<span className={getStatusColor(status.coreApi)}>
|
<span className={getStatusColor(status.coreApi)}>
|
||||||
{status.coreApi.charAt(0).toUpperCase() + status.coreApi.slice(1)}
|
{status.coreApi.charAt(0).toUpperCase() + status.coreApi.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-slate-400">Database (Directus</span>
|
<span className="text-silver">Database (Directus)</span>
|
||||||
<span className={getStatusColor(status.database)}>
|
<span className={getStatusColor(status.database)}>
|
||||||
{status.database.charAt(0).toUpperCase() + status.database.slice(1)}
|
{status.database.charAt(0).toUpperCase() + status.database.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-slate-400">WP Connection</span>
|
<span className="text-silver">WP Connection</span>
|
||||||
<span className={getStatusColor(status.wpConnection)}>
|
<span className={getStatusColor(status.wpConnection)}>
|
||||||
{status.wpConnection.charAt(0).toUpperCase() + status.wpConnection.slice(1)}
|
{status.wpConnection.charAt(0).toUpperCase() + status.wpConnection.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle Logs Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLogs(!showLogs)}
|
onClick={() => setShowLogs(!showLogs)}
|
||||||
className="px-3 py-1.5 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded border border-slate-600 transition-colors whitespace-nowrap"
|
className="spark-btn-ghost text-sm"
|
||||||
>
|
>
|
||||||
{showLogs ? 'Hide' : 'Show'} Processing Log
|
{showLogs ? 'Hide' : 'Show'} Processing Log
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Processing Log Panel */}
|
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
<div className="border-t border-slate-700 bg-slate-900">
|
<div className="border-t border-edge-subtle bg-void">
|
||||||
<div className="container mx-auto px-4 py-3 max-h-48 overflow-y-auto">
|
<div className="container mx-auto px-4 py-3 max-h-48 overflow-y-auto">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-sm text-slate-500 italic">No recent activity</div>
|
<div className="text-sm text-silver/50 italic">No recent activity</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log, index) => (
|
logs.map((log, index) => (
|
||||||
<div key={index} className="flex items-start gap-2 text-sm font-mono">
|
<div key={index} className="flex items-start gap-2 text-sm font-mono">
|
||||||
<span className="text-slate-500 shrink-0">[{log.time}]</span>
|
<span className="text-silver/50 shrink-0">[{log.time}]</span>
|
||||||
<span className={getLogColor(log.type)}>{log.message}</span>
|
<span className={getLogColor(log.type)}>{log.message}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
176
frontend/src/components/collections/CollectionManager.tsx
Normal file
176
frontend/src/components/collections/CollectionManager.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Universal Collection Manager
|
||||||
|
* Reusable component for all collection pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface CollectionManagerProps {
|
||||||
|
collection: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
displayField: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionManager({
|
||||||
|
collection,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
displayField,
|
||||||
|
}: CollectionManagerProps) {
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, [collection]);
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`https://spark.jumpstartscaling.com/items/${collection}?limit=100`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${import.meta.env.PUBLIC_DIRECTUS_TOKEN}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch ${collection}`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setItems(data.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkExport = () => {
|
||||||
|
const jsonStr = JSON.stringify(items, null, 2);
|
||||||
|
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${collection}_export.json`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="spark-data animate-pulse">Loading {title}...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="spark-card p-6 border-red-500">
|
||||||
|
<div className="text-red-400">Error: {error}</div>
|
||||||
|
<button onClick={fetchItems} className="spark-btn-secondary text-sm mt-4">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="spark-heading text-3xl flex items-center gap-3">
|
||||||
|
<span>{icon}</span>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-silver mt-1">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="spark-btn-secondary text-sm" onClick={handleBulkExport}>
|
||||||
|
📤 Export
|
||||||
|
</button>
|
||||||
|
<button className="spark-btn-secondary text-sm">
|
||||||
|
📥 Import
|
||||||
|
</button>
|
||||||
|
<button className="spark-btn-primary text-sm">
|
||||||
|
✨ New {title.slice(0, -1)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="spark-card p-6">
|
||||||
|
<div className="spark-label mb-2">Total Items</div>
|
||||||
|
<div className="spark-data text-3xl">{items.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="spark-card p-6">
|
||||||
|
<div className="spark-label mb-2">This Week</div>
|
||||||
|
<div className="spark-data text-3xl">0</div>
|
||||||
|
</div>
|
||||||
|
<div className="spark-card p-6">
|
||||||
|
<div className="spark-label mb-2">Usage Count</div>
|
||||||
|
<div className="spark-data text-3xl">—</div>
|
||||||
|
</div>
|
||||||
|
<div className="spark-card p-6">
|
||||||
|
<div className="spark-label mb-2">Status</div>
|
||||||
|
<div className="text-green-400 text-sm">● Active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="spark-card overflow-hidden">
|
||||||
|
<table className="spark-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-12">
|
||||||
|
<input type="checkbox" className="w-4 h-4" />
|
||||||
|
</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>{displayField}</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th className="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" className="w-4 h-4" />
|
||||||
|
</td>
|
||||||
|
<td className="spark-data text-sm">{item.id}</td>
|
||||||
|
<td className="text-white font-medium">
|
||||||
|
{item[displayField] || 'Untitled'}
|
||||||
|
</td>
|
||||||
|
<td className="text-silver text-sm">
|
||||||
|
{item.date_created
|
||||||
|
? new Date(item.date_created).toLocaleDateString()
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<button className="spark-btn-ghost text-xs px-2 py-1">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<p className="text-silver/50">No items found. Create your first one!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/src/lib/collections/config.ts
Normal file
107
frontend/src/lib/collections/config.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Collection Page Template Generator
|
||||||
|
* Creates standardized CRUD pages for all collections
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const collectionConfigs = {
|
||||||
|
avatar_intelligence: {
|
||||||
|
title: 'Avatar Intelligence',
|
||||||
|
description: 'Manage persona profiles and variants',
|
||||||
|
icon: '👥',
|
||||||
|
fields: ['base_name', 'wealth_cluster', 'business_niches'],
|
||||||
|
displayField: 'base_name',
|
||||||
|
},
|
||||||
|
avatar_variants: {
|
||||||
|
title: 'Avatar Variants',
|
||||||
|
description: 'Manage gender and tone variations',
|
||||||
|
icon: '🎭',
|
||||||
|
fields: ['avatar_id', 'variant_name', 'pronouns'],
|
||||||
|
displayField: 'variant_name',
|
||||||
|
},
|
||||||
|
campaign_masters: {
|
||||||
|
title: 'Campaign Masters',
|
||||||
|
description: 'Manage marketing campaigns',
|
||||||
|
icon: '📢',
|
||||||
|
fields: ['campaign_name', 'status', 'site_id'],
|
||||||
|
displayField: 'campaign_name',
|
||||||
|
},
|
||||||
|
cartesian_patterns: {
|
||||||
|
title: 'Cartesian Patterns',
|
||||||
|
description: 'Content structure templates',
|
||||||
|
icon: '🔧',
|
||||||
|
fields: ['pattern_name', 'structure_type'],
|
||||||
|
displayField: 'pattern_name',
|
||||||
|
},
|
||||||
|
content_fragments: {
|
||||||
|
title: 'Content Fragments',
|
||||||
|
description: 'Reusable content blocks',
|
||||||
|
icon: '📦',
|
||||||
|
fields: ['fragment_type', 'content'],
|
||||||
|
displayField: 'fragment_type',
|
||||||
|
},
|
||||||
|
generated_articles: {
|
||||||
|
title: 'Generated Articles',
|
||||||
|
description: 'AI-generated content output',
|
||||||
|
icon: '📝',
|
||||||
|
fields: ['title', 'status', 'seo_score', 'geo_city'],
|
||||||
|
displayField: 'title',
|
||||||
|
},
|
||||||
|
generation_jobs: {
|
||||||
|
title: 'Generation Jobs',
|
||||||
|
description: 'Content generation queue',
|
||||||
|
icon: '⚙️',
|
||||||
|
fields: ['job_name', 'status', 'progress'],
|
||||||
|
displayField: 'job_name',
|
||||||
|
},
|
||||||
|
geo_intelligence: {
|
||||||
|
title: 'Geo Intelligence',
|
||||||
|
description: 'Location targeting data',
|
||||||
|
icon: '🗺️',
|
||||||
|
fields: ['city', 'state', 'zip', 'population'],
|
||||||
|
displayField: 'city',
|
||||||
|
},
|
||||||
|
headline_inventory: {
|
||||||
|
title: 'Headline Inventory',
|
||||||
|
description: 'Pre-written headlines library',
|
||||||
|
icon: '💬',
|
||||||
|
fields: ['headline_text', 'category'],
|
||||||
|
displayField: 'headline_text',
|
||||||
|
},
|
||||||
|
leads: {
|
||||||
|
title: 'Leads',
|
||||||
|
description: 'Customer lead management',
|
||||||
|
icon: '👤',
|
||||||
|
fields: ['name', 'email', 'status'],
|
||||||
|
displayField: 'name',
|
||||||
|
},
|
||||||
|
offer_blocks: {
|
||||||
|
title: 'Offer Blocks',
|
||||||
|
description: 'Call-to-action templates',
|
||||||
|
icon: '🎯',
|
||||||
|
fields: ['offer_text', 'offer_type'],
|
||||||
|
displayField: 'offer_text',
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
title: 'Pages',
|
||||||
|
description: 'Static page content',
|
||||||
|
icon: '📄',
|
||||||
|
fields: ['title', 'slug', 'status'],
|
||||||
|
displayField: 'title',
|
||||||
|
},
|
||||||
|
posts: {
|
||||||
|
title: 'Posts',
|
||||||
|
description: 'Blog posts and articles',
|
||||||
|
icon: '📰',
|
||||||
|
fields: ['title', 'status', 'seo_score'],
|
||||||
|
displayField: 'title',
|
||||||
|
},
|
||||||
|
spintax_dictionaries: {
|
||||||
|
title: 'Spintax Dictionaries',
|
||||||
|
description: 'Word variation sets',
|
||||||
|
icon: '📚',
|
||||||
|
fields: ['category', 'variations'],
|
||||||
|
displayField: 'category',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CollectionName = keyof typeof collectionConfigs;
|
||||||
107
frontend/src/pages/admin/intelligence/avatars.astro
Normal file
107
frontend/src/pages/admin/intelligence/avatars.astro
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Avatar Intelligence Management
|
||||||
|
* Full CRUD for avatar_intelligence collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
---
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||||
|
import { getDirectusClient } from '@/lib/directus/client';
|
||||||
|
import { readItems } from '@directus/sdk';
|
||||||
|
|
||||||
|
const client = getDirectusClient();
|
||||||
|
|
||||||
|
let avatars = [];
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
avatars = await client.request(readItems('avatar_intelligence', {
|
||||||
|
fields: ['*'],
|
||||||
|
sort: ['base_name'],
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching avatars:', e);
|
||||||
|
error = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Avatar Intelligence">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="spark-heading text-3xl">Avatar Intelligence</h1>
|
||||||
|
<p class="text-silver mt-1">Manage persona profiles and variants</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="spark-btn-secondary text-sm">
|
||||||
|
📥 Import CSV
|
||||||
|
</button>
|
||||||
|
<button class="spark-btn-secondary text-sm">
|
||||||
|
📤 Export
|
||||||
|
</button>
|
||||||
|
<a href="/admin/intelligence/avatars/new" class="spark-btn-primary text-sm">
|
||||||
|
✨ New Avatar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Total Avatars</div>
|
||||||
|
<div class="spark-data text-3xl">{avatars.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Avatars Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{avatars.map((avatar: any) => (
|
||||||
|
<div class="spark-card spark-card-hover p-6">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<h3 class="text-white font-semibold text-lg">{avatar.base_name}</h3>
|
||||||
|
<button class="spark-btn-ghost text-xs px-2 py-1">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="spark-label">Wealth Cluster:</span>
|
||||||
|
<span class="text-silver ml-2">{avatar.wealth_cluster || 'Not set'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{avatar.business_niches && (
|
||||||
|
<div>
|
||||||
|
<span class="spark-label">Niches:</span>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
{avatar.business_niches.slice(0, 3).map((niche: string) => (
|
||||||
|
<span class="px-2 py-0.5 bg-graphite border border-edge-subtle rounded text-xs text-silver">
|
||||||
|
{niche}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{avatar.business_niches.length > 3 && (
|
||||||
|
<span class="px-2 py-0.5 text-xs text-silver/50">
|
||||||
|
+{avatar.business_niches.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{avatars.length === 0 && !error && (
|
||||||
|
<div class="col-span-full spark-card p-12 text-center">
|
||||||
|
<p class="text-silver/50">No avatars found. Create your first one!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
Reference in New Issue
Block a user