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 ( +
+
+ +
+ {PICKABLE.map((c) => ( + + ))} +
+

{category.template}

+
+ +
+ setTerm(e.target.value)} + /> + + +
+ +
+ + + {atCap && ( + + You're at your active-alert limit — pause one or upgrade. + + )} +
+ + {error &&

{error}

} + {notice &&

{notice}

} + + {preview && preview.length > 0 && ( +
+

+ Current results preview (you'll only be emailed NEW ones) +

+ {preview.map((r) => ( + + {r.title || r.url} + {r.url} + + ))} +
+ )} +
+ ); +} 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