Wire browser-safe finance presentation UI

This commit is contained in:
MOHAN 2026-07-16 22:07:33 +05:30
parent 1d7007637d
commit f0e6405b11
26 changed files with 986 additions and 47 deletions

View File

@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts");
return proxyRequest(req, "view/accounts");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
return proxyRequest(req, `bill-pay/bills/${params.id}/pay`);
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
return proxyRequest(req, `bill-pay/bills/${params.id}`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "bill-pay/bills");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "bill-pay/bills");
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "bill-pay/payees");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "bill-pay/payees");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "bill-pay/summary");
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "credit-score/entries");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "credit-score/entries");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "credit-score/summary");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
return proxyRequest(req, `notifications/${params.id}/read`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "notifications/preferences");
}
export async function PATCH(req: NextRequest) {
return proxyRequest(req, "notifications/preferences");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "notifications/push-subscriptions");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "notifications/read-all");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "notifications");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "notifications/test");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "notifications/vapid-public-key");
}

View File

@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions");
return proxyRequest(req, "view/transactions");
}
export async function POST(req: NextRequest) {

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "view/accounts");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "view/transactions");
}

View File

@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { usePlaidLink } from "react-plaid-link";
type Account = {
id: string;
viewRef: string;
institutionName: string;
accountType: string;
mask?: string | null;
@ -43,7 +43,7 @@ declare global {
export default function ConnectPage() {
const [status, setStatus] = useState("");
const [linkToken, setLinkToken] = useState<string | null>(null);
const [updateAccountId, setUpdateAccountId] = useState<string | null>(null);
const [updateAccountRef, setUpdateAccountRef] = useState<string | null>(null);
const [linkMode, setLinkMode] = useState<"connect" | "update">("connect");
const [pendingOpen, setPendingOpen] = useState(false);
const [manualMode, setManualMode] = useState(false);
@ -120,13 +120,13 @@ export default function ConnectPage() {
const onSuccess = useCallback(
async (publicToken: string | null) => {
if (linkMode === "update" && updateAccountId) {
if (linkMode === "update" && updateAccountRef) {
setStatus("Finishing bank reconnection...");
try {
const res = await fetch("/api/plaid/repair-complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId: updateAccountId })
body: JSON.stringify({ accountId: updateAccountRef })
});
const payload = await res.json();
if (!res.ok || payload.error) {
@ -134,7 +134,7 @@ export default function ConnectPage() {
return;
}
setStatus("Bank connection repaired.");
setUpdateAccountId(null);
setUpdateAccountRef(null);
setLinkMode("connect");
setLinkToken(null);
await loadAccounts();
@ -170,7 +170,7 @@ export default function ConnectPage() {
setStatus("Unable to exchange token.");
}
},
[createLinkToken, linkMode, loadAccounts, updateAccountId]
[createLinkToken, linkMode, loadAccounts, updateAccountRef]
);
const { open, ready } = usePlaidLink({
@ -218,13 +218,13 @@ export default function ConnectPage() {
});
};
const startUpdateMode = async (accountId: string) => {
const startUpdateMode = async (accountRef: string) => {
setStatus("Requesting Plaid update-mode link token...");
try {
const res = await fetch("/api/plaid/update-link-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId })
body: JSON.stringify({ accountId: accountRef })
});
const payload = await res.json();
if (!res.ok || payload.error) {
@ -236,7 +236,7 @@ export default function ConnectPage() {
setStatus("Unable to create update-mode link token.");
return;
}
setUpdateAccountId(accountId);
setUpdateAccountRef(accountRef);
setLinkMode("update");
setLinkToken(token);
setStatus("Reconnect token ready. Opening Plaid...");
@ -351,7 +351,7 @@ export default function ConnectPage() {
</p>
{accounts.map((account) => (
<div
key={account.id}
key={account.viewRef}
className="flex flex-col gap-3 rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm md:flex-row md:items-center md:justify-between"
>
<div>
@ -374,7 +374,7 @@ export default function ConnectPage() {
<button
type="button"
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
onClick={() => startUpdateMode(account.id)}
onClick={() => startUpdateMode(account.viewRef)}
>
Reconnect
</button>

View File

@ -33,11 +33,10 @@ type MerchantInsight = {
};
type TxRow = {
id: string;
viewRef: string;
date: string;
description: string;
category?: string | null;
accountId?: string | null;
amount: string;
};
@ -507,14 +506,14 @@ export default function AppHomePage() {
apiFetch<Summary>("/api/transactions/summary"),
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
apiFetch<{ accounts: { viewRef: string }[]; total: number }>("/api/view/accounts?limit=5"),
apiFetch<{ transactions: TxRow[]; total: number }>("/api/view/transactions?limit=5"),
])
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
if (!summaryRes.error) setSummary(summaryRes.data);
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
if (!accountsRes.error) setAccountCount(accountsRes.data?.total ?? accountsRes.data?.accounts?.length ?? 0);
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
})
.catch(() => undefined)
@ -737,7 +736,7 @@ export default function AppHomePage() {
const fmtAmt = formatCurrency(amt);
const isIncome = amt >= 0;
return (
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
<tr key={tx.viewRef} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">

303
app/bills/page.tsx Normal file
View File

@ -0,0 +1,303 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/app-shell";
import { apiFetch } from "@/lib/api";
type Payee = {
id: string;
name: string;
category?: string | null;
accountNumberLast4?: string | null;
};
type Bill = {
id: string;
payeeId?: string | null;
name: string;
amount: string | number;
currency: string;
dueDate: string;
status: "pending" | "scheduled" | "paid" | "skipped" | "cancelled";
computedStatus: string;
recurrence: string;
autopay: boolean;
reminderDays: number;
paidAt?: string | null;
payee?: Payee | null;
};
type BillSummary = {
activeCount: number;
upcomingCount: number;
overdueCount: number;
totalDueNext30: string;
};
const today = new Date().toISOString().slice(0, 10);
export default function BillsPage() {
const [payees, setPayees] = useState<Payee[]>([]);
const [bills, setBills] = useState<Bill[]>([]);
const [summary, setSummary] = useState<BillSummary | null>(null);
const [status, setStatus] = useState("");
const [filter, setFilter] = useState("all");
const [payeeForm, setPayeeForm] = useState({ name: "", category: "", accountNumberLast4: "" });
const [billForm, setBillForm] = useState({
payeeId: "",
name: "",
amount: "",
dueDate: today,
status: "pending",
recurrence: "none",
autopay: false,
reminderDays: "3",
notes: "",
});
const filteredBills = useMemo(() => {
if (filter === "all") return bills;
if (filter === "overdue") return bills.filter((bill) => bill.computedStatus === "overdue");
return bills.filter((bill) => bill.status === filter);
}, [bills, filter]);
const money = (value: string | number, currency = "USD") =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(Number(value ?? 0));
const load = async () => {
const [payeesRes, billsRes, summaryRes] = await Promise.all([
apiFetch<Payee[]>("/api/bill-pay/payees"),
apiFetch<Bill[]>("/api/bill-pay/bills"),
apiFetch<BillSummary>("/api/bill-pay/summary"),
]);
if (!payeesRes.error) setPayees(payeesRes.data ?? []);
if (!billsRes.error) setBills(billsRes.data ?? []);
if (!summaryRes.error) setSummary(summaryRes.data ?? null);
};
useEffect(() => {
load().catch(() => setStatus("Unable to load bills."));
}, []);
const createPayee = async () => {
if (!payeeForm.name.trim()) {
setStatus("Payee name is required.");
return;
}
const res = await apiFetch<Payee>("/api/bill-pay/payees", {
method: "POST",
body: JSON.stringify(payeeForm),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to create payee.");
return;
}
setPayeeForm({ name: "", category: "", accountNumberLast4: "" });
setStatus("Payee created.");
await load();
};
const createBill = async () => {
if (!billForm.name.trim() || !billForm.amount || !billForm.dueDate) {
setStatus("Bill name, amount, and due date are required.");
return;
}
const payload = {
...billForm,
payeeId: billForm.payeeId || undefined,
amount: Number(billForm.amount),
reminderDays: Number(billForm.reminderDays || 3),
};
const res = await apiFetch<Bill>("/api/bill-pay/bills", {
method: "POST",
body: JSON.stringify(payload),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to create bill.");
return;
}
setBillForm({ payeeId: "", name: "", amount: "", dueDate: today, status: "pending", recurrence: "none", autopay: false, reminderDays: "3", notes: "" });
setStatus("Bill created.");
await load();
};
const markPaid = async (bill: Bill) => {
const res = await apiFetch(`/api/bill-pay/bills/${bill.id}/pay`, {
method: "POST",
body: JSON.stringify({
amount: Number(bill.amount),
paidAt: new Date().toISOString(),
method: bill.autopay ? "autopay" : "manual",
}),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to mark bill paid.");
return;
}
setStatus(`${bill.name} marked paid.`);
await load();
};
const updateStatus = async (bill: Bill, nextStatus: Bill["status"]) => {
const res = await apiFetch(`/api/bill-pay/bills/${bill.id}`, {
method: "PATCH",
body: JSON.stringify({ status: nextStatus }),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to update bill.");
return;
}
setStatus("Bill updated.");
await load();
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return (
<AppShell title="Bills" subtitle="Track payees, due dates, reminders, and recorded payments.">
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Active bills</p>
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.activeCount ?? 0}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Due next 30 days</p>
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.upcomingCount ?? 0}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Overdue</p>
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.overdueCount ?? 0}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Total due next 30 days</p>
<p className="mt-2 text-2xl font-bold text-foreground">{money(summary?.totalDueNext30 ?? 0)}</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[0.85fr_1.15fr]">
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Payees</p>
<div className="mt-4 grid gap-3">
<div>
<label className={labelCls}>Name</label>
<input value={payeeForm.name} onChange={(event) => setPayeeForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Category</label>
<input value={payeeForm.category} onChange={(event) => setPayeeForm((prev) => ({ ...prev, category: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Account last 4</label>
<input maxLength={4} value={payeeForm.accountNumberLast4} onChange={(event) => setPayeeForm((prev) => ({ ...prev, accountNumberLast4: event.target.value }))} className={inputCls} />
</div>
<button onClick={createPayee} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">Add Payee</button>
</div>
<div className="mt-6 divide-y divide-border">
{payees.length === 0 && <p className="py-4 text-sm text-muted-foreground">No payees yet.</p>}
{payees.map((payee) => (
<div key={payee.id} className="py-3">
<p className="text-sm font-semibold text-foreground">{payee.name}</p>
<p className="text-xs text-muted-foreground">{payee.category || "Uncategorized"}{payee.accountNumberLast4 ? ` · ${payee.accountNumberLast4}` : ""}</p>
</div>
))}
</div>
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">New Bill</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className={labelCls}>Payee</label>
<select value={billForm.payeeId} onChange={(event) => setBillForm((prev) => ({ ...prev, payeeId: event.target.value }))} className={inputCls}>
<option value="">No payee</option>
{payees.map((payee) => <option key={payee.id} value={payee.id}>{payee.name}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Bill name</label>
<input value={billForm.name} onChange={(event) => setBillForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Amount</label>
<input type="number" step="0.01" value={billForm.amount} onChange={(event) => setBillForm((prev) => ({ ...prev, amount: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Due date</label>
<input type="date" value={billForm.dueDate} onChange={(event) => setBillForm((prev) => ({ ...prev, dueDate: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Recurrence</label>
<select value={billForm.recurrence} onChange={(event) => setBillForm((prev) => ({ ...prev, recurrence: event.target.value }))} className={inputCls}>
<option value="none">None</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div>
<label className={labelCls}>Reminder days</label>
<input type="number" min={0} max={30} value={billForm.reminderDays} onChange={(event) => setBillForm((prev) => ({ ...prev, reminderDays: event.target.value }))} className={inputCls} />
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" checked={billForm.autopay} onChange={(event) => setBillForm((prev) => ({ ...prev, autopay: event.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Autopay enabled externally
</label>
<button onClick={createBill} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">Add Bill</button>
</div>
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
</section>
</div>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm font-bold text-foreground">Bill Schedule</p>
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="scheduled">Scheduled</option>
<option value="overdue">Overdue</option>
<option value="paid">Paid</option>
<option value="skipped">Skipped</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div className="mt-4 divide-y divide-border">
{filteredBills.length === 0 && <p className="py-8 text-sm text-muted-foreground">No bills for this view.</p>}
{filteredBills.map((bill) => (
<article key={bill.id} className="py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-bold text-foreground">{bill.name}</p>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{bill.computedStatus}</span>
{bill.autopay && <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">autopay</span>}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{money(bill.amount, bill.currency)} due {new Date(bill.dueDate).toLocaleDateString()} · {bill.recurrence}
</p>
{bill.payee && <p className="mt-1 text-xs text-muted-foreground">Payee: {bill.payee.name}</p>}
</div>
<div className="flex flex-wrap gap-2">
{bill.status !== "paid" && (
<button onClick={() => markPaid(bill)} className="rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90">Mark Paid</button>
)}
{bill.status !== "skipped" && bill.status !== "paid" && (
<button onClick={() => updateStatus(bill, "skipped")} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground hover:bg-secondary/40">Skip</button>
)}
{bill.status !== "cancelled" && bill.status !== "paid" && (
<button onClick={() => updateStatus(bill, "cancelled")} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground hover:bg-secondary/40">Cancel</button>
)}
</div>
</div>
</article>
))}
</div>
</section>
</div>
</AppShell>
);
}

241
app/credit-score/page.tsx Normal file
View File

@ -0,0 +1,241 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/app-shell";
import { apiFetch } from "@/lib/api";
type CreditScoreEntry = {
id: string;
score: number;
bureau: string;
source: string;
model: string;
scoreDate: string;
factors?: Record<string, unknown>;
change?: number | null;
};
type CreditScoreSummary = {
latest: CreditScoreEntry | null;
previous: CreditScoreEntry | null;
change: number | null;
averageScore: number | null;
entryCount: number;
latestByBureau: CreditScoreEntry[];
trend: CreditScoreEntry[];
};
const bureaus = [
{ value: "experian", label: "Experian" },
{ value: "equifax", label: "Equifax" },
{ value: "transunion", label: "TransUnion" },
{ value: "unknown", label: "Unknown" },
];
const today = new Date().toISOString().slice(0, 10);
export default function CreditScorePage() {
const [summary, setSummary] = useState<CreditScoreSummary | null>(null);
const [entries, setEntries] = useState<CreditScoreEntry[]>([]);
const [filter, setFilter] = useState("all");
const [status, setStatus] = useState("");
const [form, setForm] = useState({
score: "",
bureau: "experian",
source: "manual",
model: "fico_8",
scoreDate: today,
positiveFactors: "",
negativeFactors: "",
});
const filteredEntries = useMemo(() => {
if (filter === "all") return entries;
return entries.filter((entry) => entry.bureau === filter);
}, [entries, filter]);
const scoreBand = (score?: number | null) => {
if (!score) return "No score";
if (score >= 800) return "Exceptional";
if (score >= 740) return "Very good";
if (score >= 670) return "Good";
if (score >= 580) return "Fair";
return "Needs work";
};
const load = async () => {
const query = filter === "all" ? "" : `?bureau=${filter}`;
const [summaryRes, entriesRes] = await Promise.all([
apiFetch<CreditScoreSummary>("/api/credit-score/summary"),
apiFetch<CreditScoreEntry[]>(`/api/credit-score/entries${query}`),
]);
if (!summaryRes.error) setSummary(summaryRes.data ?? null);
if (!entriesRes.error) setEntries(entriesRes.data ?? []);
};
useEffect(() => {
load().catch(() => setStatus("Unable to load credit score history."));
}, [filter]);
const addEntry = async () => {
const score = Number(form.score);
if (!score || score < 300 || score > 850) {
setStatus("Score must be between 300 and 850.");
return;
}
const factors = {
positive: form.positiveFactors.split(",").map((item) => item.trim()).filter(Boolean),
negative: form.negativeFactors.split(",").map((item) => item.trim()).filter(Boolean),
};
const res = await apiFetch<CreditScoreEntry>("/api/credit-score/entries", {
method: "POST",
body: JSON.stringify({
score,
bureau: form.bureau,
source: form.source,
model: form.model,
scoreDate: form.scoreDate,
factors,
}),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to add score entry.");
return;
}
const change = res.data?.change;
setStatus(change === null || change === undefined ? "Credit score entry added." : `Credit score entry added. Change: ${change > 0 ? "+" : ""}${change}.`);
setForm((prev) => ({ ...prev, score: "", positiveFactors: "", negativeFactors: "" }));
await load();
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return (
<AppShell title="Credit Score" subtitle="Track bureau scores, history, factors, and score movement alerts.">
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Latest score</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.latest?.score ?? "--"}</p>
<p className="mt-1 text-xs text-muted-foreground">{scoreBand(summary?.latest?.score)}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Last change</p>
<p className="mt-2 text-3xl font-bold text-foreground">
{summary?.change === null || summary?.change === undefined ? "--" : `${summary.change > 0 ? "+" : ""}${summary.change}`}
</p>
<p className="mt-1 text-xs text-muted-foreground">Compared with previous same-bureau entry</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Average score</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.averageScore ?? "--"}</p>
<p className="mt-1 text-xs text-muted-foreground">Across saved entries</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Entries</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.entryCount ?? 0}</p>
<p className="mt-1 text-xs text-muted-foreground">Manual or imported history</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Add Score Entry</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className={labelCls}>Score</label>
<input type="number" min={300} max={850} value={form.score} onChange={(event) => setForm((prev) => ({ ...prev, score: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Bureau</label>
<select value={form.bureau} onChange={(event) => setForm((prev) => ({ ...prev, bureau: event.target.value }))} className={inputCls}>
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Model</label>
<select value={form.model} onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))} className={inputCls}>
<option value="fico_8">FICO 8</option>
<option value="fico_9">FICO 9</option>
<option value="vantage_score_3">VantageScore 3</option>
<option value="vantage_score_4">VantageScore 4</option>
</select>
</div>
<div>
<label className={labelCls}>Score date</label>
<input type="date" value={form.scoreDate} onChange={(event) => setForm((prev) => ({ ...prev, scoreDate: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Positive factors</label>
<input value={form.positiveFactors} onChange={(event) => setForm((prev) => ({ ...prev, positiveFactors: event.target.value }))} className={inputCls} placeholder="low utilization, on-time payments" />
</div>
<div>
<label className={labelCls}>Negative factors</label>
<input value={form.negativeFactors} onChange={(event) => setForm((prev) => ({ ...prev, negativeFactors: event.target.value }))} className={inputCls} placeholder="hard inquiry, high balance" />
</div>
</div>
<button onClick={addEntry} className="mt-4 rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">
Add Score
</button>
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm font-bold text-foreground">Bureau Snapshot</p>
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
<option value="all">All bureaus</option>
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
</select>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{(summary?.latestByBureau ?? []).map((entry) => (
<div key={entry.bureau} className="rounded-lg border border-border bg-background/50 p-4">
<p className="text-xs uppercase text-muted-foreground">{entry.bureau}</p>
<p className="mt-2 text-2xl font-bold text-foreground">{entry.score}</p>
<p className="text-xs text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()}</p>
</div>
))}
{(summary?.latestByBureau?.length ?? 0) === 0 && <p className="text-sm text-muted-foreground">No bureau scores yet.</p>}
</div>
</section>
</div>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Score History</p>
<div className="mt-4 divide-y divide-border">
{filteredEntries.length === 0 && <p className="py-8 text-sm text-muted-foreground">No score entries for this view.</p>}
{filteredEntries.map((entry) => {
const factors = entry.factors ?? {};
const positive = Array.isArray(factors.positive) ? factors.positive : [];
const negative = Array.isArray(factors.negative) ? factors.negative : [];
return (
<article key={entry.id} className="py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-lg font-bold text-foreground">{entry.score}</p>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.bureau}</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.model}</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()} · {entry.source}</p>
{(positive.length > 0 || negative.length > 0) && (
<p className="mt-2 text-xs text-muted-foreground">
{positive.length > 0 ? `Positive: ${positive.join(", ")}` : ""}
{positive.length > 0 && negative.length > 0 ? " · " : ""}
{negative.length > 0 ? `Negative: ${negative.join(", ")}` : ""}
</p>
)}
</div>
<p className="text-sm font-semibold text-foreground">{scoreBand(entry.score)}</p>
</div>
</article>
);
})}
</div>
</section>
</div>
</AppShell>
);
}

244
app/notifications/page.tsx Normal file
View File

@ -0,0 +1,244 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/app-shell";
import { apiFetch } from "@/lib/api";
type NotificationPreference = {
emailEnabled: boolean;
pushEnabled: boolean;
minSeverity: "info" | "warning" | "critical";
};
type LedgerNotification = {
id: string;
type: string;
severity: "info" | "warning" | "critical";
title: string;
body: string;
channels: string[];
readAt?: string | null;
createdAt: string;
};
type VapidStatus = {
enabled: boolean;
publicKey: string | null;
};
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<LedgerNotification[]>([]);
const [preferences, setPreferences] = useState<NotificationPreference>({
emailEnabled: true,
pushEnabled: false,
minSeverity: "info",
});
const [vapid, setVapid] = useState<VapidStatus>({ enabled: false, publicKey: null });
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(true);
const unreadCount = useMemo(() => notifications.filter((item) => !item.readAt).length, [notifications]);
const load = async () => {
setLoading(true);
const [listRes, prefRes, vapidRes] = await Promise.all([
apiFetch<LedgerNotification[]>("/api/notifications"),
apiFetch<NotificationPreference>("/api/notifications/preferences"),
apiFetch<VapidStatus>("/api/notifications/vapid-public-key"),
]);
if (!listRes.error) setNotifications(listRes.data ?? []);
if (!prefRes.error && prefRes.data) setPreferences(prefRes.data);
if (!vapidRes.error && vapidRes.data) setVapid(vapidRes.data);
setLoading(false);
};
useEffect(() => {
load();
}, []);
const updatePreferences = async (patch: Partial<NotificationPreference>) => {
const next = { ...preferences, ...patch };
setPreferences(next);
const res = await apiFetch<NotificationPreference>("/api/notifications/preferences", {
method: "PATCH",
body: JSON.stringify(patch),
});
if (res.error) {
setStatus(res.error.message ?? "Could not update notification preferences.");
return;
}
if (res.data) setPreferences(res.data);
setStatus("Notification preferences updated.");
};
const enablePush = async () => {
setStatus("");
if (!vapid.enabled || !vapid.publicKey) {
setStatus("Push notifications need VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY on the backend.");
return;
}
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setStatus("This browser does not support web push notifications.");
return;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setStatus("Browser notification permission was not granted.");
return;
}
const registration = await navigator.serviceWorker.register("/sw.js");
const existing = await registration.pushManager.getSubscription();
const subscription = existing ?? await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapid.publicKey),
});
const res = await apiFetch("/api/notifications/push-subscriptions", {
method: "POST",
body: JSON.stringify(subscription.toJSON()),
});
if (res.error) {
setStatus(res.error.message ?? "Could not save push subscription.");
return;
}
setPreferences((prev) => ({ ...prev, pushEnabled: true }));
setStatus("Push notifications enabled for this browser.");
};
const sendTest = async () => {
setStatus("Sending test notification...");
const res = await apiFetch("/api/notifications/test", { method: "POST" });
if (res.error) {
setStatus(res.error.message ?? "Test notification failed.");
return;
}
setStatus("Test notification sent.");
await load();
};
const markRead = async (id: string) => {
const res = await apiFetch(`/api/notifications/${id}/read`, { method: "PATCH" });
if (!res.error) {
setNotifications((items) => items.map((item) => item.id === id ? { ...item, readAt: new Date().toISOString() } : item));
}
};
const markAllRead = async () => {
const res = await apiFetch("/api/notifications/read-all", { method: "POST" });
if (!res.error) {
const now = new Date().toISOString();
setNotifications((items) => items.map((item) => ({ ...item, readAt: item.readAt ?? now })));
}
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
return (
<AppShell title="Notifications" subtitle="Manage in-app, SMTP email, and browser push alerts.">
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-3">
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">SMTP Email</p>
<p className="mt-1 text-xs text-muted-foreground">Send important LedgerOne alerts through the configured SMTP transport.</p>
<label className="mt-4 flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={preferences.emailEnabled}
onChange={(event) => updatePreferences({ emailEnabled: event.target.checked })}
className="rounded border-border text-primary focus:ring-primary"
/>
Email alerts enabled
</label>
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Browser Push</p>
<p className="mt-1 text-xs text-muted-foreground">
{vapid.enabled ? "Register this browser for web push alerts." : "Backend VAPID keys are not configured."}
</p>
<button
onClick={enablePush}
disabled={!vapid.enabled}
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
>
{preferences.pushEnabled ? "Refresh Push Registration" : "Enable Push"}
</button>
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Delivery Threshold</p>
<p className="mt-1 text-xs text-muted-foreground">Email and push delivery only run at or above this severity.</p>
<select
value={preferences.minSeverity}
onChange={(event) => updatePreferences({ minSeverity: event.target.value as NotificationPreference["minSeverity"] })}
className={inputCls}
>
<option value="info">Info and above</option>
<option value="warning">Warning and above</option>
<option value="critical">Critical only</option>
</select>
</section>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-bold text-foreground">Notification Center</p>
<p className="mt-1 text-xs text-muted-foreground">{unreadCount} unread alert{unreadCount === 1 ? "" : "s"}</p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={sendTest} className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90">
Send Test
</button>
<button onClick={markAllRead} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary/40">
Mark All Read
</button>
</div>
</div>
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
<div className="mt-6 divide-y divide-border">
{loading && <p className="py-8 text-sm text-muted-foreground">Loading notifications...</p>}
{!loading && notifications.length === 0 && <p className="py-8 text-sm text-muted-foreground">No notifications yet.</p>}
{!loading && notifications.map((item) => (
<article key={item.id} className="py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
{!item.readAt && <span className="h-2 w-2 rounded-full bg-primary" />}
<p className="text-sm font-bold text-foreground">{item.title}</p>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{item.severity}</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{item.body}</p>
<p className="mt-2 text-xs text-muted-foreground">
{new Date(item.createdAt).toLocaleString()} · {item.channels.join(", ") || "in_app"}
</p>
</div>
{!item.readAt && (
<button onClick={() => markRead(item.id)} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-secondary/40">
Mark Read
</button>
)}
</div>
</article>
))}
</div>
</div>
</div>
</AppShell>
);
}

View File

@ -6,7 +6,7 @@ import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type TransactionRow = {
id: string;
viewRef: string;
name?: string;
description?: string;
amount: string;
@ -23,11 +23,10 @@ type TransactionRow = {
status?: string;
hidden?: boolean;
date: string;
accountId?: string | null;
};
type Account = {
id: string;
viewRef: string;
institutionName: string;
accountType: string;
mask?: string | null;
@ -69,7 +68,7 @@ type CsvPreview = {
fileName: string;
headerSignature: string;
headers: string[];
sampleRows: Record<string, string>[];
rowCount: number;
mapping: Partial<CsvMapping>;
remembered: boolean;
};
@ -114,7 +113,7 @@ export default function TransactionsPage() {
});
const fileInputRef = useRef<HTMLInputElement>(null);
const [manualForm, setManualForm] = useState({
accountId: "",
accountRef: "",
date: new Date().toISOString().slice(0, 10),
description: "",
amount: "",
@ -125,7 +124,7 @@ export default function TransactionsPage() {
splitMinePercent: "50",
splitYoursPercent: "50",
});
const [editingId, setEditingId] = useState<string | null>(null);
const [editingRef, setEditingRef] = useState<string | null>(null);
const [editForm, setEditForm] = useState({
category: "",
note: "",
@ -369,7 +368,7 @@ export default function TransactionsPage() {
const res = await apiFetch<unknown>("/api/transactions/manual", {
method: "POST",
body: JSON.stringify({
accountId: manualForm.accountId || undefined,
accountId: manualForm.accountRef || undefined,
date: manualForm.date,
description: manualForm.description,
amount,
@ -389,7 +388,7 @@ export default function TransactionsPage() {
};
const startEdit = (row: TransactionRow) => {
setEditingId(row.id);
setEditingRef(row.viewRef);
setEditForm({
category: row.category ?? "",
note: row.note ?? "",
@ -402,9 +401,9 @@ export default function TransactionsPage() {
};
const saveEdit = async () => {
if (!editingId) return;
if (!editingRef) return;
setStatus("Saving edits...");
const res = await apiFetch<unknown>(`/api/transactions/${editingId}/derived`, {
const res = await apiFetch<unknown>(`/api/transactions/${editingRef}/derived`, {
method: "PATCH",
body: JSON.stringify({
userCategory: editForm.category || undefined,
@ -415,7 +414,7 @@ export default function TransactionsPage() {
}),
});
if (res.error) { setStatus(res.error.message ?? "Unable to save edits."); return; }
setEditingId(null);
setEditingRef(null);
setStatus("Transaction updated.");
await load();
await loadSummary();
@ -545,25 +544,27 @@ export default function TransactionsPage() {
</div>
<div className="overflow-hidden rounded-xl border border-border bg-background/40">
<div className="border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-foreground">Preview: {csvPreview.fileName}</p>
<p className="text-sm font-semibold text-foreground">Columns: {csvPreview.fileName}</p>
<p className="mt-1 text-xs text-muted-foreground">{csvPreview.rowCount} detected row{csvPreview.rowCount === 1 ? "" : "s"}</p>
</div>
<div className="max-h-72 overflow-auto">
<table className="w-full text-left text-xs">
<thead className="bg-secondary/30 text-muted-foreground">
<tr>
{csvPreview.headers.map((header) => (
<th key={header} className="whitespace-nowrap px-3 py-2 font-semibold">{header}</th>
))}
<th className="whitespace-nowrap px-3 py-2 font-semibold">Column</th>
<th className="whitespace-nowrap px-3 py-2 font-semibold">Mapped as</th>
</tr>
</thead>
<tbody>
{csvPreview.sampleRows.map((row, index) => (
<tr key={index} className="border-t border-border text-foreground">
{csvPreview.headers.map((header) => (
<td key={header} className="max-w-48 truncate px-3 py-2">{row[header] ?? ""}</td>
))}
</tr>
))}
{csvPreview.headers.map((header) => {
const mapped = Object.entries(csvMapping).find(([, value]) => value === header)?.[0];
return (
<tr key={header} className="border-t border-border text-foreground">
<td className="max-w-48 truncate px-3 py-2">{header}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{mapped ?? "Not mapped"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
@ -599,10 +600,10 @@ export default function TransactionsPage() {
</div>
<div>
<label className={labelCls}>Account</label>
<select value={manualForm.accountId} onChange={(e) => setManualForm((p) => ({ ...p, accountId: e.target.value }))} className={inputCls}>
<select value={manualForm.accountRef} onChange={(e) => setManualForm((p) => ({ ...p, accountRef: e.target.value }))} className={inputCls}>
<option value=""> No account </option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.institutionName} {a.mask ? `••${a.mask}` : ""}</option>
<option key={a.viewRef} value={a.viewRef}>{a.institutionName} {a.mask ? `••${a.mask}` : ""}</option>
))}
</select>
</div>
@ -794,8 +795,8 @@ export default function TransactionsPage() {
</thead>
<tbody>
{rows.map((row) =>
editingId === row.id ? (
<tr key={row.id} className="border-b border-border bg-secondary/10">
editingRef === row.viewRef ? (
<tr key={row.viewRef} className="border-b border-border bg-secondary/10">
<td className="px-4 py-3" colSpan={4}>
<div className="flex flex-wrap gap-2">
<input
@ -863,12 +864,12 @@ export default function TransactionsPage() {
<td className="px-4 py-3" colSpan={3}>
<div className="flex gap-2 justify-end">
<button onClick={saveEdit} className="rounded bg-primary px-3 py-1 text-[11px] font-bold text-primary-foreground hover:bg-primary/90">Save</button>
<button onClick={() => setEditingId(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
<button onClick={() => setEditingRef(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
</div>
</td>
</tr>
) : (
<tr key={row.id} className={`border-b border-border hover:bg-secondary/20 transition-colors ${row.hidden ? "opacity-50" : ""}`}>
<tr key={row.viewRef} className={`border-b border-border hover:bg-secondary/20 transition-colors ${row.hidden ? "opacity-50" : ""}`}>
<td className="px-4 py-3 font-medium whitespace-nowrap">
{new Date(row.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</td>

View File

@ -10,8 +10,11 @@ const navItems = [
{ href: "/app", label: "Dashboard" },
{ href: "/app/connect", label: "Accounts" },
{ href: "/transactions", label: "Transactions" },
{ href: "/bills", label: "Bills" },
{ href: "/credit-score", label: "Credit Score" },
{ href: "/rules", label: "Rules" },
{ href: "/exports", label: "Exports" },
{ href: "/notifications", label: "Notifications" },
{ href: "/tax", label: "Tax" },
{ href: "/settings/households", label: "Households" },
{ href: "/settings", label: "Settings" },

36
public/sw.js Normal file
View File

@ -0,0 +1,36 @@
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = {};
}
const title = data.title || "LedgerOne";
const options = {
body: data.body || "You have a new LedgerOne notification.",
icon: "/favicon.ico",
badge: "/favicon.ico",
data: {
url: data.url || "/notifications",
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/notifications";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ("focus" in client && client.url.includes(url)) {
return client.focus();
}
}
return self.clients.openWindow(url);
}),
);
});