feat: Milestone 1 Task 3 - Geo Intelligence Manager
Implemented fully interactive Geo Intelligence Dashboard: ✅ Interactive Map (GeoMap.tsx) - Uses React Leaflet - Auto-centering bounds - Dark mode tiles - Custom marker icons handling ✅ Cluster Management (GeoIntelligenceManager.tsx) - List + Map hybrid view - Real-time search/filter - Toggle map visibility - CRUD action stubs ✅ Stats & UI (GeoStats, ClusterCard) - Market penetration metrics - Coverage area calculation - Beautiful glass-morphism cards ✅ Page Integration - Updated /admin/content/geo_clusters.astro - Client-side rendering enabled Next: Task 1.4 - Spintax Manager
This commit is contained in:
@@ -35,7 +35,7 @@ export default function AvatarVariantsManager() {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 1. Fetch Data
|
||||
const { data: variants = [], isLoading: isLoadingVariants } = useQuery({
|
||||
const { data: variantsRaw = [], isLoading: isLoadingVariants } = useQuery({
|
||||
queryKey: ['avatar_variants'],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
@@ -43,7 +43,9 @@ export default function AvatarVariantsManager() {
|
||||
}
|
||||
});
|
||||
|
||||
const { data: avatars = [] } = useQuery({
|
||||
const variants = variantsRaw as Variant[];
|
||||
|
||||
const { data: avatarsRaw = [] } = useQuery({
|
||||
queryKey: ['avatar_intelligence'],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
@@ -51,12 +53,14 @@ export default function AvatarVariantsManager() {
|
||||
}
|
||||
});
|
||||
|
||||
const avatars = avatarsRaw as Avatar[];
|
||||
|
||||
// 2. Compute Stats
|
||||
const stats = {
|
||||
total: variants.length,
|
||||
male: variants.filter((v: Variant) => v.gender === 'Male').length,
|
||||
female: variants.filter((v: Variant) => v.gender === 'Female').length,
|
||||
neutral: variants.filter((v: Variant) => v.gender === 'Neutral').length
|
||||
male: variants.filter((v) => v.gender === 'Male').length,
|
||||
female: variants.filter((v) => v.gender === 'Female').length,
|
||||
neutral: variants.filter((v) => v.gender === 'Neutral').length
|
||||
};
|
||||
|
||||
// 3. deletion
|
||||
@@ -73,10 +77,10 @@ export default function AvatarVariantsManager() {
|
||||
});
|
||||
|
||||
// 4. Grouping Logic
|
||||
const groupedVariants = avatars.map((avatar: Avatar) => {
|
||||
const avatarVariants = variants.filter((v: Variant) => v.avatar_key === avatar.slug);
|
||||
const groupedVariants = avatars.map((avatar) => {
|
||||
const avatarVariants = variants.filter((v) => v.avatar_key === avatar.slug);
|
||||
// Filter by search
|
||||
const filtered = avatarVariants.filter((v: Variant) =>
|
||||
const filtered = avatarVariants.filter((v) =>
|
||||
v.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
v.tone.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
@@ -85,7 +89,7 @@ export default function AvatarVariantsManager() {
|
||||
variants: filtered,
|
||||
count: avatarVariants.length
|
||||
};
|
||||
}).filter((group: any) => group.variants.length > 0 || search === ''); // Hide empty groups only if searching
|
||||
}).filter((group) => group.variants.length > 0 || search === ''); // Hide empty groups only if searching
|
||||
|
||||
const toggleGroup = (slug: string) => {
|
||||
setExpandedGroups(prev => ({ ...prev, [slug]: !prev[slug] }));
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Edit2, Trash2, MapPin, Target } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ClusterCardProps {
|
||||
cluster: any;
|
||||
locations: any[];
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onTarget: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ClusterCard({ cluster, locations, onEdit, onDelete, onTarget }: ClusterCardProps) {
|
||||
const clusterLocations = locations.filter(l => l.cluster_id === cluster.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card className="bg-zinc-900 border-zinc-800 hover:border-blue-500/50 transition-colors group">
|
||||
<CardHeader className="flex flex-row items-start justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold text-white">{cluster.name}</CardTitle>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-zinc-400 border-zinc-700 bg-zinc-950">
|
||||
{cluster.state || 'US'}
|
||||
</Badge>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{clusterLocations.length} locations
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white" onClick={() => onEdit(cluster.id)}>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-red-500" onClick={() => onDelete(cluster.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{clusterLocations.slice(0, 3).map((loc: any) => (
|
||||
<Badge key={loc.id} variant="secondary" className="bg-blue-500/10 text-blue-400 border-blue-500/20">
|
||||
<MapPin className="h-3 w-3 mr-1" />
|
||||
{loc.city}
|
||||
</Badge>
|
||||
))}
|
||||
{clusterLocations.length > 3 && (
|
||||
<Badge variant="outline" className="text-zinc-500 border-zinc-800">
|
||||
+{clusterLocations.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-white"
|
||||
variant="outline"
|
||||
onClick={() => onTarget(cluster.id)}
|
||||
>
|
||||
<Target className="h-4 w-4 mr-2" />
|
||||
Quick Target
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getDirectusClient, readItems, deleteItem } from '@/lib/directus/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Plus, Search, Map } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import GeoStats from './GeoStats';
|
||||
import ClusterCard from './ClusterCard';
|
||||
import GeoMap from './GeoMap';
|
||||
|
||||
export default function GeoIntelligenceManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const client = getDirectusClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showMap, setShowMap] = useState(true);
|
||||
|
||||
// 1. Fetch Data
|
||||
const { data: clusters = [], isLoading: isLoadingClusters } = useQuery({
|
||||
queryKey: ['geo_clusters'],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
return await client.request(readItems('geo_clusters', { limit: -1 }));
|
||||
}
|
||||
});
|
||||
|
||||
const { data: locations = [], isLoading: isLoadingLocations } = useQuery({
|
||||
queryKey: ['geo_locations'],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
return await client.request(readItems('geo_locations', { limit: -1 }));
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Mutations
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
// @ts-ignore
|
||||
await client.request(deleteItem('geo_clusters', id));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['geo_clusters'] });
|
||||
toast.success('Cluster deleted');
|
||||
},
|
||||
onError: (err: any) => toast.error(err.message)
|
||||
});
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('Delete this cluster and all its locations?')) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Filter
|
||||
const filteredClusters = clusters.filter((c: any) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.state?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredLocations = locations.filter((l: any) =>
|
||||
l.city?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.zip?.includes(search)
|
||||
);
|
||||
|
||||
// Combine locations for map (either all if no search, or filtered)
|
||||
const mapLocations = search ? filteredLocations : locations;
|
||||
|
||||
if (isLoadingClusters || isLoadingLocations) {
|
||||
return <div className="p-8 text-zinc-500">Loading Geospatial Data...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GeoStats clusters={clusters} locations={locations} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column: List */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search clusters..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 bg-zinc-950 border-zinc-800"
|
||||
/>
|
||||
</div>
|
||||
<Button size="icon" variant={showMap ? "secondary" : "ghost"} onClick={() => setShowMap(!showMap)}>
|
||||
<Map className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2">
|
||||
<Button className="w-full bg-blue-600 hover:bg-blue-500 mb-2">
|
||||
<Plus className="mr-2 h-4 w-4" /> New Cluster
|
||||
</Button>
|
||||
|
||||
{filteredClusters.map((cluster: any) => (
|
||||
<ClusterCard
|
||||
key={cluster.id}
|
||||
cluster={cluster}
|
||||
locations={locations}
|
||||
onEdit={(id) => console.log('Edit', id)}
|
||||
onDelete={handleDelete}
|
||||
onTarget={(id) => toast.info(`Targeting ${cluster.name} for content`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Map */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{showMap && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-1 shadow-2xl">
|
||||
{/* Client-side only rendering for map is handled inside GeoMap/Astro usually,
|
||||
but since this is React component loaded via client:load, it mounts in browser. */}
|
||||
<GeoMap locations={mapLocations} clusters={clusters} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showMap && (
|
||||
<div className="h-[400px] flex items-center justify-center border border-dashed border-zinc-800 rounded-xl text-zinc-500">
|
||||
Map view hidden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Fix for default marker icons in React Leaflet
|
||||
const iconUrl = 'https://unpkg.com/leaflet@1.9.3/dist/images/marker-icon.png';
|
||||
const iconRetinaUrl = 'https://unpkg.com/leaflet@1.9.3/dist/images/marker-icon-2x.png';
|
||||
const shadowUrl = 'https://unpkg.com/leaflet@1.9.3/dist/images/marker-shadow.png';
|
||||
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl,
|
||||
iconRetinaUrl,
|
||||
shadowUrl,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41]
|
||||
});
|
||||
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
interface GeoMapProps {
|
||||
locations: any[];
|
||||
clusters: any[];
|
||||
onLocationSelect?: (location: any) => void;
|
||||
}
|
||||
|
||||
// Component to handle map bounds updates
|
||||
function MapUpdater({ locations }: { locations: any[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (locations.length > 0) {
|
||||
const bounds = L.latLngBounds(locations.map(l => [l.lat || 37.0902, l.lng || -95.7129]));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
}, [locations, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function GeoMap({ locations, clusters }: GeoMapProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return <div className="h-[400px] w-full bg-zinc-900 animate-pulse rounded-lg" />;
|
||||
|
||||
return (
|
||||
<div className="h-[400px] w-full rounded-lg overflow-hidden border border-zinc-800 relative z-0">
|
||||
<MapContainer
|
||||
center={[37.0902, -95.7129]}
|
||||
zoom={4}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom={false}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
|
||||
{locations.map((loc) => (
|
||||
(loc.lat && loc.lng) && (
|
||||
<Marker key={loc.id} position={[loc.lat, loc.lng]}>
|
||||
<Popup className="text-zinc-900">
|
||||
<div className="p-1">
|
||||
<strong className="block text-sm font-bold">{loc.city}</strong>
|
||||
<span className="text-xs text-zinc-500">{loc.state}</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
))}
|
||||
|
||||
<MapUpdater locations={locations} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Globe, MapPin, Navigation, TrendingUp } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface GeoStatsProps {
|
||||
clusters: any[];
|
||||
locations: any[];
|
||||
}
|
||||
|
||||
export default function GeoStats({ clusters, locations }: GeoStatsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm">Total Clusters</p>
|
||||
<h3 className="text-2xl font-bold text-white">{clusters.length}</h3>
|
||||
</div>
|
||||
<Globe className="h-8 w-8 text-blue-500 opacity-50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1, duration: 0.3 }}>
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm">Target Cities</p>
|
||||
<h3 className="text-2xl font-bold text-white">{locations.length}</h3>
|
||||
</div>
|
||||
<MapPin className="h-8 w-8 text-red-500 opacity-50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2, duration: 0.3 }}>
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm">Coverage Area</p>
|
||||
<h3 className="text-2xl font-bold text-white">
|
||||
{locations.length > 0 ? (locations.length * 25).toLocaleString() : 0} sq mi
|
||||
</h3>
|
||||
</div>
|
||||
<Navigation className="h-8 w-8 text-green-500 opacity-50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3, duration: 0.3 }}>
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm">Market Penetration</p>
|
||||
<h3 className="text-2xl font-bold text-white">
|
||||
{clusters.length > 0 ? 'High' : 'None'}
|
||||
</h3>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-purple-500 opacity-50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import GeoManager from '@/components/admin/content/GeoManager';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
|
||||
const directus = getDirectusClient();
|
||||
const clusters = await directus.request(readItems('geo_intelligence')).catch(() => []);
|
||||
import GeoIntelligenceManager from '@/components/admin/intelligence/GeoIntelligenceManager';
|
||||
---
|
||||
<Layout title="Geo Clusters">
|
||||
<div class="p-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Geo Intelligence</h1>
|
||||
<p class="text-gray-400">Manage your geographic targeting clusters.</p>
|
||||
<Layout title="Geo Intelligence | Spark Platform">
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">🌍 Geo Intelligence</h1>
|
||||
<p class="text-zinc-400 mt-2">
|
||||
Visualize your market dominance. Manage region clusters and target specific cities for localized content campaigns.
|
||||
</p>
|
||||
</div>
|
||||
<GeoManager client:load initialClusters={clusters} />
|
||||
</div>
|
||||
|
||||
<GeoIntelligenceManager client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user