diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index f4468e0..1b2515d 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -17,6 +17,7 @@ import { AddVideoPage } from "../pages/content-management/AddVideoPage" import { AddPracticePage } from "../pages/content-management/AddPracticePage" import { NotFoundPage } from "../pages/NotFoundPage" import { NotificationsPage } from "../pages/notifications/NotificationsPage" +import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage" import { UserDetailPage } from "../pages/user-management/UserDetailPage" import { UserManagementLayout } from "../pages/user-management/UserManagementLayout" import { UsersListPage } from "../pages/user-management/UsersListPage" @@ -93,6 +94,7 @@ export function AppRoutes() { } /> + } /> } /> } /> } /> diff --git a/src/components/topbar/NotificationDropdown.tsx b/src/components/topbar/NotificationDropdown.tsx index 4898a68..64eacbd 100644 --- a/src/components/topbar/NotificationDropdown.tsx +++ b/src/components/topbar/NotificationDropdown.tsx @@ -76,7 +76,6 @@ function NotificationItem({ type="button" className={cn( "group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100", - !notification.is_read && "bg-brand-100/30" )} onClick={() => { if (!notification.is_read) onMarkRead(notification.id) @@ -84,13 +83,13 @@ function NotificationItem({ > {/* Unread dot */} {!notification.is_read && ( - + )} {/* Type icon */} @@ -101,16 +100,16 @@ function NotificationItem({ - {getNotificationTitle(notification)} + {getNotificationTitle(notification) || "Notification"} - - {getNotificationMessage(notification)} + + {getNotificationMessage(notification) || "No preview text available."} - + {formatTimestamp(notification.timestamp)} @@ -170,14 +169,11 @@ export function NotificationDropdown() { {/* Bell button */} setOpen((prev) => !prev)} > - - Notifications - {unreadCount > 0 && ( {unreadCount > 99 ? "99+" : unreadCount} diff --git a/src/pages/issues/IssuesPage.tsx b/src/pages/issues/IssuesPage.tsx index 445582f..ebe06c2 100644 --- a/src/pages/issues/IssuesPage.tsx +++ b/src/pages/issues/IssuesPage.tsx @@ -116,7 +116,7 @@ function getIssueTypeConfig(type: string): { case "course": return { label: "Course", - classes: "bg-brand-50 text-brand-700 border-brand-200", + classes: "bg-brand-500 text-white border-brand-500", icon: BookOpen, }; case "account": diff --git a/src/pages/notifications/CreateNotificationPage.tsx b/src/pages/notifications/CreateNotificationPage.tsx new file mode 100644 index 0000000..1206264 --- /dev/null +++ b/src/pages/notifications/CreateNotificationPage.tsx @@ -0,0 +1,422 @@ +import { useEffect, useMemo, useState } from "react" +import { useNavigate } from "react-router-dom" +import { Bell, Loader2, Mail, MailOpen, Megaphone } from "lucide-react" +import { Card, CardContent } from "../../components/ui/card" +import { Button } from "../../components/ui/button" +import { Input } from "../../components/ui/input" +import { Textarea } from "../../components/ui/textarea" +import { FileUpload } from "../../components/ui/file-upload" +import { cn } from "../../lib/utils" +import { getTeamMembers } from "../../api/team.api" +import type { TeamMember } from "../../types/team.types" + +export function CreateNotificationPage() { + const navigate = useNavigate() + + const [composeChannels, setComposeChannels] = useState>(["push"]) + const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all") + const [teamRecipients, setTeamRecipients] = useState([]) + const [recipientsLoading, setRecipientsLoading] = useState(false) + const [selectedRecipientIds, setSelectedRecipientIds] = useState([]) + const [composeTitle, setComposeTitle] = useState("") + const [composeMessage, setComposeMessage] = useState("") + const [sending, setSending] = useState(false) + const [, setComposeImage] = useState(null) + + useEffect(() => { + setRecipientsLoading(true) + getTeamMembers(1, 50) + .then((res) => { + setTeamRecipients(res.data.data ?? []) + }) + .catch(() => { + setTeamRecipients([]) + }) + .finally(() => { + setRecipientsLoading(false) + }) + }, []) + + const selectedRecipients = useMemo( + () => teamRecipients.filter((m) => selectedRecipientIds.includes(m.id)), + [teamRecipients, selectedRecipientIds], + ) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!composeTitle.trim() || !composeMessage.trim()) return + if (composeChannels.length === 0) return + setSending(true) + try { + // Hook up to backend send API here when available. + await new Promise((resolve) => setTimeout(resolve, 400)) + setComposeTitle("") + setComposeMessage("") + setComposeAudience("all") + setComposeChannels(["push"]) + setSelectedRecipientIds([]) + setComposeImage(null) + navigate("/notifications") + } finally { + setSending(false) + } + } + + return ( + + {/* Breadcrumb + Header */} + + + navigate("/dashboard")} + > + Dashboard + + / + navigate("/notifications")} + > + Notifications + + / + Create + + + + + + Notifications + + + Create notification + + + Composer + + + + Send a one-off push or SMS notification to your users. + + + navigate("/notifications")} + > + Back to notifications + + + + + + {/* Left: message setup */} + + + + {/* Channel & audience */} + + + + Channel + + + + setComposeChannels((prev) => + prev.includes("push") + ? prev.filter((c) => c !== "push") + : [...prev, "push"], + ) + } + className={cn( + "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors", + composeChannels.includes("push") + ? "bg-brand-500 text-white shadow-sm" + : "text-grayScale-500 hover:text-grayScale-700", + )} + > + + Push + + + setComposeChannels((prev) => + prev.includes("sms") + ? prev.filter((c) => c !== "sms") + : [...prev, "sms"], + ) + } + className={cn( + "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors", + composeChannels.includes("sms") + ? "bg-brand-500 text-white shadow-sm" + : "text-grayScale-500 hover:text-grayScale-700", + )} + > + + SMS + + + + + + + Audience + + + setComposeAudience("all")} + className={cn( + "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors", + composeAudience === "all" + ? "bg-brand-500 text-white shadow-sm" + : "text-grayScale-500 hover:text-grayScale-700", + )} + > + All users + + setComposeAudience("selected")} + className={cn( + "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors", + composeAudience === "selected" + ? "bg-brand-500 text-white shadow-sm" + : "text-grayScale-500 hover:text-grayScale-700", + )} + > + Selected users + + + + + + {/* Title & message */} + + + + Title + + setComposeTitle(e.target.value)} + /> + + + + Message + + setComposeMessage(e.target.value)} + /> + + + + + + {/* Image upload */} + + + + Image (push only) + + + + Image will be ignored for SMS-only sends. Connect your push provider to attach it to + real notifications. + + + + + {/* Footer actions */} + + + This is a UI-only preview. Hook into your notification API to deliver messages. + + + { + setComposeTitle("") + setComposeMessage("") + setComposeAudience("all") + setComposeChannels(["push"]) + setSelectedRecipientIds([]) + setComposeImage(null) + }} + > + Clear + + + {sending ? ( + <> + + Sending… + > + ) : ( + <> + + Send notification + > + )} + + + + + + {/* Right: audience & preview */} + + + + + Audience & channels + + {composeChannels.join(" + ").toUpperCase() || "—"} + + + + + Audience:{" "} + {composeAudience === "all" + ? "All users" + : selectedRecipients.length === 0 + ? "No users selected yet" + : `${selectedRecipients.length} selected user${ + selectedRecipients.length === 1 ? "" : "s" + }`} + + + Channels:{" "} + {composeChannels.length === 0 + ? "None selected" + : composeChannels.map((c) => c.toUpperCase()).join(" + ")} + + + + + + + + + Selected users + {composeAudience === "selected" && ( + + {selectedRecipients.length} selected + + )} + + {composeAudience === "all" ? ( + + All eligible users will receive this notification. Switch to{" "} + Selected users to target + specific people. + + ) : ( + + {recipientsLoading && ( + + + Loading users… + + )} + {!recipientsLoading && teamRecipients.length === 0 && ( + + No users available to select. + + )} + {!recipientsLoading && + teamRecipients.map((member) => { + const checked = selectedRecipientIds.includes(member.id) + return ( + + { + setSelectedRecipientIds((prev) => + e.target.checked + ? [...prev, member.id] + : prev.filter((id) => id !== member.id), + ) + }} + /> + + {member.first_name} {member.last_name} + + · {member.email} + + + + ) + })} + + )} + + + + {/* Mobile preview card */} + + + Preview + + + + + + + + {composeTitle || "Notification title"} + + + {composeMessage || "Message preview will appear here."} + + + + + + + + + + ) +} + diff --git a/src/pages/notifications/NotificationsPage.tsx b/src/pages/notifications/NotificationsPage.tsx index 5582961..d760987 100644 --- a/src/pages/notifications/NotificationsPage.tsx +++ b/src/pages/notifications/NotificationsPage.tsx @@ -51,6 +51,8 @@ import { import { FileUpload } from "../../components/ui/file-upload" import { cn } from "../../lib/utils" import { SpinnerIcon } from "../../components/ui/spinner-icon" +import { useNavigate } from "react-router-dom" +import { SpinnerIcon } from "../../components/ui/spinner-icon" import { getNotifications, getUnreadCount, @@ -259,6 +261,7 @@ function NotificationItem({ } export function NotificationsPage() { + const navigate = useNavigate() const [notifications, setNotifications] = useState([]) const [totalCount, setTotalCount] = useState(0) const [globalUnread, setGlobalUnread] = useState(0) @@ -276,17 +279,6 @@ export function NotificationsPage() { const [typeFilter, setTypeFilter] = useState<"all" | string>("all") const [levelFilter, setLevelFilter] = useState<"all" | string>("all") - const [composeChannels, setComposeChannels] = useState>(["push"]) - const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all") - const [teamRecipients, setTeamRecipients] = useState([]) - const [recipientsLoading, setRecipientsLoading] = useState(false) - const [selectedRecipientIds, setSelectedRecipientIds] = useState([]) - const [composeTitle, setComposeTitle] = useState("") - const [composeMessage, setComposeMessage] = useState("") - const [sending, setSending] = useState(false) - const [composeOpen, setComposeOpen] = useState(false) - const [composeImage, setComposeImage] = useState(null) - const [bulkOpen, setBulkOpen] = useState(false) const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms") const [bulkTitle, setBulkTitle] = useState("") @@ -551,45 +543,8 @@ export function NotificationsPage() { setDetailOpen(true) } - const handleComposeSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!composeTitle.trim() || !composeMessage.trim()) return - if (composeChannels.length === 0) return - setSending(true) - try { - // Hook up to backend send API here when available. - // For now, we just reset the form after a short delay for UI feedback. - await new Promise((resolve) => setTimeout(resolve, 400)) - setComposeTitle("") - setComposeMessage("") - setComposeAudience("all") - setComposeChannels(["push"]) - setSelectedRecipientIds([]) - setComposeImage(null) - setComposeOpen(false) - } finally { - setSending(false) - } - } - - // Lazy-load users for recipient selection when compose dialog first opens - useEffect(() => { - if (!composeOpen || teamRecipients.length > 0 || recipientsLoading) return - setRecipientsLoading(true) - getTeamMembers(1, 50) - .then((res) => { - setTeamRecipients(res.data.data ?? []) - }) - .catch(() => { - setTeamRecipients([]) - }) - .finally(() => { - setRecipientsLoading(false) - }) - }, [composeOpen, teamRecipients.length, recipientsLoading]) - return ( - + {/* Header */} Notifications @@ -606,7 +561,7 @@ export function NotificationsPage() { setBulkOpen(true)} + onClick={() => navigate("/notifications/create")} > Send notification @@ -884,7 +839,7 @@ export function NotificationsPage() { key={n.id} className={cn( "cursor-pointer", - !n.is_read && "bg-brand-50/40 hover:bg-brand-50/70", + !n.is_read && "bg-brand-50/10 hover:bg-brand-50/25", )} onClick={() => handleOpenDetail(n)} > @@ -1105,236 +1060,6 @@ export function NotificationsPage() { )} - {/* Compose dialog */} - - - - - - Create notification - - - Send a one-off push or SMS notification to your users. - - - - - - - - Channel - - - - setComposeChannels((prev) => - prev.includes("push") - ? prev.filter((c) => c !== "push") - : [...prev, "push"], - ) - } - className={cn( - "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors", - composeChannels.includes("push") - ? "bg-white text-brand-600 shadow-sm" - : "text-grayScale-500 hover:text-grayScale-700", - )} - > - - Push - - - setComposeChannels((prev) => - prev.includes("sms") - ? prev.filter((c) => c !== "sms") - : [...prev, "sms"], - ) - } - className={cn( - "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors", - composeChannels.includes("sms") - ? "bg-white text-brand-600 shadow-sm" - : "text-grayScale-500 hover:text-grayScale-700", - )} - > - - SMS - - - - - - - Audience - - - setComposeAudience("all")} - className={cn( - "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors", - composeAudience === "all" - ? "bg-white text-brand-600 shadow-sm" - : "text-grayScale-500 hover:text-grayScale-700", - )} - > - All users - - setComposeAudience("selected")} - className={cn( - "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors", - composeAudience === "selected" - ? "bg-white text-brand-600 shadow-sm" - : "text-grayScale-500 hover:text-grayScale-700", - )} - > - Selected users - - - - - - - - - Title - setComposeTitle(e.target.value)} - /> - - - - Message - - setComposeMessage(e.target.value)} - /> - - - - - - Image (push only) - - - - Image will be ignored for SMS-only sends. Connect your push provider to attach it - to real notifications. - - - - - {composeAudience === "selected" && ( - - Recipients - - {recipientsLoading && ( - - - Loading users… - - )} - {!recipientsLoading && teamRecipients.length === 0 && ( - - No users available to select. - - )} - {!recipientsLoading && - teamRecipients.map((member) => ( - - { - setSelectedRecipientIds((prev) => - e.target.checked - ? [...prev, member.id] - : prev.filter((id) => id !== member.id), - ) - }} - /> - - {member.first_name} {member.last_name} - - · {member.email} - - - - ))} - - - Only the selected users will receive this notification. - - - )} - - - - This is a UI-only preview. Hook into your notification API to deliver messages. - - - { - setComposeTitle("") - setComposeMessage("") - setComposeAudience("all") - setComposeChannels(["push"]) - setSelectedRecipientIds([]) - setComposeImage(null) - }} - > - Clear - - - {sending ? ( - <> - - Sending… - > - ) : ( - <> - - Send notification - > - )} - - - - - - - {/* Bulk send dialog */}
- {getNotificationTitle(notification)} + {getNotificationTitle(notification) || "Notification"}
- {getNotificationMessage(notification)} +
+ {getNotificationMessage(notification) || "No preview text available."}
+
{formatTimestamp(notification.timestamp)}
+ Notifications +
+ Send a one-off push or SMS notification to your users. +
+ Channel +
+ Audience +
+ Image (push only) +
+ Image will be ignored for SMS-only sends. Connect your push provider to attach it to + real notifications. +
+ This is a UI-only preview. Hook into your notification API to deliver messages. +
Audience & channels
+ Audience:{" "} + {composeAudience === "all" + ? "All users" + : selectedRecipients.length === 0 + ? "No users selected yet" + : `${selectedRecipients.length} selected user${ + selectedRecipients.length === 1 ? "" : "s" + }`} +
+ Channels:{" "} + {composeChannels.length === 0 + ? "None selected" + : composeChannels.map((c) => c.toUpperCase()).join(" + ")} +
Selected users
+ All eligible users will receive this notification. Switch to{" "} + Selected users to target + specific people. +
Preview
+ {composeTitle || "Notification title"} +
+ {composeMessage || "Message preview will appear here."} +
- Channel -
- Audience -
- Image (push only) -
- Image will be ignored for SMS-only sends. Connect your push provider to attach it - to real notifications. -
Recipients
- Only the selected users will receive this notification. -
- This is a UI-only preview. Hook into your notification API to deliver messages. -