feat: add notifications
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m42s

This commit is contained in:
Nathan Lamy 2025-08-20 12:22:05 +02:00
parent f3d5601d00
commit 7831f61fb9
4 changed files with 242 additions and 116 deletions

View file

@ -25,7 +25,7 @@ export default function SettingsPage({ user }: { user: User }) {
value: "notifications", value: "notifications",
label: "Notifications", label: "Notifications",
icon: <Bell className="h-4 w-4" />, icon: <Bell className="h-4 w-4" />,
content: <NotificationSettings user={user} />, content: <NotificationSettings />,
}, },
]; ];

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { import {
Card, Card,
CardContent, CardContent,
@ -23,8 +23,60 @@ import InstallApp, { isInstalled } from "./install-app";
import { import {
isNotificationEnabled, isNotificationEnabled,
registerNotification, registerNotification,
STORAGE_KEY,
unregisterNotification, unregisterNotification,
} from "~/lib/notification"; } from "~/lib/notification";
import {
testNotification,
unsubscribe,
updateSubscription,
useNotifications,
type Event,
} from "~/lib/api";
import { toast } from "sonner";
const EVENTS = [
{
id: "SYSTEM",
label: "Système",
description:
"Soyez informé des mises à jour et des nouveautés de Khollisé.",
},
{
id: "COLLE_ADDED",
label: "Colle ajoutée",
description:
"Recevez une notification lorsqu'une nouvelle colle est ajoutée par votre CDT.",
},
{
id: "COLLE_REMOVED",
label: "Colle supprimée",
description: "Recevez une notification lorsqu'une colle est supprimée.",
},
{
id: "COLLE_UPDATED",
label: "Colle modifiée",
description:
"Soyez averti lorsqu'un colleur modifie le contenu d'une colle.",
},
{
id: "GRADE_ADDED",
label: "Note ajoutée",
description: "Recevez une notification lorsqu'une colle est notée.",
},
{
id: "GRADE_UPDATED",
label: "Note modifiée",
description: "Soyez averti lorsqu'une note est modifiée.",
},
{
id: "ROOM_UPDATED",
label: "Salle modifiée",
description: "Soyez averti lorsqu'une colle change de salle.",
},
];
export default function NotificationSettings() { export default function NotificationSettings() {
const [pushEnabled, setPushEnabled] = useState(isNotificationEnabled()); const [pushEnabled, setPushEnabled] = useState(isNotificationEnabled());
@ -36,7 +88,7 @@ export default function NotificationSettings() {
// Unregister notifications // Unregister notifications
unregisterNotification() unregisterNotification()
.then(() => { .then(() => {
setPushEnabled(true); setPushEnabled(false);
}) })
.catch((error) => { .catch((error) => {
console.error("Failed to unregister notifications:", error); console.error("Failed to unregister notifications:", error);
@ -51,7 +103,7 @@ export default function NotificationSettings() {
if ("error" in result && result.error) { if ("error" in result && result.error) {
console.error("Failed to register notifications:", result.error); console.error("Failed to register notifications:", result.error);
} else { } else {
setPushEnabled(false); setPushEnabled(true);
} }
}) })
.catch((error) => { .catch((error) => {
@ -63,38 +115,9 @@ export default function NotificationSettings() {
} }
} }
// TODO: Replace with actual user data const [events, setEvents] = useState<Event[]>([]);
const [events, setEvents] = useState([ // TODO: Loader and error handling
{ id: "new-message", label: "New Message", enabled: true }, const { notifications, isError, isLoading } = useNotifications();
{ id: "comment-reply", label: "Comment Reply", enabled: true },
{ id: "app-update", label: "App Update", enabled: false },
{ id: "weekly-digest", label: "Weekly Digest", enabled: true },
{ id: "security-alert", label: "Security Alert", enabled: true },
{ id: "friend-request", label: "Friend Request", enabled: false },
]);
const [subscriptions] = useState([
{
id: "1",
name: "iPhone 15 Pro",
type: "mobile",
status: "active",
lastSeen: "2 hours ago",
},
{
id: "2",
name: "MacBook Pro",
type: "desktop",
status: "active",
lastSeen: "5 minutes ago",
},
{
id: "3",
name: "Chrome on Windows",
type: "desktop",
status: "revoked",
lastSeen: "3 days ago",
},
]);
const toggleEvent = (eventId: string) => { const toggleEvent = (eventId: string) => {
setEvents( setEvents(
@ -103,6 +126,17 @@ export default function NotificationSettings() {
) )
); );
}; };
const hasEvent = (eventId: string) => {
return events.some((event) => event.id === eventId && event.enabled);
};
const currentSubscriptionId = localStorage.getItem(STORAGE_KEY);
const currentSubscription = notifications.find(
(sub) => sub.id === currentSubscriptionId
);
useEffect(() => {
setEvents(currentSubscription?.events || []);
}, [currentSubscription]);
return ( return (
<div className="max-w-4xl mx-auto space-y-6 sm:space-y-8"> <div className="max-w-4xl mx-auto space-y-6 sm:space-y-8">
@ -173,52 +207,67 @@ export default function NotificationSettings() {
</Card> </Card>
{/* Select Notification Events Section */} {/* Select Notification Events Section */}
<Card> {currentSubscription && (
<CardHeader> <Card>
<CardTitle>Select Notification Events</CardTitle> <CardHeader>
<CardDescription> <CardTitle>Select Notification Events</CardTitle>
Choose which events you want to receive notifications for <CardDescription>
</CardDescription> Choose which events you want to receive notifications for
</CardHeader> </CardDescription>
<CardContent> </CardHeader>
<div className="space-y-3 sm:space-y-4"> <CardContent>
{events.map((event, index) => ( <div className="space-y-3 sm:space-y-4">
<div key={event.id}> {EVENTS.map((event, index) => (
<div className="flex items-start sm:items-center justify-between py-2 gap-4"> <div key={event.id}>
<div className="space-y-1 flex-1 min-w-0"> <div className="flex items-start sm:items-center justify-between py-2 gap-4">
<p className="font-medium text-sm sm:text-base"> <div className="space-y-1 flex-1 min-w-0">
{event.label} <p className="font-medium text-sm sm:text-base">
</p> {event.label}
<p className="text-xs sm:text-sm text-muted-foreground leading-relaxed"> </p>
{event.id === "new-message" && <p className="text-xs sm:text-sm text-muted-foreground leading-relaxed">
"Get notified when you receive a new message"} {event.description}
{event.id === "comment-reply" && </p>
"Get notified when someone replies to your comment"} </div>
{event.id === "app-update" && <Switch
"Get notified when a new app version is available"} checked={hasEvent(event.id)}
{event.id === "weekly-digest" && onCheckedChange={() => toggleEvent(event.id)}
"Receive a weekly summary of your activity"} aria-label={`Toggle ${event.label} notifications`}
{event.id === "security-alert" && className="flex-shrink-0"
"Important security notifications and alerts"} />
{event.id === "friend-request" &&
"Get notified when someone sends you a friend request"}
</p>
</div> </div>
<Switch {index < events.length - 1 && (
checked={event.enabled} <Separator className="mt-3 sm:mt-4" />
onCheckedChange={() => toggleEvent(event.id)} )}
aria-label={`Toggle ${event.label} notifications`}
className="flex-shrink-0"
/>
</div> </div>
{index < events.length - 1 && ( ))}
<Separator className="mt-3 sm:mt-4" /> <Button
)} variant="outline"
</div> className="w-full mt-4"
))} onClick={() =>
</div> updateSubscription(
</CardContent> currentSubscription.id,
</Card> events.map((event) => event.id)
)
.then(() => {
toast.success(
"Vos préférences de notification ont été mises à jour."
);
})
.catch((error) => {
console.error("Failed to update subscription:", error);
toast.error(
"Échec de la mise à jour des préférences de notification."
);
})
}
>
<Send className="h-4 w-4 mr-2" />
Enregistrer
</Button>
</div>
</CardContent>
</Card>
)}
{/* Manage Subscriptions Section */} {/* Manage Subscriptions Section */}
<Card> <Card>
@ -230,23 +279,23 @@ export default function NotificationSettings() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
{subscriptions.map((subscription, index) => ( {notifications.map((subscription, index) => (
<div key={subscription.id}> <div key={subscription.id}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{subscription.type === "mobile" ? ( <Smartphone className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground" />
<Smartphone className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground" />
)}
</div> </div>
<div className="space-y-1 flex-1 min-w-0"> <div className="space-y-1 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm sm:text-base truncate"> <p className="font-medium text-sm sm:text-base truncate">
{subscription.name} {subscription.device}
</p> </p>
{subscription.status === "revoked" && ( {subscription.enabled ? (
<Badge variant="secondary" className="text-xs">
Active
</Badge>
) : (
<Badge <Badge
variant="destructive" variant="destructive"
className="flex items-center gap-1 text-xs" className="flex items-center gap-1 text-xs"
@ -255,22 +304,31 @@ export default function NotificationSettings() {
Revoked Revoked
</Badge> </Badge>
)} )}
{subscription.status === "active" && (
<Badge variant="secondary" className="text-xs">
Active
</Badge>
)}
</div> </div>
<p className="text-xs sm:text-sm text-muted-foreground">
Last seen {subscription.lastSeen}
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 sm:gap-2 self-start sm:self-center"> <div className="flex items-center gap-2 sm:gap-2 self-start sm:self-center">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={subscription.status === "revoked"} disabled={!subscription.enabled}
onClick={() => {
testNotification(subscription.id)
.then(() => {
toast.success(
"Vous allez recevoir une notification de test sur votre appareil..."
);
})
.catch((error) => {
console.error(
"Failed to send test notification:",
error
);
toast.error(
"Échec de l'envoi de la notification de test."
);
});
}}
className="flex items-center gap-1 bg-transparent text-xs sm:text-sm px-2 sm:px-3" className="flex items-center gap-1 bg-transparent text-xs sm:text-sm px-2 sm:px-3"
> >
<Send className="h-3 w-3 sm:h-4 sm:w-4" /> <Send className="h-3 w-3 sm:h-4 sm:w-4" />
@ -280,26 +338,41 @@ export default function NotificationSettings() {
variant="outline" variant="outline"
size="sm" size="sm"
className="flex items-center gap-1 text-destructive hover:text-destructive bg-transparent text-xs sm:text-sm px-2 sm:px-3" className="flex items-center gap-1 text-destructive hover:text-destructive bg-transparent text-xs sm:text-sm px-2 sm:px-3"
onClick={() => {
unsubscribe(subscription.id)
.then(() => {
toast.success(
"L'appareil a été désabonné des notifications."
);
})
.catch((error) => {
console.error("Failed to unsubscribe:", error);
toast.error(
"Échec de la désinscription de l'appareil."
);
});
}}
> >
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" /> <Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
<span className="hidden xs:inline">Delete</span> <span className="hidden xs:inline">Delete</span>
</Button> </Button>
</div> </div>
</div> </div>
{index < subscriptions.length - 1 && ( {index < notifications.length - 1 && (
<Separator className="mt-3 sm:mt-4" /> <Separator className="mt-3 sm:mt-4" />
)} )}
</div> </div>
))} ))}
{subscriptions.length === 0 && ( {notifications.length === 0 && (
<div className="text-center py-6 sm:py-8 text-muted-foreground"> <div className="text-center py-6 sm:py-8 text-muted-foreground">
<Bell className="h-10 w-10 sm:h-12 sm:w-12 mx-auto mb-3 sm:mb-4 opacity-50" /> <Bell className="h-10 w-10 sm:h-12 sm:w-12 mx-auto mb-3 sm:mb-4 opacity-50" />
<p className="text-sm sm:text-base"> <p className="text-sm sm:text-base">
No subscribed devices found Aucun appareil n'est actuellement abonné aux notifications.
</p> </p>
<p className="text-xs sm:text-sm"> <p className="text-xs sm:text-sm">
Enable push notifications to add this device Vous pouvez activer les notifications pour recevoir des
alertes sur vos appareils.
</p> </p>
</div> </div>
)} )}

View file

@ -320,16 +320,68 @@ export const useSubjects = () => {
*/ */
export const subscribe = async (data: any) => { export const subscribe = async (data: any) => {
return makePostRequest( return makePostRequest(
"/notifications/subscribe", "/notifications",
data, data,
"Échec de l'abonnement aux notifications" "Échec de l'abonnement aux notifications"
); );
} };
export const unsubscribe = async (data: any) => { export const unsubscribe = async (id: string) => {
return makePostRequest( return makePostRequest(
"/notifications/unsubscribe", `/notifications/${id}/unsubscribe`,
data, {},
"Échec de la désinscription des notifications" "Échec de la désinscription des notifications"
); );
}; };
export const updateSubscription = async (id: string, events: string[]) => {
return makePostRequest(
`/notifications/${id}`,
{ events },
"Échec de la mise à jour de l'abonnement aux notifications"
);
};
export const getNotifications = async () => {
return makeRequest(
"/notifications",
"Échec de la récupération des notifications"
);
};
export interface Event {
id: string;
enabled: boolean;
}
interface Subscription {
id: string;
device: string;
events: Event[];
enabled: boolean;
}
export const useNotifications = () => {
const { data, ...props } = useQuery({
queryKey: ["notifications"],
queryFn: getNotifications,
staleTime: Duration.fromObject({
hours: 1, // 1 hour
}).toMillis(),
gcTime: Duration.fromObject({
days: 3, // 3 days
}).toMillis(),
});
return {
notifications: (data as Subscription[]) || [],
...props,
};
};
export const testNotification = async (id: string) => {
return makePostRequest(
`/notifications/${id}/test`,
{},
"Échec de l'envoi de la notification de test"
);
};

View file

@ -1,6 +1,6 @@
import { subscribe, unsubscribe } from "./api"; import { subscribe, unsubscribe } from "./api";
const STORAGE_KEY = "notification_enabled"; export const STORAGE_KEY = "notification_enabled";
export async function registerNotification() { export async function registerNotification() {
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
@ -20,9 +20,9 @@ export async function registerNotification() {
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: import.meta.env.VITE_PUBLIC_VAPID_KEY, applicationServerKey: import.meta.env.VITE_PUBLIC_VAPID_KEY,
}); });
await subscribe(subscription); const data = await subscribe(subscription);
// Store to local storage // Store to local storage
localStorage.setItem(STORAGE_KEY, "true"); localStorage.setItem(STORAGE_KEY, data.id?.toString());
return subscription; return subscription;
} catch (err) { } catch (err) {
return { return {
@ -32,15 +32,16 @@ export async function registerNotification() {
} }
export function isNotificationEnabled() { export function isNotificationEnabled() {
return localStorage.getItem(STORAGE_KEY) === "true"; return !!localStorage.getItem(STORAGE_KEY);
} }
export async function unregisterNotification() { export async function unregisterNotification() {
const registration = await navigator.serviceWorker.ready; const subscriptionId = localStorage.getItem(STORAGE_KEY);
const subscription = await registration.pushManager.getSubscription(); if (!subscriptionId) {
if (subscription) { return {
await subscription.unsubscribe(); error: "E_NOT_REGISTERED",
localStorage.removeItem(STORAGE_KEY); };
await unsubscribe(subscription);
} }
await unsubscribe(subscriptionId);
localStorage.removeItem(STORAGE_KEY);
} }