push notification

fix implemented
This commit is contained in:
dimitar committed 2026-02-22 04:00:20 +01:00
1 parent 0bbf2ab56f
commit 57864e00da
15 files changed
+5126 -26

No files matched your search

@@ -0,0 +1,166 @@
import { useState } from 'react';
import { usePushStats, useSendPushNotification } from '@/queries/push';
import { Button } from '@/components/ui/button';
import { Bell, Send, Users, AlertCircle, CheckCircle } from 'lucide-react';
export function PushNotificationManager() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [url, setUrl] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
const [result, setResult] = useState<{ sent: number; failed: number } | null>(
null,
);
const { data: stats, isLoading: loadingStats } = usePushStats();
const sendMutation = useSendPushNotification();
const handleSendTest = async () => {
if (!title.trim() || !body.trim()) return;
try {
const result = await sendMutation.mutateAsync({
title: title.trim(),
body: body.trim(),
url: url.trim() || undefined,
});
setResult(result);
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 5000);
} catch (error) {
console.error('Failed to send notification:', error);
}
};
const isValid = title.trim().length > 0 && body.trim().length > 0;
return (
<div className="border-brutal bg-card p-6">
<div className="flex items-center gap-3 mb-6">
<Bell className="h-6 w-6 text-accent" />
<h2 className="text-2xl font-display uppercase">Push Notifications</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<div className="border-brutal-sm bg-background p-5 h-full">
<div className="flex items-center gap-2 mb-3">
<Users className="h-5 w-5 text-muted-foreground" />
<span className="font-body text-sm uppercase tracking-wider text-muted-foreground">
Subscribers
</span>
</div>
{loadingStats ? (
<div className="text-4xl font-display animate-pulse">...</div>
) : (
<div className="text-5xl font-display">
{stats?.totalSubscribers ?? 0}
</div>
)}
<p className="text-xs font-body text-muted-foreground mt-2">
Active push subscribers
</p>
</div>
</div>
<div className="lg:col-span-2">
<div className="border-brutal-sm bg-background p-5">
<h3 className="font-body text-sm uppercase tracking-wider text-muted-foreground mb-4">
Send Notification
</h3>
<div className="space-y-4">
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter notification title..."
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
maxLength={50}
/>
</div>
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
Body
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Enter notification message..."
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none resize-none"
rows={3}
maxLength={150}
/>
</div>
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
URL (optional)
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="/article/slug or /live-blogs/slug"
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
/>
</div>
<div className="flex items-center justify-between gap-4 pt-2">
<div className="text-xs font-body text-muted-foreground">
{title.length}/50 chars • {body.length}/150 chars
</div>
<Button
variant="brutalAccent"
onClick={handleSendTest}
disabled={!isValid || sendMutation.isPending}
className="flex items-center gap-2"
>
<Send className="h-4 w-4" />
{sendMutation.isPending ? 'Sending...' : 'Send to All'}
</Button>
</div>
</div>
{showSuccess && result && (
<div className="mt-4 p-3 border-2 border-green-500 bg-green-500/10 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-body text-sm font-medium">
Notification sent!
</p>
<p className="font-body text-xs text-muted-foreground">
Delivered: {result.sent} • Failed: {result.failed}
</p>
</div>
</div>
)}
{sendMutation.isError && (
<div className="mt-4 p-3 border-2 border-red-500 bg-red-500/10 flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-500" />
<p className="font-body text-sm">
Failed to send notification. Please try again.
</p>
</div>
)}
</div>
</div>
</div>
<div className="mt-6 p-4 border-2 border-foreground/20 bg-foreground/5">
<p className="font-body text-xs text-muted-foreground">
<strong>Note:</strong> Notifications are sent to all subscribers with
push enabled. Use sparingly to avoid spam. Articles and live blog
updates are automatically pushed when published.
</p>
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { format } from 'date-fns';
import { mk } from 'date-fns/locale';
import { PushNotificationManager } from '@/components/admin/PushNotificationManager';
export function AdminDashboardComponent() {
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@@ -167,32 +168,42 @@ export function AdminDashboardComponent() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{liveBlogs.filter(b => b.status === 'live').length || 0}
{liveBlogs.filter((b) => b.status === 'live').length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
<p className="text-xs font-body text-muted-foreground mt-1">
Активни live блогови
</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{articles.filter(a => a.status === 'published').length || 0}
{articles.filter((a) => a.status === 'published').length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
<p className="text-xs font-body text-muted-foreground mt-1">
Објавени написи
</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{liveBlogs.filter(b => b.isPinned).length || 0}
{liveBlogs.filter((b) => b.isPinned).length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
<p className="text-xs font-body text-muted-foreground mt-1">
Закачени live блогови
</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
<p className="text-xs font-body text-muted-foreground mt-1">
Вкупни прегледи
</p>
</div>
</div>
)}
{!showArchived && <PushNotificationManager />}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="border-brutal bg-card">
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
+38
View File
@@ -827,3 +827,41 @@ export async function getUserReaction(
}
return response.json();
}
// Push Notification Admin Types
export interface PushStats {
totalSubscribers: number;
}
export interface SendNotificationResult {
sent: number;
failed: number;
}
export interface SendNotificationDto {
title: string;
body: string;
url?: string;
}
// Push Notification Admin API Functions
export async function fetchPushStats(): Promise<PushStats> {
const response = await authFetch(`${API_BASE_URL}/push/stats`);
if (!response.ok) {
throw new Error('Failed to fetch push stats');
}
return response.json();
}
export async function sendPushNotification(
dto: SendNotificationDto,
): Promise<SendNotificationResult> {
const response = await authFetch(`${API_BASE_URL}/push/send`, {
method: 'POST',
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to send push notification');
}
return response.json();
}
+20
View File
@@ -0,0 +1,20 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api';
export function usePushStats() {
return useQuery({
queryKey: ['push', 'stats'],
queryFn: api.fetchPushStats,
});
}
export function useSendPushNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.sendPushNotification,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['push', 'stats'] });
},
});
}