Implement User CRM: Enhanced Cards, Filters (nuqs), and Details Sheet

This commit is contained in:
CycroftX
2026-02-04 23:09:11 +05:30
parent 06dec50c9e
commit c07ebd4ba8
8 changed files with 771 additions and 95 deletions

View File

@@ -1,13 +1,12 @@
import { Search, MoreHorizontal, UserX, KeyRound, Eye } from "lucide-react";
import { useState, useMemo } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
Search,
Filter,
ArrowUpDown,
Check
} from "lucide-react";
import { useQueryState, parseAsString, parseAsInteger } from 'nuqs';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -15,106 +14,181 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuCheckboxItem
} from "@/components/ui/dropdown-menu";
import { mockEndUsers } from "@/features/users/data/mockRbacData";
import { toast } from "sonner";
import { formatCurrency } from "@/data/mockData";
import { mockEndUsers, EndUser } from "@/features/users/data/mockRbacData";
import { UserCard } from "./UserCard";
import { UserDetailSheet } from "./UserDetailSheet";
export function UserBaseTab() {
const handleBanUser = (name: string) => {
toast.success(`User ${name} has been banned.`);
// --- State (URL Params) ---
const [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault(''));
const [minSpent, setMinSpent] = useQueryState('minSpent', parseAsInteger);
const [statusFilter, setStatusFilter] = useQueryState('status', parseAsString.withDefault('all'));
// --- Local State ---
const [selectedUser, setSelectedUser] = useState<EndUser | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
// --- Filtering Logic ---
const filteredUsers = useMemo(() => {
return mockEndUsers.filter(user => {
// Search
const searchLower = searchQuery.toLowerCase();
const matchesSearch =
user.name.toLowerCase().includes(searchLower) ||
user.email.toLowerCase().includes(searchLower) ||
user.phone.includes(searchLower);
if (!matchesSearch) return false;
// Min Spent
if (minSpent !== null && user.totalSpent < minSpent) return false;
// Status
if (statusFilter !== 'all' && user.status !== statusFilter) return false;
return true;
});
}, [searchQuery, minSpent, statusFilter]);
// --- Handlers ---
const handleCardClick = (user: EndUser) => {
setSelectedUser(user);
setIsSheetOpen(true);
};
const handleImpersonate = (name: string) => {
toast.info(`Impersonating ${name}... (Dev Mode Only)`);
// In a real app this would store a token and redirect
const clearFilters = () => {
setSearchQuery(null);
setMinSpent(null);
setStatusFilter(null);
};
const activeFiltersCount = [
searchQuery ? 1 : 0,
minSpent !== null ? 1 : 0,
statusFilter !== 'all' ? 1 : 0
].reduce((a, b) => a + b, 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-foreground">User Base</h3>
<Badge variant="outline" className="text-xs border-green-200 text-green-700 bg-green-50">B2C</Badge>
{/* --- Toolbar --- */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-background/50 p-4 rounded-xl border border-border/50 shadow-sm backdrop-blur-sm">
<div className="flex items-center gap-3 w-full sm:w-auto">
<h3 className="text-lg font-bold text-foreground whitespace-nowrap">User Base</h3>
<Badge variant="outline" className="text-xs border-green-200 text-green-700 bg-green-50">
{filteredUsers.length} Users
</Badge>
</div>
<div className="flex gap-2">
<div className="relative w-72">
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
{/* Search */}
<div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search emails, phone, IDs..." className="pl-9 h-9 bg-secondary/50 border-transparent focus:border-primary" />
<Input
placeholder="Search name, email..."
className="pl-9 h-9 bg-white/50 border-border/50 focus:bg-white transition-all"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value || null)}
/>
</div>
<Button variant="outline" size="sm" className="h-9">Export List</Button>
{/* Filter Builder */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-2 bg-white/50">
<Filter className="h-4 w-4" />
Filters
{activeFiltersCount > 0 && (
<Badge variant="secondary" className="ml-1 h-5 w-5 p-0 flex items-center justify-center bg-primary text-primary-foreground rounded-full text-[10px]">
{activeFiltersCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter Users</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="p-2">
<p className="text-xs font-medium text-muted-foreground mb-2">Status</p>
<div className="flex flex-wrap gap-1">
{['all', 'Active', 'Banned', 'Suspended'].map(status => (
<Badge
key={status}
variant={statusFilter === status ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => setStatusFilter(status === 'all' ? null : status)}
>
{status}
</Badge>
))}
</div>
</div>
<DropdownMenuSeparator />
<div className="p-2">
<p className="text-xs font-medium text-muted-foreground mb-2">Min. Spent</p>
<Input
type="number"
placeholder="e.g. 1000"
className="h-8 text-xs"
value={minSpent || ''}
onChange={e => setMinSpent(e.target.value ? parseInt(e.target.value) : null)}
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
className="justify-center text-error font-medium cursor-pointer"
onClick={clearFilters}
>
Clear All Filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Sort (Placeholder for now) */}
<Button variant="ghost" size="sm" className="h-9 w-9 p-0">
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
<div className="rounded-xl border border-border overflow-hidden bg-card">
<Table>
<TableHeader>
<TableRow className="bg-secondary/30 hover:bg-secondary/30">
<TableHead>User Details</TableHead>
<TableHead>Contact</TableHead>
<TableHead className="text-center">Bookings</TableHead>
<TableHead className="text-right">Total Spent</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockEndUsers.map((user) => (
<TableRow key={user.id} className="hover:bg-secondary/10">
<TableCell>
<div>
<p className="font-medium text-foreground">{user.name}</p>
<p className="text-xs text-muted-foreground font-mono">ID: {user.id}</p>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
<p>{user.email}</p>
<p className="text-muted-foreground">{user.phone}</p>
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{user.bookingsCount}</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(user.totalSpent)}
</TableCell>
<TableCell>
{user.status === 'Active' ? (
<Badge variant="outline" className="border-success/30 text-success bg-success/5">Active</Badge>
) : (
<Badge variant="destructive">Banned</Badge>
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleImpersonate(user.name)}>
<Eye className="mr-2 h-4 w-4" /> Impersonate
</DropdownMenuItem>
<DropdownMenuItem>
<KeyRound className="mr-2 h-4 w-4" /> Reset 2FA
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleBanUser(user.name)} className="text-error">
<UserX className="mr-2 h-4 w-4" /> Ban User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* --- Grid View --- */}
{filteredUsers.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-in fade-in-50 duration-500">
{filteredUsers.map(user => (
<UserCard
key={user.id}
user={user}
onClick={handleCardClick}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 text-center border-2 border-dashed border-border/50 rounded-xl bg-secondary/10">
<Search className="h-10 w-10 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-semibold">No users found</h3>
<p className="text-muted-foreground max-w-sm mt-1">
Try adjusting your filters or search query to find who you're looking for.
</p>
<Button variant="link" onClick={clearFilters} className="mt-4">
Clear all filters
</Button>
</div>
)}
{/* --- Detail Sheet --- */}
<UserDetailSheet
user={selectedUser}
open={isSheetOpen}
onOpenChange={setIsSheetOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import {
MoreHorizontal,
Mail,
Ban,
Tag,
UserCheck
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { EndUser } from "@/features/users/data/mockRbacData";
import { formatCurrency, formatRelativeTime } from "@/data/mockData";
import { toast } from "sonner";
interface UserCardProps {
user: EndUser;
onClick: (user: EndUser) => void;
}
export function UserCard({ user, onClick }: UserCardProps) {
const handleAction = (e: React.MouseEvent, action: string) => {
e.stopPropagation();
toast.info(`${action} action triggered for ${user.name}`);
};
const getTierColor = (tier: string) => {
switch (tier) {
case 'Gold': return 'bg-yellow-500/10 text-yellow-600 border-yellow-200';
case 'Silver': return 'bg-slate-300/20 text-slate-600 border-slate-300';
case 'Bronze': return 'bg-orange-700/10 text-orange-700 border-orange-200';
default: return 'bg-secondary text-muted-foreground';
}
};
return (
<div
onClick={() => onClick(user)}
className="group relative flex flex-col gap-4 rounded-xl border border-white/40 bg-white/20 p-5 shadow-neu-sm hover:shadow-neu transition-all duration-300 cursor-pointer backdrop-blur-md overflow-hidden"
>
{/* Hover Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-white/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
<div className="relative flex justify-between items-start">
<div className="flex gap-3">
<Avatar className="h-12 w-12 border-2 border-white shadow-sm">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="bg-primary/10 text-primary font-bold">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h4 className="font-bold text-foreground leading-tight group-hover:text-primary transition-colors">{user.name}</h4>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className={`text-[10px] h-5 px-1.5 ${getTierColor(user.tier)}`}>
{user.tier}
</Badge>
<span className="text-xs text-muted-foreground">{user.status}</span>
</div>
</div>
</div>
<div className="z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-white/40"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={(e) => handleAction(e, "Message")}>
<Mail className="mr-2 h-4 w-4" /> Message User
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => handleAction(e, "Add Tag")}>
<Tag className="mr-2 h-4 w-4" /> Add CRM Tag
</DropdownMenuItem>
<DropdownMenuSeparator />
{user.status === 'Banned' || user.status === 'Suspended' ? (
<DropdownMenuItem onClick={(e) => handleAction(e, "Activate")} className="text-success">
<UserCheck className="mr-2 h-4 w-4" /> Activate User
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={(e) => handleAction(e, "Blacklist")} className="text-error">
<Ban className="mr-2 h-4 w-4" /> Blacklist User
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="relative grid grid-cols-2 gap-2 py-2 border-t border-white/30 border-b">
<div className="text-center p-2 rounded-lg bg-white/30">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">Spent</p>
<p className="text-sm font-bold text-foreground">{formatCurrency(user.totalSpent)}</p>
</div>
<div className="text-center p-2 rounded-lg bg-white/30">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">Bookings</p>
<p className="text-sm font-bold text-foreground">{user.bookingsCount}</p>
</div>
</div>
<div className="relative flex flex-wrap gap-1.5 min-h-[1.5rem]">
{user.tags.slice(0, 3).map(tag => (
<span key={tag} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary/50 text-secondary-foreground border border-white/20">
{tag}
</span>
))}
{user.tags.length > 3 && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary/30 text-muted-foreground">
+{user.tags.length - 3}
</span>
)}
</div>
<div className="relative flex items-center justify-between text-xs text-muted-foreground mt-auto pt-1">
<span>Joined {new Date(user.joinedAt).getFullYear()}</span>
<span>Active {user.lastLogin}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Mail,
Phone,
Calendar,
MapPin,
CreditCard,
Ticket,
LogIn,
MessageSquare,
Save
} from "lucide-react";
import { EndUser, Interaction } from "@/features/users/data/mockRbacData";
import { formatCurrency } from "@/data/mockData";
import { toast } from "sonner";
interface UserDetailSheetProps {
user: EndUser | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UserDetailSheet({ user, open, onOpenChange }: UserDetailSheetProps) {
const [notes, setNotes] = useState(user?.notes || "");
if (!user) return null;
const handleSaveNotes = () => {
toast.success("Notes saved successfully");
};
const getInteractionIcon = (type: Interaction['type']) => {
switch (type) {
case 'booking': return <Ticket className="h-4 w-4 text-blue-500" />;
case 'login': return <LogIn className="h-4 w-4 text-slate-500" />;
case 'support': return <MessageSquare className="h-4 w-4 text-orange-500" />;
default: return <Calendar className="h-4 w-4" />;
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[400px] sm:w-[540px] overflow-y-auto p-0 border-l border-border/50 shadow-2xl">
{/* Header Section */}
<div className="relative bg-secondary/30 p-6 border-b border-border/50">
<div className="absolute top-4 right-4">
<Badge variant="outline" className="bg-background/50 backdrop-blur-sm">
{user.status}
</Badge>
</div>
<div className="flex flex-col items-center text-center gap-4">
<Avatar className="h-24 w-24 border-4 border-background shadow-lg">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="text-2xl bg-primary/10 text-primary font-bold">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h2 className="text-2xl font-bold tracking-tight">{user.name}</h2>
<p className="text-muted-foreground flex items-center justify-center gap-2 mt-1">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
{user.email}
</p>
</div>
<div className="flex gap-2">
{user.tags.map(tag => (
<Badge key={tag} variant="secondary" className="px-3 py-1 bg-white/50 hover:bg-white/80 transition-colors">
{tag}
</Badge>
))}
</div>
<div className="grid grid-cols-3 gap-4 w-full mt-4">
<div className="bg-background/80 rounded-xl p-3 text-center shadow-sm">
<p className="text-xs text-muted-foreground font-semibold uppercase">Spent</p>
<p className="font-bold text-lg text-primary">{formatCurrency(user.totalSpent)}</p>
</div>
<div className="bg-background/80 rounded-xl p-3 text-center shadow-sm">
<p className="text-xs text-muted-foreground font-semibold uppercase">Bookings</p>
<p className="font-bold text-lg text-foreground">{user.bookingsCount}</p>
</div>
<div className="bg-background/80 rounded-xl p-3 text-center shadow-sm">
<p className="text-xs text-muted-foreground font-semibold uppercase">Tier</p>
<p className="font-bold text-lg text-orange-600">{user.tier}</p>
</div>
</div>
</div>
</div>
<div className="p-6 space-y-8">
{/* Contact Info */}
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Contact Details</h3>
<div className="grid gap-3">
<div className="flex items-center gap-3 text-sm p-3 rounded-lg bg-secondary/20">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{user.email}</span>
</div>
<div className="flex items-center gap-3 text-sm p-3 rounded-lg bg-secondary/20">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{user.phone}</span>
</div>
<div className="flex items-center gap-3 text-sm p-3 rounded-lg bg-secondary/20">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Joined {new Date(user.joinedAt).toLocaleDateString()}</span>
</div>
</div>
</div>
<Separator />
{/* Notes Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Private Notes</h3>
<Button size="sm" variant="ghost" onClick={handleSaveNotes} className="h-8 gap-2">
<Save className="h-3 w-3" /> Save
</Button>
</div>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add internal notes about this user..."
className="min-h-[100px] bg-yellow-50/50 border-yellow-200/50 focus-visible:ring-yellow-400/30"
/>
</div>
<Separator />
{/* Timeline */}
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Activity Timeline</h3>
<div className="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-[2px] before:bg-border/50">
{user.interactions.length > 0 ? (
user.interactions.map((interaction) => (
<div key={interaction.id} className="relative">
<div className="absolute -left-[29px] top-1 h-6 w-6 rounded-full border-4 border-background bg-secondary flex items-center justify-center">
{getInteractionIcon(interaction.type)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-sm">{interaction.description}</p>
<span className="text-xs text-muted-foreground">{interaction.date}</span>
</div>
{interaction.status && (
<Badge variant="outline" className="w-fit text-[10px] capitalize">
{interaction.status}
</Badge>
)}
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground italic">No recent activity recorded.</p>
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -82,6 +82,14 @@ export const mockPartnerUsers: PartnerUser[] = [
{ id: 'p3', name: 'Bob Promoter', email: 'bob@toptier.com', partnerName: 'TopTier Promoters', role: 'Partner Admin', isVerified: false, commissionOverride: 5.0, status: 'Active' },
];
export interface Interaction {
id: string;
type: 'booking' | 'login' | 'support';
description: string;
date: string;
status?: 'attended' | 'no-show' | 'resolved';
}
export interface EndUser {
id: string;
name: string;
@@ -90,11 +98,81 @@ export interface EndUser {
bookingsCount: number;
totalSpent: number;
lastLogin: string;
status: 'Active' | 'Banned';
status: 'Active' | 'Banned' | 'Suspended';
tier: 'Gold' | 'Silver' | 'Bronze';
tags: string[];
notes?: string;
interactions: Interaction[];
avatarUrl?: string;
joinedAt: string;
}
export const mockEndUsers: EndUser[] = [
{ id: 'u1', name: 'Alice Walker', email: 'alice@gmail.com', phone: '+91 9876543210', bookingsCount: 5, totalSpent: 12500, lastLogin: '1h ago', status: 'Active' },
{ id: 'u2', name: 'Charlie Brown', email: 'charlie@yahoo.com', phone: '+91 8765432109', bookingsCount: 1, totalSpent: 500, lastLogin: '3d ago', status: 'Active' },
{ id: 'u3', name: 'Dave Fraud', email: 'dave@suspicious.com', phone: '+91 7654321098', bookingsCount: 0, totalSpent: 0, lastLogin: 'Never', status: 'Banned' },
{
id: 'u1',
name: 'Alice Walker',
email: 'alice@gmail.com',
phone: '+91 9876543210',
bookingsCount: 5,
totalSpent: 12500,
lastLogin: '1h ago',
status: 'Active',
tier: 'Gold',
tags: ['VIP', 'Big Spender'],
notes: "Always prefers front row seats. Send birthday discount.",
joinedAt: '2024-01-15',
interactions: [
{ id: 'i1', type: 'booking', description: 'Tech Summit 2024', date: '2025-02-01', status: 'attended' },
{ id: 'i2', type: 'login', description: 'Logged in from iPhone', date: '2025-02-03' }
]
},
{
id: 'u2',
name: 'Charlie Brown',
email: 'charlie@yahoo.com',
phone: '+91 8765432109',
bookingsCount: 1,
totalSpent: 500,
lastLogin: '3d ago',
status: 'Active',
tier: 'Bronze',
tags: ['New User'],
joinedAt: '2024-12-10',
interactions: [
{ id: 'i3', type: 'booking', description: 'Comedy Night', date: '2025-01-20', status: 'no-show' }
]
},
{
id: 'u3',
name: 'Dave Fraud',
email: 'dave@suspicious.com',
phone: '+91 7654321098',
bookingsCount: 0,
totalSpent: 0,
lastLogin: 'Never',
status: 'Banned',
tier: 'Bronze',
tags: ['Risk'],
notes: "Multiple failed payment attempts using different cards.",
joinedAt: '2024-11-05',
interactions: []
},
{
id: 'u4',
name: 'Sarah Jenkins',
email: 'sarah.j@outlook.com',
phone: '+91 9988776655',
bookingsCount: 12,
totalSpent: 45000,
lastLogin: '5m ago',
status: 'Active',
tier: 'Gold',
tags: ['Influencer', 'Early Adopter'],
notes: "Verified tech influencer on Instagram. Comp tickets for TechConf.",
joinedAt: '2023-06-20',
interactions: [
{ id: 'i4', type: 'booking', description: 'Tech Summit 2024', date: '2025-02-01', status: 'attended' },
{ id: 'i5', type: 'support', description: 'Ticket refund request', date: '2024-12-15', status: 'resolved' }
]
}
];