diff --git a/app/(app)/alerts/alert-actions.tsx b/app/(app)/alerts/alert-actions.tsx
new file mode 100644
index 00000000..034ac752
--- /dev/null
+++ b/app/(app)/alerts/alert-actions.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { pauseAlert, resumeAlert, deleteAlert } from "@/app/actions/alerts";
+
+export function AlertActions({ alertId, status }: { alertId: string; status: string }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+
+ function run(fn: () => Promise<{ ok: boolean; error?: string }>) {
+ startTransition(async () => {
+ const res = await fn();
+ if (!res.ok && res.error) window.alert(res.error);
+ router.refresh();
+ });
+ }
+
+ return (
+
+ {status === "active" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/app/(app)/alerts/create-alert.tsx b/app/(app)/alerts/create-alert.tsx
new file mode 100644
index 00000000..d79a320d
--- /dev/null
+++ b/app/(app)/alerts/create-alert.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { ALERT_CATEGORIES, type Recency } from "@/lib/alerts/categories";
+import { createAlert, testRunAlert } from "@/app/actions/alerts";
+import type { SerpResult } from "@/lib/alerts/valueserp";
+
+// People-tracking templates (name / reputation / impersonation / legal) are
+// withheld from the v1 launch picker pending a trust-and-safety policy.
+const PICKABLE = ALERT_CATEGORIES.filter((c) => !c.gated);
+
+const RECENCIES: { value: Recency; label: string }[] = [
+ { value: "day", label: "Past 24 hours" },
+ { value: "week", label: "Past week" },
+ { value: "month", label: "Past month" },
+ { value: "any", label: "Any time" },
+];
+
+export function CreateAlert({
+ remainingSlots,
+ allowHourly,
+}: {
+ remainingSlots: number;
+ allowHourly: boolean;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [categoryKey, setCategoryKey] = useState(PICKABLE[0].key);
+ const [term, setTerm] = useState("");
+ const [recency, setRecency] = useState(PICKABLE[0].defaultRecency);
+ const [frequency, setFrequency] = useState<"daily" | "hourly">("daily");
+ const [preview, setPreview] = useState(null);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ const category = useMemo(() => PICKABLE.find((c) => c.key === categoryKey)!, [categoryKey]);
+ const atCap = remainingSlots <= 0;
+
+ function pickCategory(key: string) {
+ const cat = PICKABLE.find((c) => c.key === key)!;
+ setCategoryKey(cat.key);
+ setRecency(cat.defaultRecency);
+ setPreview(null);
+ setError(null);
+ setNotice(null);
+ }
+
+ function runTest() {
+ setError(null);
+ setNotice(null);
+ if (!term.trim()) {
+ setError(`${category.inputLabel} is required.`);
+ return;
+ }
+ startTransition(async () => {
+ const res = await testRunAlert({ category: categoryKey, term, customQuery: term });
+ if (!res.ok) {
+ setError(res.error);
+ return;
+ }
+ setPreview(res.results);
+ if (res.results.length === 0) setNotice("No current results — you'll be emailed when something new appears.");
+ });
+ }
+
+ function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setNotice(null);
+ if (!term.trim()) {
+ setError(`${category.inputLabel} is required.`);
+ return;
+ }
+ startTransition(async () => {
+ const res = await createAlert({
+ category: categoryKey,
+ term,
+ customQuery: term,
+ recency,
+ frequency,
+ });
+ if (!res.ok) {
+ setError(res.error);
+ return;
+ }
+ setTerm("");
+ setPreview(null);
+ setNotice("Alert created. First check runs shortly.");
+ router.refresh();
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/app/(app)/alerts/page.tsx b/app/(app)/alerts/page.tsx
new file mode 100644
index 00000000..ca4ec2f8
--- /dev/null
+++ b/app/(app)/alerts/page.tsx
@@ -0,0 +1,124 @@
+import { redirect } from "next/navigation";
+import Link from "next/link";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { createAlert } from "@/app/actions/alerts";
+import {
+ MAX_ACTIVE_ALERTS,
+ SERP_CALLS_PER_MONTH,
+ planFromProfile,
+} from "@/lib/alerts/limits";
+import { getCategory } from "@/lib/alerts/categories";
+import { CreateAlert } from "./create-alert";
+import { AlertActions } from "./alert-actions";
+
+export const metadata = { title: "Alerts" };
+
+type AlertRow = {
+ id: string;
+ label: string;
+ category: string;
+ status: string;
+ recency: string;
+ frequency: string;
+ last_checked_at: string | null;
+ confirm_backlink: boolean;
+};
+
+function fmt(ts: string | null): string {
+ if (!ts) return "not yet";
+ const d = new Date(ts);
+ return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
+}
+
+export default async function AlertsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ new?: string; term?: string }>;
+}) {
+ const { new: newCat, term } = await searchParams;
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) redirect("/login");
+
+ // Post-signup: create the alert carried in the magic-link redirect, then
+ // strip the params so a refresh can't double-create.
+ if (newCat && getCategory(newCat)) {
+ await createAlert({ category: newCat, term: term ?? "", customQuery: term ?? "" });
+ redirect("/alerts");
+ }
+
+ const svc = serviceClient();
+ const { data: profile } = await svc
+ .from("profiles")
+ .select("plan, alert_serp_calls_used")
+ .eq("id", user.id)
+ .maybeSingle();
+ const plan = planFromProfile(profile?.plan as string | undefined);
+
+ const { data: alertRows } = await svc
+ .from("alerts")
+ .select("id, label, category, status, recency, frequency, last_checked_at, confirm_backlink")
+ .eq("owner_id", user.id)
+ .order("created_at", { ascending: false });
+ const alerts = (alertRows ?? []) as AlertRow[];
+
+ const activeCount = alerts.filter((a) => a.status === "active").length;
+ const cap = MAX_ACTIVE_ALERTS[plan];
+ const remaining = Math.max(0, cap - activeCount);
+ const budget = SERP_CALLS_PER_MONTH[plan];
+ const used = (profile?.alert_serp_calls_used as number) ?? 0;
+
+ return (
+
+
+
+
Alerts
+
+ Free email alerts for anything Google can see — brand mentions, backlinks, buying intent, and more.
+
+
+
+ {activeCount}/{cap} active
+ {Math.max(0, budget - used)} checks left this month
+ {plan === "free" && (
+
+ Upgrade
+
+ )}
+
+
+
+
+
+
+
+ Your alerts
+
+ {alerts.length === 0 && (
+
+ No alerts yet. Create your first one above — try your brand name or your domain.
+
+ )}
+ {alerts.map((a) => (
+
+
+
+ {a.label}
+ {a.status === "paused" && paused}
+ {a.confirm_backlink && backlink-verified}
+
+
+ {getCategory(a.category)?.title ?? a.category} · {a.frequency} · last checked {fmt(a.last_checked_at)}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index 64fa74b8..8d556bfd 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -35,6 +35,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod