Compare commits
No commits in common. "production" and "main" have entirely different histories.
production
...
main
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.yimaruacademy.com/api/v1
|
||||
VITE_API_BASE_URL=http://localhost:8432/api/v1
|
||||
VITE_GOOGLE_CLIENT_ID=
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ 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"
|
||||
|
|
@ -86,7 +85,6 @@ export function AppRoutes() {
|
|||
</Route>
|
||||
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/notifications/create" element={<CreateNotificationPage />} />
|
||||
<Route path="/user-log" element={<UserLogPage />} />
|
||||
<Route path="/issues" element={<IssuesPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ 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)
|
||||
|
|
@ -83,13 +84,13 @@ function NotificationItem({
|
|||
>
|
||||
{/* Unread dot */}
|
||||
{!notification.is_read && (
|
||||
<span className="absolute left-0.5 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
|
||||
<span className="absolute left-1 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
|
||||
)}
|
||||
|
||||
{/* Type icon */}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
|
||||
"grid h-9 w-9 shrink-0 place-items-center rounded-lg",
|
||||
cfg.bg
|
||||
)}
|
||||
>
|
||||
|
|
@ -100,16 +101,16 @@ function NotificationItem({
|
|||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm leading-snug text-grayScale-900",
|
||||
"text-sm leading-snug text-grayScale-800",
|
||||
!notification.is_read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{getNotificationTitle(notification) || "Notification"}
|
||||
{getNotificationTitle(notification)}
|
||||
</p>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-700">
|
||||
{getNotificationMessage(notification) || "No preview text available."}
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500">
|
||||
{getNotificationMessage(notification)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-grayScale-600">
|
||||
<p className="mt-1 text-[11px] text-grayScale-400">
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -169,11 +170,14 @@ export function NotificationDropdown() {
|
|||
{/* Bell button */}
|
||||
<button
|
||||
type="button"
|
||||
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
|
||||
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"
|
||||
aria-label="Notifications"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="hidden text-xs font-medium text-grayScale-600 sm:inline">
|
||||
Notifications
|
||||
</span>
|
||||
{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">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
|
|
|
|||
|
|
@ -687,6 +687,84 @@ export function ProfilePage() {
|
|||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,20 +113,15 @@ function SortableChip({
|
|||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn(
|
||||
"flex min-w-[180px] items-center gap-2 rounded-xl border px-3 py-2 shadow-sm",
|
||||
active
|
||||
? "border-brand-500 bg-brand-500/90 text-white"
|
||||
: "border-grayScale-200 bg-white text-grayScale-700",
|
||||
"flex min-w-[180px] items-center gap-2 rounded-xl border bg-white px-3 py-2 shadow-sm",
|
||||
active ? "border-brand-300 bg-brand-50" : "border-grayScale-200",
|
||||
isDragging && "opacity-60 ring-2 ring-brand-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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",
|
||||
)}
|
||||
className="grid h-6 w-6 shrink-0 place-items-center rounded-md text-grayScale-300 hover:bg-grayScale-100 hover:text-grayScale-500"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
|
|
@ -135,7 +130,7 @@ function SortableChip({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-semibold"
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-semibold text-grayScale-700"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ function getIssueTypeConfig(type: string): {
|
|||
case "course":
|
||||
return {
|
||||
label: "Course",
|
||||
classes: "bg-brand-500 text-white border-brand-500",
|
||||
classes: "bg-brand-50 text-brand-700 border-brand-200",
|
||||
icon: BookOpen,
|
||||
};
|
||||
case "account":
|
||||
|
|
|
|||
|
|
@ -1,422 +0,0 @@
|
|||
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,7 +51,6 @@ import {
|
|||
} from "../../components/ui/dropdown-menu"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
|
|
@ -260,7 +259,6 @@ function NotificationItem({
|
|||
}
|
||||
|
||||
export function NotificationsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [globalUnread, setGlobalUnread] = useState(0)
|
||||
|
|
@ -278,6 +276,17 @@ export function NotificationsPage() {
|
|||
const [typeFilter, setTypeFilter] = 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 [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms")
|
||||
const [bulkTitle, setBulkTitle] = useState("")
|
||||
|
|
@ -526,8 +535,45 @@ 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 (
|
||||
<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">
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="mb-5">
|
||||
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
|
||||
|
|
@ -544,7 +590,7 @@ export function NotificationsPage() {
|
|||
<Button
|
||||
size="sm"
|
||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||
onClick={() => navigate("/notifications/create")}
|
||||
onClick={() => setBulkOpen(true)}
|
||||
>
|
||||
<Mail className="mr-2 h-3.5 w-3.5" />
|
||||
Send notification
|
||||
|
|
@ -784,7 +830,7 @@ export function NotificationsPage() {
|
|||
key={n.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!n.is_read && "bg-brand-50/10 hover:bg-brand-50/25",
|
||||
!n.is_read && "bg-brand-50/40 hover:bg-brand-50/70",
|
||||
)}
|
||||
onClick={() => handleOpenDetail(n)}
|
||||
>
|
||||
|
|
@ -970,6 +1016,236 @@ export function NotificationsPage() {
|
|||
</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 */}
|
||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user