Profile + Notification Page Updates
This commit is contained in:
parent
8180e64f59
commit
ce34b35b7e
2
.env
2
.env
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
422
src/pages/notifications/CreateNotificationPage.tsx
Normal file
422
src/pages/notifications/CreateNotificationPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user