Compare commits

..

1 Commits

Author SHA1 Message Date
“kirukib”
ce34b35b7e Profile + Notification Page Updates 2026-03-11 11:44:03 +03:00
8 changed files with 448 additions and 377 deletions

2
.env
View File

@ -1,3 +1,3 @@
# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1 # VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1
VITE_API_BASE_URL=http://localhost:8432/api/v1 VITE_API_BASE_URL= https://api.yimaruacademy.com/api/v1
VITE_GOOGLE_CLIENT_ID= VITE_GOOGLE_CLIENT_ID=

View File

@ -17,6 +17,7 @@ import { AddVideoPage } from "../pages/content-management/AddVideoPage"
import { AddPracticePage } from "../pages/content-management/AddPracticePage" import { AddPracticePage } from "../pages/content-management/AddPracticePage"
import { NotFoundPage } from "../pages/NotFoundPage" import { NotFoundPage } from "../pages/NotFoundPage"
import { NotificationsPage } from "../pages/notifications/NotificationsPage" import { NotificationsPage } from "../pages/notifications/NotificationsPage"
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage"
import { UserDetailPage } from "../pages/user-management/UserDetailPage" import { UserDetailPage } from "../pages/user-management/UserDetailPage"
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout" import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"
import { UsersListPage } from "../pages/user-management/UsersListPage" import { UsersListPage } from "../pages/user-management/UsersListPage"
@ -85,6 +86,7 @@ export function AppRoutes() {
</Route> </Route>
<Route path="/notifications" element={<NotificationsPage />} /> <Route path="/notifications" element={<NotificationsPage />} />
<Route path="/notifications/create" element={<CreateNotificationPage />} />
<Route path="/user-log" element={<UserLogPage />} /> <Route path="/user-log" element={<UserLogPage />} />
<Route path="/issues" element={<IssuesPage />} /> <Route path="/issues" element={<IssuesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />

View File

@ -76,7 +76,6 @@ function NotificationItem({
type="button" type="button"
className={cn( className={cn(
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100", "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={() => { onClick={() => {
if (!notification.is_read) onMarkRead(notification.id) if (!notification.is_read) onMarkRead(notification.id)
@ -84,13 +83,13 @@ function NotificationItem({
> >
{/* Unread dot */} {/* Unread dot */}
{!notification.is_read && ( {!notification.is_read && (
<span className="absolute left-1 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" /> <span className="absolute left-0.5 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
)} )}
{/* Type icon */} {/* Type icon */}
<span <span
className={cn( className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-lg", "ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
cfg.bg cfg.bg
)} )}
> >
@ -101,16 +100,16 @@ function NotificationItem({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p <p
className={cn( className={cn(
"text-sm leading-snug text-grayScale-800", "text-sm leading-snug text-grayScale-900",
!notification.is_read && "font-semibold" !notification.is_read && "font-semibold"
)} )}
> >
{getNotificationTitle(notification)} {getNotificationTitle(notification) || "Notification"}
</p> </p>
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500"> <p className="mt-0.5 line-clamp-2 text-xs text-grayScale-700">
{getNotificationMessage(notification)} {getNotificationMessage(notification) || "No preview text available."}
</p> </p>
<p className="mt-1 text-[11px] text-grayScale-400"> <p className="mt-1 text-[11px] text-grayScale-600">
{formatTimestamp(notification.timestamp)} {formatTimestamp(notification.timestamp)}
</p> </p>
</div> </div>
@ -170,14 +169,11 @@ export function NotificationDropdown() {
{/* Bell button */} {/* Bell button */}
<button <button
type="button" type="button"
className="relative inline-flex h-10 items-center gap-2 rounded-full border bg-white px-3 text-grayScale-500 transition-colors hover:text-brand-600" className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
aria-label="Notifications" aria-label="Notifications"
onClick={() => setOpen((prev) => !prev)} onClick={() => setOpen((prev) => !prev)}
> >
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
<span className="hidden text-xs font-medium text-grayScale-600 sm:inline">
Notifications
</span>
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white"> <span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount} {unreadCount > 99 ? "99+" : unreadCount}

View File

@ -687,84 +687,6 @@ export function ProfilePage() {
</Card> </Card>
</div> </div>
</div> </div>
{/* ─── Learning & Goals Card ─── */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
<div className="h-1 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
<div className="border-b border-grayScale-100 px-5 py-3">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Learning & Preferences
</p>
</div>
<CardContent className="p-0">
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-4 lg:divide-x lg:divide-y-0">
<div className="p-5">
<DetailItem
icon={Target}
label="Learning Goal"
value={profile.learning_goal || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.learning_goal ?? ""}
onChange={(e) => updateField("learning_goal", e.target.value)}
placeholder="Your learning goal"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={Languages}
label="Language Goal"
value={profile.language_goal || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.language_goal ?? ""}
onChange={(e) => updateField("language_goal", e.target.value)}
placeholder="Language goal"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={MessageCircle}
label="Language Challenge"
value={profile.language_challange || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.language_challange ?? ""}
onChange={(e) => updateField("language_challange", e.target.value)}
placeholder="Language challenge"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={Heart}
label="Favourite Topic"
value={profile.favoutite_topic || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.favoutite_topic ?? ""}
onChange={(e) => updateField("favoutite_topic", e.target.value)}
placeholder="Favourite topic"
/>
}
/>
</div>
</div>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@ -113,15 +113,20 @@ function SortableChip({
ref={setNodeRef} ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }} style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn( className={cn(
"flex min-w-[180px] items-center gap-2 rounded-xl border bg-white px-3 py-2 shadow-sm", "flex min-w-[180px] items-center gap-2 rounded-xl border px-3 py-2 shadow-sm",
active ? "border-brand-300 bg-brand-50" : "border-grayScale-200", active
? "border-brand-500 bg-brand-500/90 text-white"
: "border-grayScale-200 bg-white text-grayScale-700",
isDragging && "opacity-60 ring-2 ring-brand-300", isDragging && "opacity-60 ring-2 ring-brand-300",
className, className,
)} )}
> >
<button <button
type="button" type="button"
className="grid h-6 w-6 shrink-0 place-items-center rounded-md text-grayScale-300 hover:bg-grayScale-100 hover:text-grayScale-500" className={cn(
"grid h-6 w-6 shrink-0 place-items-center rounded-md text-grayScale-300 hover:bg-grayScale-100 hover:text-grayScale-500",
active && "text-white/80 hover:bg-white/10 hover:text-white",
)}
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
@ -130,7 +135,7 @@ function SortableChip({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="min-w-0 flex-1 truncate text-left text-sm font-semibold text-grayScale-700" className="min-w-0 flex-1 truncate text-left text-sm font-semibold"
> >
{label} {label}
</button> </button>

View File

@ -115,7 +115,7 @@ function getIssueTypeConfig(type: string): {
case "course": case "course":
return { return {
label: "Course", label: "Course",
classes: "bg-brand-50 text-brand-700 border-brand-200", classes: "bg-brand-500 text-white border-brand-500",
icon: BookOpen, icon: BookOpen,
}; };
case "account": case "account":

View File

@ -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<Array<"push" | "sms">>(["push"])
const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
const [teamRecipients, setTeamRecipients] = useState<TeamMember[]>([])
const [recipientsLoading, setRecipientsLoading] = useState(false)
const [selectedRecipientIds, setSelectedRecipientIds] = useState<number[]>([])
const [composeTitle, setComposeTitle] = useState("")
const [composeMessage, setComposeMessage] = useState("")
const [sending, setSending] = useState(false)
const [, setComposeImage] = useState<File | null>(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 (
<div className="mx-auto w-full max-w-5xl space-y-5">
{/* Breadcrumb + Header */}
<div className="space-y-2">
<nav className="flex items-center gap-1 text-xs text-grayScale-400">
<button
type="button"
className="hover:text-grayScale-600"
onClick={() => navigate("/dashboard")}
>
Dashboard
</button>
<span>/</span>
<button
type="button"
className="hover:text-grayScale-600"
onClick={() => navigate("/notifications")}
>
Notifications
</button>
<span>/</span>
<span className="text-grayScale-500">Create</span>
</nav>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-grayScale-400">
Notifications
</p>
<h1 className="mt-1 flex items-center gap-2 text-2xl font-semibold tracking-tight text-grayScale-700">
Create notification
<span className="inline-flex h-7 items-center gap-1 rounded-full bg-brand-500/90 px-2 text-[11px] font-medium text-white">
<Megaphone className="h-3.5 w-3.5" />
Composer
</span>
</h1>
<p className="mt-1 text-xs text-grayScale-400">
Send a one-off push or SMS notification to your users.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => navigate("/notifications")}
>
Back to notifications
</Button>
</div>
</div>
<form
onSubmit={handleSubmit}
className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1.1fr)]"
>
{/* Left: message setup */}
<div className="space-y-4">
<Card className="shadow-none border border-grayScale-100">
<CardContent className="space-y-4 p-4">
{/* Channel & audience */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Channel
</p>
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() =>
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",
)}
>
<Bell className="h-3.5 w-3.5" />
Push
</button>
<button
type="button"
onClick={() =>
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",
)}
>
<Mail className="h-3.5 w-3.5" />
SMS
</button>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Audience
</p>
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
</div>
{/* Title & message */}
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Title
</label>
<Input
placeholder="Short headline for this notification"
value={composeTitle}
onChange={(e) => setComposeTitle(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Message
</label>
<Textarea
rows={4}
placeholder={
composeChannels.includes("sms") && !composeChannels.includes("push")
? "Concise SMS body. Keep it clear and under 160 characters where possible."
: "Notification body shown inside the app."
}
value={composeMessage}
onChange={(e) => setComposeMessage(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
{/* Image upload */}
<Card className="shadow-none border border-grayScale-100">
<CardContent className="space-y-2 p-4">
<p className="mb-1 block text-xs font-medium text-grayScale-500">
Image (push only)
</p>
<FileUpload
accept="image/*"
onFileSelect={setComposeImage}
label="Upload notification image"
description="Shown with push notification where supported"
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<p className="text-[10px] text-grayScale-400">
Image will be ignored for SMS-only sends. Connect your push provider to attach it to
real notifications.
</p>
</CardContent>
</Card>
{/* Footer actions */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
<p className="text-[11px] text-grayScale-400">
This is a UI-only preview. Hook into your notification API to deliver messages.
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setComposeTitle("")
setComposeMessage("")
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeImage(null)
}}
>
Clear
</Button>
<Button
type="submit"
size="sm"
disabled={sending || !composeTitle.trim() || !composeMessage.trim()}
>
{sending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<MailOpen className="mr-2 h-3.5 w-3.5" />
Send notification
</>
)}
</Button>
</div>
</div>
</div>
{/* Right: audience & preview */}
<div className="space-y-4">
<Card className="shadow-none border border-grayScale-100">
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-grayScale-600">Audience & channels</p>
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-100 px-2 py-0.5 text-[10px] font-medium text-grayScale-500">
{composeChannels.join(" + ").toUpperCase() || "—"}
</span>
</div>
<div className="space-y-1 text-[11px] text-grayScale-500">
<p>
<span className="font-semibold text-grayScale-600">Audience:</span>{" "}
{composeAudience === "all"
? "All users"
: selectedRecipients.length === 0
? "No users selected yet"
: `${selectedRecipients.length} selected user${
selectedRecipients.length === 1 ? "" : "s"
}`}
</p>
<p>
<span className="font-semibold text-grayScale-600">Channels:</span>{" "}
{composeChannels.length === 0
? "None selected"
: composeChannels.map((c) => c.toUpperCase()).join(" + ")}
</p>
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-grayScale-100">
<CardContent className="space-y-2 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-grayScale-600">Selected users</p>
{composeAudience === "selected" && (
<span className="text-[10px] text-grayScale-400">
{selectedRecipients.length} selected
</span>
)}
</div>
{composeAudience === "all" ? (
<p className="text-[11px] text-grayScale-400">
All eligible users will receive this notification. Switch to{" "}
<span className="font-semibold text-grayScale-600">Selected users</span> to target
specific people.
</p>
) : (
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading users
</div>
)}
{!recipientsLoading && teamRecipients.length === 0 && (
<div className="py-4 text-center text-xs text-grayScale-400">
No users available to select.
</div>
)}
{!recipientsLoading &&
teamRecipients.map((member) => {
const checked = selectedRecipientIds.includes(member.id)
return (
<label
key={member.id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs",
checked ? "bg-brand-50 text-brand-700" : "hover:bg-grayScale-100",
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5 rounded border-grayScale-300"
checked={checked}
onChange={(e) => {
setSelectedRecipientIds((prev) =>
e.target.checked
? [...prev, member.id]
: prev.filter((id) => id !== member.id),
)
}}
/>
<span className="truncate">
{member.first_name} {member.last_name}
<span className="ml-1 text-[10px] text-grayScale-400">
· {member.email}
</span>
</span>
</label>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Mobile preview card */}
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/40">
<CardContent className="space-y-2 p-4">
<p className="text-xs font-semibold text-grayScale-600">Preview</p>
<div className="space-y-1 rounded-xl border border-grayScale-200 bg-white p-3 text-xs">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-brand-500/90 text-white">
<Bell className="h-3.5 w-3.5" />
</span>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-grayScale-800">
{composeTitle || "Notification title"}
</p>
<p className="truncate text-[11px] text-grayScale-500">
{composeMessage || "Message preview will appear here."}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</form>
</div>
)
}

View File

@ -51,6 +51,7 @@ import {
} from "../../components/ui/dropdown-menu" } from "../../components/ui/dropdown-menu"
import { FileUpload } from "../../components/ui/file-upload" import { FileUpload } from "../../components/ui/file-upload"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { useNavigate } from "react-router-dom"
import { import {
getNotifications, getNotifications,
getUnreadCount, getUnreadCount,
@ -259,6 +260,7 @@ function NotificationItem({
} }
export function NotificationsPage() { export function NotificationsPage() {
const navigate = useNavigate()
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
const [totalCount, setTotalCount] = useState(0) const [totalCount, setTotalCount] = useState(0)
const [globalUnread, setGlobalUnread] = useState(0) const [globalUnread, setGlobalUnread] = useState(0)
@ -276,17 +278,6 @@ export function NotificationsPage() {
const [typeFilter, setTypeFilter] = useState<"all" | string>("all") const [typeFilter, setTypeFilter] = useState<"all" | string>("all")
const [levelFilter, setLevelFilter] = useState<"all" | string>("all") const [levelFilter, setLevelFilter] = useState<"all" | string>("all")
const [composeChannels, setComposeChannels] = useState<Array<"push" | "sms">>(["push"])
const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
const [teamRecipients, setTeamRecipients] = useState<TeamMember[]>([])
const [recipientsLoading, setRecipientsLoading] = useState(false)
const [selectedRecipientIds, setSelectedRecipientIds] = useState<number[]>([])
const [composeTitle, setComposeTitle] = useState("")
const [composeMessage, setComposeMessage] = useState("")
const [sending, setSending] = useState(false)
const [composeOpen, setComposeOpen] = useState(false)
const [composeImage, setComposeImage] = useState<File | null>(null)
const [bulkOpen, setBulkOpen] = useState(false) const [bulkOpen, setBulkOpen] = useState(false)
const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms") const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms")
const [bulkTitle, setBulkTitle] = useState("") const [bulkTitle, setBulkTitle] = useState("")
@ -535,45 +526,8 @@ export function NotificationsPage() {
setDetailOpen(true) 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 ( return (
<div className="mx-auto w-full max-w-6xl"> <div className="mx-auto w-full max-w-6xl bg-grayScale-50/60 rounded-2xl px-3 py-4 sm:px-4 sm:py-5">
{/* Header */} {/* Header */}
<div className="mb-5"> <div className="mb-5">
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div> <div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
@ -590,7 +544,7 @@ export function NotificationsPage() {
<Button <Button
size="sm" size="sm"
className="bg-brand-500 text-white hover:bg-brand-600" className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setBulkOpen(true)} onClick={() => navigate("/notifications/create")}
> >
<Mail className="mr-2 h-3.5 w-3.5" /> <Mail className="mr-2 h-3.5 w-3.5" />
Send notification Send notification
@ -830,7 +784,7 @@ export function NotificationsPage() {
key={n.id} key={n.id}
className={cn( className={cn(
"cursor-pointer", "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)} onClick={() => handleOpenDetail(n)}
> >
@ -1016,236 +970,6 @@ export function NotificationsPage() {
</Dialog> </Dialog>
)} )}
{/* Compose dialog */}
<Dialog open={composeOpen} onOpenChange={setComposeOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-brand-500" />
<span>Create notification</span>
</DialogTitle>
<DialogDescription>
Send a one-off push or SMS notification to your users.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleComposeSubmit} className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Channel
</p>
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() =>
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",
)}
>
<Bell className="h-3.5 w-3.5" />
Push
</button>
<button
type="button"
onClick={() =>
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",
)}
>
<Mail className="h-3.5 w-3.5" />
SMS
</button>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Audience
</p>
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1.2fr)]">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">Title</label>
<Input
placeholder="Short headline for this notification"
value={composeTitle}
onChange={(e) => setComposeTitle(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Message
</label>
<Textarea
rows={3}
placeholder={
composeChannels.includes("sms") && !composeChannels.includes("push")
? "Concise SMS body. Keep it clear and under 160 characters where possible."
: "Notification body shown inside the app."
}
value={composeMessage}
onChange={(e) => setComposeMessage(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<p className="mb-1 block text-xs font-medium text-grayScale-500">
Image (push only)
</p>
<FileUpload
accept="image/*"
onFileSelect={setComposeImage}
label="Upload notification image"
description="Shown with push notification where supported"
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<p className="text-[10px] text-grayScale-400">
Image will be ignored for SMS-only sends. Connect your push provider to attach it
to real notifications.
</p>
</div>
</div>
{composeAudience === "selected" && (
<div className="space-y-2">
<p className="text-xs font-medium text-grayScale-500">Recipients</p>
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading users
</div>
)}
{!recipientsLoading && teamRecipients.length === 0 && (
<div className="py-4 text-center text-xs text-grayScale-400">
No users available to select.
</div>
)}
{!recipientsLoading &&
teamRecipients.map((member) => (
<label
key={member.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-grayScale-100"
>
<input
type="checkbox"
className="h-3.5 w-3.5 rounded border-grayScale-300"
checked={selectedRecipientIds.includes(member.id)}
onChange={(e) => {
setSelectedRecipientIds((prev) =>
e.target.checked
? [...prev, member.id]
: prev.filter((id) => id !== member.id),
)
}}
/>
<span className="truncate">
{member.first_name} {member.last_name}
<span className="ml-1 text-[10px] text-grayScale-400">
· {member.email}
</span>
</span>
</label>
))}
</div>
<p className="text-[11px] text-grayScale-400">
Only the selected users will receive this notification.
</p>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
<p className="text-[11px] text-grayScale-400">
This is a UI-only preview. Hook into your notification API to deliver messages.
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setComposeTitle("")
setComposeMessage("")
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeImage(null)
}}
>
Clear
</Button>
<Button
type="submit"
size="sm"
disabled={sending || !composeTitle.trim() || !composeMessage.trim()}
>
{sending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<MailOpen className="mr-2 h-3.5 w-3.5" />
Send notification
</>
)}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
{/* Bulk send dialog */} {/* Bulk send dialog */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}> <Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">