563 lines
24 KiB
TypeScript
563 lines
24 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Search,
|
|
CheckCheck,
|
|
Send,
|
|
BellRing,
|
|
Mail,
|
|
MessageSquare,
|
|
History,
|
|
Target,
|
|
ArrowRight,
|
|
Loader2,
|
|
Calendar,
|
|
} from "lucide-react";
|
|
import { notificationService } from "@/services/notification.service";
|
|
import type {
|
|
SendPushNotificationRequest,
|
|
SendSmsNotificationRequest,
|
|
SendEmailNotificationRequest,
|
|
} from "@/services/notification.service";
|
|
import { toast } from "sonner";
|
|
import { format } from "date-fns";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Channel = "PUSH" | "SMS" | "EMAIL";
|
|
|
|
export default function NotificationsPage() {
|
|
const queryClient = useQueryClient();
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [activeChannel, setActiveChannel] = useState<Channel>("PUSH");
|
|
const [isSendModalOpen, setIsSendModalOpen] = useState(false);
|
|
|
|
// Combined form state
|
|
const [pushForm, setPushForm] = useState<SendPushNotificationRequest>({
|
|
title: "",
|
|
body: "",
|
|
recipientId: "",
|
|
url: "",
|
|
icon: "/assets/icon.png",
|
|
});
|
|
const [smsForm, setSmsForm] = useState<SendSmsNotificationRequest>({
|
|
body: "",
|
|
recipientPhone: "",
|
|
});
|
|
const [emailForm, setEmailForm] = useState<SendEmailNotificationRequest>({
|
|
subject: "",
|
|
body: "",
|
|
recipientEmail: "",
|
|
});
|
|
|
|
const { data: notifications, isLoading } = useQuery({
|
|
queryKey: ["notifications"],
|
|
queryFn: () => notificationService.getNotifications(),
|
|
});
|
|
|
|
const { data: unreadCount } = useQuery({
|
|
queryKey: ["notifications", "unread-count"],
|
|
queryFn: () => notificationService.getUnreadCount(),
|
|
});
|
|
|
|
const pushMutation = useMutation({
|
|
mutationFn: (data: SendPushNotificationRequest) =>
|
|
notificationService.sendPushNotification(data),
|
|
onSuccess: () => {
|
|
toast.success("Network transmission: Push packet delivered to gateway");
|
|
setIsSendModalOpen(false);
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
},
|
|
});
|
|
|
|
const smsMutation = useMutation({
|
|
mutationFn: (data: SendSmsNotificationRequest) =>
|
|
notificationService.sendSmsNotification(data),
|
|
onSuccess: () => {
|
|
toast.success("Cellular uplink: SMS payload queued for broadcast");
|
|
setIsSendModalOpen(false);
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
},
|
|
});
|
|
|
|
const emailMutation = useMutation({
|
|
mutationFn: (data: SendEmailNotificationRequest) =>
|
|
notificationService.sendEmailNotification(data),
|
|
onSuccess: () => {
|
|
toast.success("SMTP Handshake: Email broadcast initiated");
|
|
setIsSendModalOpen(false);
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
},
|
|
});
|
|
|
|
const filteredNotifications = useMemo(() => {
|
|
if (!notifications) return [];
|
|
return notifications.filter((n) => {
|
|
if (!searchQuery) return true;
|
|
const q = searchQuery.toLowerCase();
|
|
return (
|
|
n.title?.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
|
|
);
|
|
});
|
|
}, [notifications, searchQuery]);
|
|
|
|
const handleSend = () => {
|
|
if (activeChannel === "PUSH") pushMutation.mutate(pushForm);
|
|
else if (activeChannel === "SMS") smsMutation.mutate(smsForm);
|
|
else if (activeChannel === "EMAIL") emailMutation.mutate(emailForm);
|
|
};
|
|
|
|
const isPending =
|
|
pushMutation.isPending || smsMutation.isPending || emailMutation.isPending;
|
|
|
|
return (
|
|
<div className="space-y-8 animate-in fade-in duration-500">
|
|
{/* Header Section */}
|
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 text-primary mb-1">
|
|
<div className="p-2 bg-primary/10 rounded-lg">
|
|
<BellRing className="w-5 h-5" />
|
|
</div>
|
|
<span className="text-xs font-black uppercase tracking-widest opacity-70">
|
|
Messaging Hub
|
|
</span>
|
|
</div>
|
|
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
|
|
Command <span className="text-primary NOT-italic">Center</span>
|
|
</h1>
|
|
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
|
Dispatch multi-channel broadcasts and monitor real-time network
|
|
telemetry across the Yaltopia mesh.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
className="h-12 px-6 rounded-2xl text-slate-400 hover:text-slate-900 hover:bg-slate-100 font-black uppercase text-[10px] tracking-widest transition-all"
|
|
onClick={() => notificationService.markAllAsRead()}
|
|
>
|
|
<CheckCheck className="w-4 h-4 mr-2" />
|
|
Clear Signal
|
|
</Button>
|
|
<Button
|
|
className="h-12 px-8 rounded-2xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-xl shadow-slate-200 transition-all hover:-translate-y-0.5"
|
|
onClick={() => setIsSendModalOpen(true)}
|
|
>
|
|
<Send className="h-4 w-4 mr-2" />
|
|
New Broadcast
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Stats / Quick Info */}
|
|
<div className="lg:col-span-1 space-y-6">
|
|
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-slate-900 text-white rounded-3xl overflow-hidden p-8">
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
|
System Status
|
|
</span>
|
|
<Badge className="bg-emerald-500 text-white border-none text-[10px] rounded-full px-2 py-0">
|
|
Active
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
<span className="text-5xl font-black italic tracking-tighter leading-none">
|
|
{unreadCount ?? 0}
|
|
</span>
|
|
<p className="text-slate-400 text-xs font-medium mt-2">
|
|
Active notifications in current window.
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4 border-t border-white/10 pt-6">
|
|
<div className="text-center">
|
|
<div className="text-lg font-black tracking-tight">
|
|
{notifications?.length ?? 0}
|
|
</div>
|
|
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
|
Push
|
|
</div>
|
|
</div>
|
|
<div className="text-center border-x border-white/10 px-2">
|
|
<div className="text-lg font-black tracking-tight">—</div>
|
|
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
|
SMS
|
|
</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-lg font-black tracking-tight">—</div>
|
|
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
|
Mail
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl p-6">
|
|
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-4 px-2">
|
|
Operator Directives
|
|
</h3>
|
|
<div className="space-y-4">
|
|
{[
|
|
{
|
|
icon: Target,
|
|
label: "Audience Segmentation",
|
|
desc: "Filter by role",
|
|
},
|
|
{
|
|
icon: Calendar,
|
|
label: "Scheduled Dispatch",
|
|
desc: "Queue for later",
|
|
},
|
|
{
|
|
icon: History,
|
|
label: "Audit Integrity",
|
|
desc: "Full log access",
|
|
},
|
|
].map((item, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-center gap-4 p-3 rounded-2xl hover:bg-slate-50 transition-colors group cursor-default"
|
|
>
|
|
<div className="p-2.5 bg-slate-100 rounded-xl group-hover:bg-primary/10 transition-colors">
|
|
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-primary transition-colors" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-black text-slate-900 tracking-tight">
|
|
{item.label}
|
|
</p>
|
|
<p className="text-[10px] text-slate-400 font-medium">
|
|
{item.desc}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* History Feed */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
|
|
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 uppercase">
|
|
<h2 className="text-xs font-black tracking-[0.2em] text-slate-400">
|
|
Transmission Log
|
|
</h2>
|
|
<div className="relative group min-w-[280px]">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
|
<Input
|
|
className="pl-11 h-11 bg-slate-50 border-slate-200/60 rounded-xl text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
|
placeholder="Search signal history..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto min-h-[400px]">
|
|
<div className="p-4 space-y-4">
|
|
{isLoading ? (
|
|
Array.from({ length: 4 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="h-24 bg-slate-100/50 animate-pulse rounded-2xl"
|
|
/>
|
|
))
|
|
) : filteredNotifications.length ? (
|
|
filteredNotifications.map((n) => (
|
|
<div
|
|
key={n.id}
|
|
className="p-6 bg-white border border-slate-100 rounded-2xl hover:border-primary/20 hover:shadow-lg hover:shadow-primary/5 transition-all group relative overflow-hidden"
|
|
>
|
|
<div className="absolute top-0 right-0 w-1 p-1 h-full bg-slate-100 group-hover:bg-primary transition-colors" />
|
|
<div className="flex items-start justify-between gap-6">
|
|
<div className="flex gap-4">
|
|
<div className="p-3 bg-slate-50 rounded-xl group-hover:bg-primary/5 transition-colors">
|
|
<BellRing className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-black text-slate-900 tracking-tight">
|
|
{n.title}
|
|
</span>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[9px] font-black uppercase tracking-tighter opacity-50 px-1.5 py-0 border-slate-200"
|
|
>
|
|
Push
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-slate-500 font-medium leading-relaxed max-w-lg">
|
|
{n.body}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
|
<span className="text-[10px] font-bold text-slate-400">
|
|
{format(
|
|
new Date(n.createdAt),
|
|
"HH:mm · MMM d, yyyy",
|
|
)}
|
|
</span>
|
|
<Badge
|
|
className={cn(
|
|
"text-[9px] font-black uppercase tracking-widest rounded-lg px-2 border-none",
|
|
n.isSent
|
|
? "bg-emerald-500 text-white"
|
|
: "bg-slate-200 text-slate-500",
|
|
)}
|
|
>
|
|
{n.isSent ? "Delivered" : "Queued"}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="py-24 text-center">
|
|
<div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
|
|
<History className="w-16 h-16" />
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-black uppercase tracking-[0.2em]">
|
|
Zero Telemetry
|
|
</span>
|
|
<span className="text-xs font-medium italic mt-1">
|
|
No transmissions detected in the current signal
|
|
range.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dispatch Dialog */}
|
|
<Dialog open={isSendModalOpen} onOpenChange={setIsSendModalOpen}>
|
|
<DialogContent className="rounded-3xl max-w-2xl p-0 border-none shadow-2xl overflow-hidden">
|
|
<div className="p-8 bg-slate-900 text-white overflow-hidden relative">
|
|
{/* Decorative element */}
|
|
<div className="absolute -top-12 -right-12 w-48 h-48 bg-primary/20 rounded-full blur-3xl" />
|
|
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-white/10 rounded-xl">
|
|
<Target className="w-5 h-5 text-primary" />
|
|
</div>
|
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
|
|
Signal Transmission
|
|
</span>
|
|
</div>
|
|
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
|
Dispatch{" "}
|
|
<span className="text-primary NOT-italic">Broadcast</span>
|
|
</DialogTitle>
|
|
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
|
Authoritative platform-wide signal broadcast. Choose delivery
|
|
channels and construct the payload with precision.
|
|
</DialogDescription>
|
|
</div>
|
|
|
|
<div className="p-8">
|
|
<div className="space-y-8">
|
|
{/* Channel Selector */}
|
|
<div className="space-y-4">
|
|
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
|
Select Uplink Channels
|
|
</Label>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
{[
|
|
{ id: "PUSH", icon: BellRing, label: "Push Notification" },
|
|
{ id: "SMS", icon: MessageSquare, label: "SMS Gateway" },
|
|
{ id: "EMAIL", icon: Mail, label: "Email Relay" },
|
|
].map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => setActiveChannel(c.id as Channel)}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 transition-all group",
|
|
activeChannel === c.id
|
|
? "border-primary bg-primary/5 text-primary shadow-lg shadow-primary/10"
|
|
: "border-slate-100 bg-white text-slate-400 hover:border-slate-200",
|
|
)}
|
|
>
|
|
<c.icon
|
|
className={cn(
|
|
"w-6 h-6 transition-transform group-active:scale-90",
|
|
activeChannel === c.id
|
|
? "text-primary"
|
|
: "text-slate-300",
|
|
)}
|
|
/>
|
|
<span className="text-[10px] font-black uppercase tracking-widest">
|
|
{c.id}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className="bg-slate-100" />
|
|
|
|
{/* Dynamic Form Area */}
|
|
<div className="space-y-6">
|
|
{activeChannel === "PUSH" && (
|
|
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
|
<div className="grid gap-2">
|
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
Notification Title
|
|
</Label>
|
|
<Input
|
|
value={pushForm.title}
|
|
onChange={(e) =>
|
|
setPushForm({ ...pushForm, title: e.target.value })
|
|
}
|
|
placeholder="Critical System Patch Available"
|
|
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
Message Body
|
|
</Label>
|
|
<Textarea
|
|
value={pushForm.body}
|
|
onChange={(e) =>
|
|
setPushForm({ ...pushForm, body: e.target.value })
|
|
}
|
|
placeholder="Update your client to version 4.2 now..."
|
|
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeChannel === "SMS" && (
|
|
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
|
<div className="grid gap-2">
|
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
Target Phone (Optional)
|
|
</Label>
|
|
<Input
|
|
value={smsForm.recipientPhone}
|
|
onChange={(e) =>
|
|
setSmsForm({
|
|
...smsForm,
|
|
recipientPhone: e.target.value,
|
|
})
|
|
}
|
|
placeholder="+1 (555) 000-0000"
|
|
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
|
/>
|
|
<p className="text-[9px] text-slate-400 font-medium italic">
|
|
Leave empty for multi-user broadcast.
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
SMS Payload
|
|
</Label>
|
|
<Textarea
|
|
value={smsForm.body}
|
|
onChange={(e) =>
|
|
setSmsForm({ ...smsForm, body: e.target.value })
|
|
}
|
|
placeholder="Your Yaltopia ticket code is XYZ-123..."
|
|
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeChannel === "EMAIL" && (
|
|
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
|
<div className="grid gap-2">
|
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
Email Subject
|
|
</Label>
|
|
<Input
|
|
value={emailForm.subject}
|
|
onChange={(e) =>
|
|
setEmailForm({
|
|
...emailForm,
|
|
subject: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Important Account Update"
|
|
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
HTML Content
|
|
</Label>
|
|
<Textarea
|
|
value={emailForm.body}
|
|
onChange={(e) =>
|
|
setEmailForm({ ...emailForm, body: e.target.value })
|
|
}
|
|
placeholder="<h1>Welcome to Yaltopia</h1>..."
|
|
className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
|
<Button
|
|
variant="ghost"
|
|
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
|
|
onClick={() => setIsSendModalOpen(false)}
|
|
>
|
|
Abort Mission
|
|
</Button>
|
|
<Button
|
|
className="h-12 px-10 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-slate-200 transition-all active:scale-95"
|
|
disabled={isPending}
|
|
onClick={handleSend}
|
|
>
|
|
{isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Broadcasting...
|
|
</>
|
|
) : (
|
|
<>
|
|
Commit {activeChannel} Signal
|
|
<ArrowRight className="w-4 h-4 ml-2" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|