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",
label: "Notifications",
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 {
Card,
CardContent,
@ -23,8 +23,60 @@ import InstallApp, { isInstalled } from "./install-app";
import {
isNotificationEnabled,
registerNotification,
STORAGE_KEY,
unregisterNotification,
} 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() {
const [pushEnabled, setPushEnabled] = useState(isNotificationEnabled());
@ -36,7 +88,7 @@ export default function NotificationSettings() {
// Unregister notifications
unregisterNotification()
.then(() => {
setPushEnabled(true);
setPushEnabled(false);
})
.catch((error) => {
console.error("Failed to unregister notifications:", error);
@ -51,7 +103,7 @@ export default function NotificationSettings() {
if ("error" in result && result.error) {
console.error("Failed to register notifications:", result.error);
} else {
setPushEnabled(false);
setPushEnabled(true);
}
})
.catch((error) => {
@ -63,38 +115,9 @@ export default function NotificationSettings() {
}
}
// TODO: Replace with actual user data
const [events, setEvents] = useState([
{ id: "new-message", label: "New Message", enabled: true },
{ 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 [events, setEvents] = useState<Event[]>([]);
// TODO: Loader and error handling
const { notifications, isError, isLoading } = useNotifications();
const toggleEvent = (eventId: string) => {
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 (
<div className="max-w-4xl mx-auto space-y-6 sm:space-y-8">
@ -173,52 +207,67 @@ export default function NotificationSettings() {
</Card>
{/* Select Notification Events Section */}
<Card>
<CardHeader>
<CardTitle>Select Notification Events</CardTitle>
<CardDescription>
Choose which events you want to receive notifications for
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 sm:space-y-4">
{events.map((event, index) => (
<div key={event.id}>
<div className="flex items-start sm:items-center justify-between py-2 gap-4">
<div className="space-y-1 flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">
{event.label}
</p>
<p className="text-xs sm:text-sm text-muted-foreground leading-relaxed">
{event.id === "new-message" &&
"Get notified when you receive a new message"}
{event.id === "comment-reply" &&
"Get notified when someone replies to your comment"}
{event.id === "app-update" &&
"Get notified when a new app version is available"}
{event.id === "weekly-digest" &&
"Receive a weekly summary of your activity"}
{event.id === "security-alert" &&
"Important security notifications and alerts"}
{event.id === "friend-request" &&
"Get notified when someone sends you a friend request"}
</p>
{currentSubscription && (
<Card>
<CardHeader>
<CardTitle>Select Notification Events</CardTitle>
<CardDescription>
Choose which events you want to receive notifications for
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 sm:space-y-4">
{EVENTS.map((event, index) => (
<div key={event.id}>
<div className="flex items-start sm:items-center justify-between py-2 gap-4">
<div className="space-y-1 flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">
{event.label}
</p>
<p className="text-xs sm:text-sm text-muted-foreground leading-relaxed">
{event.description}
</p>
</div>
<Switch
checked={hasEvent(event.id)}
onCheckedChange={() => toggleEvent(event.id)}
aria-label={`Toggle ${event.label} notifications`}
className="flex-shrink-0"
/>
</div>
<Switch
checked={event.enabled}
onCheckedChange={() => toggleEvent(event.id)}
aria-label={`Toggle ${event.label} notifications`}
className="flex-shrink-0"
/>
{index < events.length - 1 && (
<Separator className="mt-3 sm:mt-4" />
)}
</div>
{index < events.length - 1 && (
<Separator className="mt-3 sm:mt-4" />
)}
</div>
))}
</div>
</CardContent>
</Card>
))}
<Button
variant="outline"
className="w-full mt-4"
onClick={() =>
updateSubscription(
currentSubscription.id,
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 */}
<Card>
@ -230,23 +279,23 @@ export default function NotificationSettings() {
</CardHeader>
<CardContent>
<div className="space-y-3 sm:space-y-4">
{subscriptions.map((subscription, index) => (
{notifications.map((subscription, index) => (
<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 items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">
{subscription.type === "mobile" ? (
<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" />
)}
<Smartphone className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground" />
</div>
<div className="space-y-1 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm sm:text-base truncate">
{subscription.name}
{subscription.device}
</p>
{subscription.status === "revoked" && (
{subscription.enabled ? (
<Badge variant="secondary" className="text-xs">
Active
</Badge>
) : (
<Badge
variant="destructive"
className="flex items-center gap-1 text-xs"
@ -255,22 +304,31 @@ export default function NotificationSettings() {
Revoked
</Badge>
)}
{subscription.status === "active" && (
<Badge variant="secondary" className="text-xs">
Active
</Badge>
)}
</div>
<p className="text-xs sm:text-sm text-muted-foreground">
Last seen {subscription.lastSeen}
</p>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-2 self-start sm:self-center">
<Button
variant="outline"
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"
>
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
@ -280,26 +338,41 @@ export default function NotificationSettings() {
variant="outline"
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"
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" />
<span className="hidden xs:inline">Delete</span>
</Button>
</div>
</div>
{index < subscriptions.length - 1 && (
{index < notifications.length - 1 && (
<Separator className="mt-3 sm:mt-4" />
)}
</div>
))}
{subscriptions.length === 0 && (
{notifications.length === 0 && (
<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" />
<p className="text-sm sm:text-base">
No subscribed devices found
Aucun appareil n'est actuellement abonné aux notifications.
</p>
<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>
</div>
)}

View file

@ -320,16 +320,68 @@ export const useSubjects = () => {
*/
export const subscribe = async (data: any) => {
return makePostRequest(
"/notifications/subscribe",
"/notifications",
data,
"Échec de l'abonnement aux notifications"
);
}
};
export const unsubscribe = async (data: any) => {
export const unsubscribe = async (id: string) => {
return makePostRequest(
"/notifications/unsubscribe",
data,
`/notifications/${id}/unsubscribe`,
{},
"É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";
const STORAGE_KEY = "notification_enabled";
export const STORAGE_KEY = "notification_enabled";
export async function registerNotification() {
const permission = await Notification.requestPermission();
@ -20,9 +20,9 @@ export async function registerNotification() {
userVisibleOnly: true,
applicationServerKey: import.meta.env.VITE_PUBLIC_VAPID_KEY,
});
await subscribe(subscription);
const data = await subscribe(subscription);
// Store to local storage
localStorage.setItem(STORAGE_KEY, "true");
localStorage.setItem(STORAGE_KEY, data.id?.toString());
return subscription;
} catch (err) {
return {
@ -32,15 +32,16 @@ export async function registerNotification() {
}
export function isNotificationEnabled() {
return localStorage.getItem(STORAGE_KEY) === "true";
return !!localStorage.getItem(STORAGE_KEY);
}
export async function unregisterNotification() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
localStorage.removeItem(STORAGE_KEY);
await unsubscribe(subscription);
const subscriptionId = localStorage.getItem(STORAGE_KEY);
if (!subscriptionId) {
return {
error: "E_NOT_REGISTERED",
};
}
await unsubscribe(subscriptionId);
localStorage.removeItem(STORAGE_KEY);
}