Yimaru-Admin/src/pages/notifications/CreateNotificationPage.tsx
Yared Yemane 1f0046a8ee standardize loading indicators with shared spinner asset
Replace ad-hoc Loader2 loading indicators with SpinnerIcon so loading states across content and notifications pages use the same Circular-indeterminate progress indicator.

Made-with: Cursor
2026-04-15 04:30:07 -07:00

424 lines
18 KiB
TypeScript

import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Bell, 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 { SpinnerIcon } from "../../components/ui/spinner-icon"
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 ? (
<>
<SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
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">
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
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>
)
}