feat: add notifications
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m42s
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m42s
This commit is contained in:
parent
f3d5601d00
commit
7831f61fb9
4 changed files with 242 additions and 116 deletions
|
|
@ -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 />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +207,7 @@ export default function NotificationSettings() {
|
|||
</Card>
|
||||
|
||||
{/* Select Notification Events Section */}
|
||||
{currentSubscription && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Notification Events</CardTitle>
|
||||
|
|
@ -182,7 +217,7 @@ export default function NotificationSettings() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{events.map((event, index) => (
|
||||
{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">
|
||||
|
|
@ -190,22 +225,11 @@ export default function NotificationSettings() {
|
|||
{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"}
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={event.enabled}
|
||||
checked={hasEvent(event.id)}
|
||||
onCheckedChange={() => toggleEvent(event.id)}
|
||||
aria-label={`Toggle ${event.label} notifications`}
|
||||
className="flex-shrink-0"
|
||||
|
|
@ -216,9 +240,34 @@ export default function NotificationSettings() {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
<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" />
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue