Skip to content
2 changes: 2 additions & 0 deletions code/src/app/components/explorer/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ function ChatRow({
// tag so they know it isn't public yet (others won't see it at all).
const underReview = post.status !== "PUBLISHED";

if (post.kind === "BULLETIN") return null;

return (
<div className={`xp-msg ${pinned ? "is-pinned" : ""} ${underReview ? "is-under-review" : ""}`}>
<span className={`xp-msg-author ${isSystem ? "is-system" : ""}`}>{name}</span>
Expand Down
12 changes: 12 additions & 0 deletions code/src/app/ops/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Calendar,
HelpCircle,
Newspaper,
Megaphone,
} from "lucide-react";
import { Toaster } from "sonner";

Expand Down Expand Up @@ -129,6 +130,17 @@ export default function OpsLayout({ children }: { children: React.ReactNode }) {
<Newspaper className="w-4 h-4" />
<span className="hidden sm:inline">Bulletins</span>
</Link>
<Link
href="/ops/notification"
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition ${
pathname === "/ops/notification"
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-800/50"
}`}
>
<Megaphone className="w-4 h-4" />
<span className="hidden sm:inline">Notification</span>
</Link>
<Link
href="/ops/list"
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition ${
Expand Down
5 changes: 5 additions & 0 deletions code/src/app/ops/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,9 @@ export const ADMIN_API = {

/** Manually run the daily weather generation (same as the 8 AM cron) */
generateBulletins: `${BASE_URL}/admin/bulletin/generate`,

// ---- Broadcast ----

/** Send a push notification to every user with an active device token */
notification: `${BASE_URL}/admin/notification`,
};
106 changes: 106 additions & 0 deletions code/src/app/ops/notification/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import { useState } from "react";
import { adminFetch } from "../lib/api";
import { ADMIN_API } from "../lib/constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Send } from "lucide-react";
import { toast } from "sonner";

async function errorMessage(res: Response): Promise<string> {
try {
const body = await res.json();
return body?.error?.message ?? body?.message ?? `${res.status}`;
} catch {
return `${res.status}`;
}
}

export default function NotificationPage() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);

async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !body.trim()) return;
if (!window.confirm("Send this notification to every user with the app installed?")) return;

setSending(true);
try {
const res = await adminFetch(ADMIN_API.notification, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
notification: { type: "BROADCAST", payload: { title, body } },
}),
});
if (!res.ok) throw new Error(await errorMessage(res));
const json = await res.json();
const delivered = json.data?.notification_delivered ?? 0;
toast.success(`Sent to ${delivered} device${delivered === 1 ? "" : "s"}`);
setTitle("");
setBody("");
} catch (err) {
toast.error(`Failed to send broadcast: ${err}`);
} finally {
setSending(false);
}
}

return (
<div className="px-4 py-6 max-w-2xl mx-auto">
<div className="mb-5">
<h1 className="text-lg font-semibold text-zinc-200">Notification</h1>
<p className="text-xs text-zinc-500 mt-0.5">
Sends a push notification to every user with an active device token.
</p>
</div>

<form
onSubmit={handleSend}
className="space-y-3 bg-zinc-900 border border-zinc-800 rounded-xl p-5"
>
<div>
<label className="text-xs text-zinc-400 mb-1 block">Title *</label>
<Input
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. New feature: City Bulletins"
className="bg-zinc-800 border-zinc-700 text-zinc-200"
/>
</div>

<div>
<label className="text-xs text-zinc-400 mb-1 block">Message *</label>
<textarea
required
rows={4}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="What do you want to tell every user?"
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
</div>

<div className="flex justify-end pt-2">
<Button
type="submit"
size="sm"
disabled={sending}
className="bg-emerald-700 hover:bg-emerald-600 text-white"
>
{sending ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Send className="w-3 h-3 mr-1" />
)}
Send to Everyone
</Button>
</div>
</form>
</div>
);
}
Loading