Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions app/(app)/alerts/alert-actions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex shrink-0 gap-2">
{status === "active" ? (
<button className="btn" disabled={pending} onClick={() => run(() => pauseAlert(alertId))}>
Pause
</button>
) : (
<button className="btn" disabled={pending} onClick={() => run(() => resumeAlert(alertId))}>
Resume
</button>
)}
<button
className="btn"
disabled={pending}
onClick={() => {
if (window.confirm("Delete this alert? Its history is removed too.")) {
run(() => deleteAlert(alertId));
}
}}
>
Delete
</button>
</div>
);
}
184 changes: 184 additions & 0 deletions app/(app)/alerts/create-alert.tsx
Original file line number Diff line number Diff line change
@@ -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<Recency>(PICKABLE[0].defaultRecency);
const [frequency, setFrequency] = useState<"daily" | "hourly">("daily");
const [preview, setPreview] = useState<SerpResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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 (
<form onSubmit={submit} className="card space-y-4">
<div>
<label className="mb-1 block text-sm font-medium">What do you want to track?</label>
<div className="flex flex-wrap gap-2">
{PICKABLE.map((c) => (
<button
type="button"
key={c.key}
onClick={() => pickCategory(c.key)}
className={`badge ${c.key === categoryKey ? "badge-pass" : ""}`}
>
{c.title}
</button>
))}
</div>
<p className="mt-2 text-xs text-[var(--color-muted)]">{category.template}</p>
</div>

<div className="grid gap-3 sm:grid-cols-[1fr_auto_auto]">
<input
className="input"
placeholder={category.inputPlaceholder}
aria-label={category.inputLabel}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<select
className="input"
value={recency}
onChange={(e) => setRecency(e.target.value as Recency)}
aria-label="Recency"
>
{RECENCIES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
<select
className="input"
value={frequency}
onChange={(e) => setFrequency(e.target.value as "daily" | "hourly")}
aria-label="Check frequency"
disabled={!allowHourly}
title={allowHourly ? "" : "Hourly checks are a paid feature"}
>
<option value="daily">Daily</option>
<option value="hourly">Hourly{allowHourly ? "" : " (paid)"}</option>
</select>
</div>

<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn" onClick={runTest} disabled={pending}>
{pending ? "Checking…" : "See current results"}
</button>
<button type="submit" className="btn btn-primary" disabled={pending || atCap}>
Create alert
</button>
{atCap && (
<span className="text-xs text-[var(--color-warn)]">
You're at your active-alert limit — pause one or upgrade.
</span>
)}
</div>

{error && <p className="text-sm text-[var(--color-fail)]">{error}</p>}
{notice && <p className="text-sm text-[var(--color-accent)]">{notice}</p>}

{preview && preview.length > 0 && (
<div className="space-y-2 border-t border-[var(--color-border)] pt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-muted)]">
Current results preview (you'll only be emailed NEW ones)
</p>
{preview.map((r) => (
<a
key={r.url}
href={r.url}
target="_blank"
rel="noreferrer"
className="block text-sm"
>
<span className="font-medium text-[var(--color-accent)]">{r.title || r.url}</span>
<span className="block truncate text-xs text-[var(--color-muted)]">{r.url}</span>
</a>
))}
</div>
)}
</form>
);
}
124 changes: 124 additions & 0 deletions app/(app)/alerts/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Alerts</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Free email alerts for anything Google can see — brand mentions, backlinks, buying intent, and more.
</p>
</div>
<div className="flex gap-2 text-xs">
<span className="badge">{activeCount}/{cap} active</span>
<span className="badge">{Math.max(0, budget - used)} checks left this month</span>
{plan === "free" && (
<Link href="/settings/billing" className="badge badge-pass">
Upgrade
</Link>
)}
</div>
</div>

<CreateAlert remainingSlots={remaining} allowHourly={plan !== "free"} />

<div className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--color-muted)]">
Your alerts
</h2>
{alerts.length === 0 && (
<div className="card text-sm text-[var(--color-muted)]">
No alerts yet. Create your first one above — try your brand name or your domain.
</div>
)}
{alerts.map((a) => (
<div key={a.id} className="card flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold">{a.label}</span>
{a.status === "paused" && <span className="badge badge-warn">paused</span>}
{a.confirm_backlink && <span className="badge badge-pass">backlink-verified</span>}
</div>
<div className="mt-1 text-xs text-[var(--color-muted)]">
{getCategory(a.category)?.title ?? a.category} · {a.frequency} · last checked {fmt(a.last_checked_at)}
</div>
</div>
<AlertActions alertId={a.id} status={a.status} />
</div>
))}
</div>
</div>
);
}
1 change: 1 addition & 0 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
</Link>
<nav className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-[var(--color-muted)]">
<Link href="/dashboard">Dashboard</Link>
<Link href="/alerts">Alerts</Link>
<Link href="/recent">Recent</Link>
<Link href="/projects/new">New</Link>
<Link href="/settings/integrations/github">GitHub</Link>
Expand Down
Loading
Loading