|
| 1 | +<script setup lang="ts"> |
| 2 | +import { computed } from "vue"; |
| 3 | +import { useRouter } from "vue-router"; |
| 4 | +import { useQuery } from "@pinia/colada"; |
| 5 | +import { getMyNotifications } from "@/api/notifications"; |
| 6 | +import { getSpeakerById } from "@/api/speakers"; |
| 7 | +import { getCompanyById } from "@/api/companies"; |
| 8 | +import { |
| 9 | + useDeleteNotificationMutation, |
| 10 | + useDeleteAllNotificationsMutation, |
| 11 | +} from "@/mutations/notifications"; |
| 12 | +import type { EnrichedActor, EnrichedNotification } from "@/dto/notifications"; |
| 13 | +import { Bell, Trash } from "lucide-vue-next"; |
| 14 | +import { |
| 15 | + Popover, |
| 16 | + PopoverContent, |
| 17 | + PopoverTrigger, |
| 18 | +} from "@/components/ui/popover"; |
| 19 | +import Badge from "../ui/badge/Badge.vue"; |
| 20 | +import Image from "../Image.vue"; |
| 21 | +
|
| 22 | +const makeMessage = (kind: string, actor: EnrichedActor) => { |
| 23 | + const actorName = actor.name; |
| 24 | + switch (kind) { |
| 25 | + case "UPDATED_PARTICIPATION": |
| 26 | + return "Participation updated"; |
| 27 | + case "UPDATED_PARTICIPATION_STATUS": |
| 28 | + return "Participation status changed"; |
| 29 | + case "CREATED_PARTICIPATION": |
| 30 | + return "New participation"; |
| 31 | + case "DELETED_PARTICIPATION": |
| 32 | + return "Participation removed"; |
| 33 | + case "UPLOADED_MEETING_MINUTE": |
| 34 | + return "Meeting minute uploaded"; |
| 35 | + case "DELETED_MEETING_MINUTE": |
| 36 | + return "Meeting minute deleted"; |
| 37 | + case "UPDATED_PRIVATE_IMAGE": |
| 38 | + return "Private image updated"; |
| 39 | + case "UPDATED": |
| 40 | + return "Updated details"; |
| 41 | + case "CREATED": |
| 42 | + return "Created"; |
| 43 | + case "DELETED": |
| 44 | + return actorName ? `${actorName} deleted` : "Deleted"; |
| 45 | + case "TAGGED": |
| 46 | + return "You were tagged in a post"; |
| 47 | + default: |
| 48 | + return actorName ? `${kind} - ${actorName}` : kind; |
| 49 | + } |
| 50 | +}; |
| 51 | +
|
| 52 | +const fetchEnrichedNotifications = async () => { |
| 53 | + const res = await getMyNotifications(); |
| 54 | + const data = await Promise.all( |
| 55 | + (res.data || []).map(async (n) => { |
| 56 | + const enriched: EnrichedNotification = { ...n }; |
| 57 | + try { |
| 58 | + const speakerId = n.speaker; |
| 59 | + const companyId = n.company; |
| 60 | +
|
| 61 | + if (speakerId) { |
| 62 | + const spRes = await getSpeakerById(speakerId); |
| 63 | + const sp = spRes?.data || spRes; |
| 64 | + if (sp) { |
| 65 | + enriched.actor = { |
| 66 | + type: "speaker", |
| 67 | + id: sp.id, |
| 68 | + name: sp.name, |
| 69 | + avatar: sp.imgs?.speaker || sp.imgs?.internal || undefined, |
| 70 | + }; |
| 71 | + enriched.message = makeMessage(n.kind, enriched.actor); |
| 72 | + } |
| 73 | + } else if (companyId) { |
| 74 | + const coRes = await getCompanyById(companyId); |
| 75 | + const co = coRes?.data || coRes; |
| 76 | + if (co) { |
| 77 | + enriched.actor = { |
| 78 | + type: "company", |
| 79 | + id: co.id, |
| 80 | + name: co.name, |
| 81 | + avatar: co.imgs?.public || co.imgs?.internal || undefined, |
| 82 | + }; |
| 83 | + enriched.message = makeMessage(n.kind, enriched.actor); |
| 84 | + } |
| 85 | + } |
| 86 | + } catch { |
| 87 | + // ignore enrichment errors |
| 88 | + } |
| 89 | + if (!enriched.message && enriched.actor) |
| 90 | + enriched.message = makeMessage(n.kind, enriched.actor); |
| 91 | + return enriched; |
| 92 | + }), |
| 93 | + ); |
| 94 | + return { ...res, data }; |
| 95 | +}; |
| 96 | +
|
| 97 | +const { data: notifications } = useQuery({ |
| 98 | + key: ["notifications"], |
| 99 | + query: fetchEnrichedNotifications, |
| 100 | +}); |
| 101 | +
|
| 102 | +const notificationItems = computed(() => { |
| 103 | + const items = (notifications.value?.data as EnrichedNotification[]) || []; |
| 104 | + // sort newest first — try common timestamp fields (date, createdAt, created_at) |
| 105 | + return items.slice().sort((a, b) => b.date?.localeCompare(a.date ?? "") || 0); |
| 106 | +}); |
| 107 | +
|
| 108 | +const _deleteNotificationMutation = useDeleteNotificationMutation(); |
| 109 | +const _deleteAllNotificationsMutation = useDeleteAllNotificationsMutation(); |
| 110 | +
|
| 111 | +const removeNotification = async (id: string) => { |
| 112 | + await _deleteNotificationMutation.mutate(id); |
| 113 | +}; |
| 114 | +
|
| 115 | +const removeAllNotifications = async () => { |
| 116 | + const ids = notificationItems.value.map((i) => i.id); |
| 117 | + if (!ids.length) return; |
| 118 | + await _deleteAllNotificationsMutation.mutate(); |
| 119 | +}; |
| 120 | +
|
| 121 | +const router = useRouter(); |
| 122 | +const navigateNotification = (n: EnrichedNotification) => { |
| 123 | + const actor = n.actor; |
| 124 | + if (actor?.type === "speaker" && actor?.id) { |
| 125 | + router.push({ name: "speaker", params: { speakerId: actor.id } }); |
| 126 | + return; |
| 127 | + } |
| 128 | + if (actor?.type === "company" && actor?.id) { |
| 129 | + router.push({ name: "company", params: { companyId: actor.id } }); |
| 130 | + return; |
| 131 | + } |
| 132 | +}; |
| 133 | +
|
| 134 | +const onNotificationClick = async (n: EnrichedNotification) => { |
| 135 | + if (!n || !n.id) return; |
| 136 | + try { |
| 137 | + await removeNotification(n.id); |
| 138 | + } catch { |
| 139 | + // ignore |
| 140 | + } |
| 141 | + navigateNotification(n); |
| 142 | +}; |
| 143 | +</script> |
| 144 | + |
| 145 | +<template> |
| 146 | + <Popover> |
| 147 | + <PopoverTrigger> |
| 148 | + <button |
| 149 | + class="p-2 rounded hover:bg-gray-100 relative" |
| 150 | + :title="'Notifications'" |
| 151 | + > |
| 152 | + <Bell class="h-5 w-5 text-gray-600" /> |
| 153 | + <Badge |
| 154 | + v-if="notificationItems.length" |
| 155 | + variant="destructive" |
| 156 | + class="absolute -top-2 -right-1 text-xs" |
| 157 | + > |
| 158 | + {{ notificationItems.length }} |
| 159 | + </Badge> |
| 160 | + </button> |
| 161 | + </PopoverTrigger> |
| 162 | + |
| 163 | + <PopoverContent class="w-80 p-0"> |
| 164 | + <div class="p-2 flex items-center justify-between border-b"> |
| 165 | + <h4 class="font-semibold">Notifications</h4> |
| 166 | + <button |
| 167 | + v-if="notificationItems.length" |
| 168 | + class="text-sm text-blue-600" |
| 169 | + @click="removeAllNotifications()" |
| 170 | + > |
| 171 | + Read all |
| 172 | + </button> |
| 173 | + </div> |
| 174 | + |
| 175 | + <div class="max-h-60 overflow-auto"> |
| 176 | + <div v-if="!notificationItems.length" class="p-4 text-sm text-gray-500"> |
| 177 | + No notifications |
| 178 | + </div> |
| 179 | + |
| 180 | + <ul> |
| 181 | + <li |
| 182 | + v-for="n in notificationItems" |
| 183 | + :key="n.id" |
| 184 | + class="flex items-center justify-between px-3 py-2 hover:bg-gray-50 cursor-pointer" |
| 185 | + @click="onNotificationClick(n)" |
| 186 | + > |
| 187 | + <div class="flex items-center gap-3"> |
| 188 | + <Image |
| 189 | + v-if="n.actor && n.actor.avatar" |
| 190 | + :src="n.actor.avatar" |
| 191 | + alt="actor" |
| 192 | + class="h-8 w-8 rounded-full object-cover" |
| 193 | + /> |
| 194 | + <div class="text-sm"> |
| 195 | + <div class="font-medium">{{ n.message || n.kind }}</div> |
| 196 | + <div class="text-xs text-gray-500"> |
| 197 | + {{ n.actor?.name || n.date }} |
| 198 | + </div> |
| 199 | + </div> |
| 200 | + </div> |
| 201 | + <div> |
| 202 | + <button |
| 203 | + class="text-red-500 text-sm" |
| 204 | + @click.stop.prevent="removeNotification(n.id)" |
| 205 | + > |
| 206 | + <Trash :size="16" /> |
| 207 | + </button> |
| 208 | + </div> |
| 209 | + </li> |
| 210 | + </ul> |
| 211 | + </div> |
| 212 | + </PopoverContent> |
| 213 | + </Popover> |
| 214 | +</template> |
0 commit comments