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
22 changes: 22 additions & 0 deletions app/api/metrics/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getMetrics, getMetricsByType } from "@/lib/metrics";

/**
* Issue #224 (#720) — GET /api/metrics
*
* Returns aggregate escrow transaction failure statistics, plus a per-type
* breakdown for richer dashboards.
*/
export async function GET() {
const aggregate = getMetrics();
return NextResponse.json(
{
...aggregate,
by_type: getMetricsByType(),
},
{
// Always reflect current counters; never serve a cached snapshot.
headers: { "Cache-Control": "no-store" },
},
);
}
4 changes: 4 additions & 0 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import PaymentHistoryTab, { type ReleasedEscrow } from "@/components/dashboard/P
import { PlusCircle, Wallet, FileText, ArrowRight, ShieldCheck, Clock, Upload, TrendingUp } from "lucide-react";
import EscrowLabel from "@/components/escrow/EscrowLabel";
import PortfolioSummary from "@/components/dashboard/PortfolioSummary";
import DeadlineBanners from "@/components/dashboard/DeadlineBanners";

const MOCK_RELEASED_ESCROWS = (landlordKey: string): ReleasedEscrow[] => [
{
Expand Down Expand Up @@ -136,6 +137,9 @@ export default function DashboardPage() {
</div>
</header>

{/* Deadline reminder banners (issue #238) */}
<DeadlineBanners escrows={escrows} />

{/* Sticky Stats Header */}
<PortfolioSummary escrows={escrows} releasedEscrows={releasedEscrows} />

Expand Down
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { StellarProvider } from "@/context/StellarContext";
import { ToastProvider } from "@/components/ui/toast-provider";
import { EmailAuthProvider } from "@/context/EmailAuthContext";
import { WebVitals } from "@/components/web-vitals";
import { Analytics } from "@/components/ui/analytics";
import { LocaleDirection } from "@/components/ui/locale-direction";
import { BottomNav } from "@/components/ui/bottom-nav";
import "./globals.css";
Expand Down Expand Up @@ -70,6 +71,7 @@ export default function RootLayout({
>
<LocaleDirection />
<WebVitals />
<Analytics />
<ToastProvider>
<QueryProvider>
<EmailAuthProvider>
Expand Down
95 changes: 95 additions & 0 deletions components/dashboard/DeadlineBanners.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

/**
* Issue #238 (#734) — Landlord deadline reminder banner.
*
* On dashboard load, surfaces a dismissible amber banner for every active escrow
* whose deadline falls within the next 72 hours, so landlords are reminded
* before a deadline lapses. Dismissals are remembered (localStorage) so a banner
* does not reappear on every navigation within the same approaching window.
*/
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AlertTriangle, X } from "lucide-react";
import type { EscrowContract } from "@/lib/stellar/types";
import { getApproachingEscrows, formatTimeUntil } from "./deadline-utils";

const DISMISSED_KEY = "dismissed_deadline_banners";

interface DeadlineBannersProps {
escrows: EscrowContract[];
}

export default function DeadlineBanners({ escrows }: DeadlineBannersProps) {
const nowSeconds = Math.floor(Date.now() / 1000);
const approaching = useMemo(
() => getApproachingEscrows(escrows, nowSeconds),
[escrows, nowSeconds],
);

const [dismissed, setDismissed] = useState<Set<string>>(new Set());

// Load previously dismissed banners once on mount.
useEffect(() => {
try {
const stored = localStorage.getItem(DISMISSED_KEY);
if (stored) setDismissed(new Set(JSON.parse(stored) as string[]));
} catch {
// Ignore malformed storage; show all banners.
}
}, []);

const dismiss = (id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next]));
} catch {
// Non-fatal: dismissal just won't persist.
}
return next;
});
};

const visible = approaching.filter((e) => !dismissed.has(e.id));
if (visible.length === 0) return null;

return (
<div className="space-y-3" aria-label="Deadline reminders" role="region">
{visible.map((escrow) => (
<div
key={escrow.id}
role="alert"
data-testid="deadline-banner"
className="flex items-center gap-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-amber-200"
>
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" />
<div className="flex-1 text-sm">
<span className="font-black uppercase tracking-wider text-amber-300">
Deadline approaching
</span>{" "}
<span className="text-amber-100/90">
Escrow {escrow.id.slice(0, 6)}…{escrow.id.slice(-4)} is due{" "}
{formatTimeUntil(escrow.deadlineEpoch, nowSeconds)}.
</span>
</div>
<Link
href={`/escrow/${escrow.id}`}
className="shrink-0 rounded-lg border border-amber-400/40 px-3 py-1.5 text-[11px] font-black uppercase tracking-widest text-amber-200 transition-colors hover:bg-amber-500/20"
>
Review
</Link>
<button
type="button"
aria-label="Dismiss reminder"
onClick={() => dismiss(escrow.id)}
className="shrink-0 rounded-md p-1 text-amber-300/70 transition-colors hover:bg-amber-500/20 hover:text-amber-100"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
);
}
84 changes: 84 additions & 0 deletions components/dashboard/deadline-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import test from "node:test";
import assert from "node:assert/strict";

import {
getApproachingEscrows,
formatTimeUntil,
DEADLINE_WINDOW_SECONDS,
} from "./deadline-utils.ts";
import type { EscrowContract } from "@/lib/stellar/types";

const NOW = 1_700_000_000; // fixed reference time (seconds)
const HOUR = 3600;

function escrow(
overrides: Partial<EscrowContract> & Pick<EscrowContract, "id">,
): EscrowContract {
return {
landlord: "GLANDLORD",
totalRent: "1000",
deadline: new Date((overrides.deadlineEpoch ?? NOW) * 1000).toISOString(),
totalFunded: 0,
deadlineEpoch: NOW + 48 * HOUR,
status: "active",
...overrides,
};
}

test("includes an active escrow whose deadline is 48h away (acceptance criterion)", () => {
const escrows = [escrow({ id: "E48", deadlineEpoch: NOW + 48 * HOUR })];
const result = getApproachingEscrows(escrows, NOW);
assert.equal(result.length, 1);
assert.equal(result[0].id, "E48");
});

test("excludes deadlines beyond the 72h window", () => {
const escrows = [escrow({ id: "E96", deadlineEpoch: NOW + 96 * HOUR })];
assert.equal(getApproachingEscrows(escrows, NOW).length, 0);
});

test("includes a deadline exactly at the 72h boundary", () => {
const escrows = [
escrow({ id: "E72", deadlineEpoch: NOW + DEADLINE_WINDOW_SECONDS }),
];
assert.equal(getApproachingEscrows(escrows, NOW).length, 1);
});

test("excludes deadlines already in the past", () => {
const escrows = [escrow({ id: "EPast", deadlineEpoch: NOW - HOUR })];
assert.equal(getApproachingEscrows(escrows, NOW).length, 0);
});

test("excludes released/expired escrows even if within the window", () => {
const escrows = [
escrow({ id: "ERel", deadlineEpoch: NOW + 24 * HOUR, status: "released" }),
escrow({ id: "EExp", deadlineEpoch: NOW + 24 * HOUR, status: "expired" }),
];
assert.equal(getApproachingEscrows(escrows, NOW).length, 0);
});

test("includes funded escrows (still active for reminder purposes)", () => {
const escrows = [
escrow({ id: "EFund", deadlineEpoch: NOW + 12 * HOUR, status: "funded" }),
];
assert.equal(getApproachingEscrows(escrows, NOW).length, 1);
});

test("returns multiple approaching escrows, dropping out-of-window ones", () => {
const escrows = [
escrow({ id: "A", deadlineEpoch: NOW + 10 * HOUR }),
escrow({ id: "B", deadlineEpoch: NOW + 70 * HOUR }),
escrow({ id: "C", deadlineEpoch: NOW + 200 * HOUR }),
];
assert.deepEqual(
getApproachingEscrows(escrows, NOW).map((e) => e.id),
["A", "B"],
);
});

test("formatTimeUntil renders days, hours, and minutes", () => {
assert.equal(formatTimeUntil(NOW + 48 * HOUR, NOW), "in 2 days");
assert.equal(formatTimeUntil(NOW + 5 * HOUR, NOW), "in 5 hours");
assert.equal(formatTimeUntil(NOW + 30 * 60, NOW), "in 30 minutes");
assert.equal(formatTimeUntil(NOW + 1 * HOUR, NOW), "in 1 hour");
});
47 changes: 47 additions & 0 deletions components/dashboard/deadline-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Issue #238 (#734) — pure helpers for the deadline reminder banner.
*
* Kept in a JSX-free .ts module so the selection logic is unit-testable via
* `node --test` (which strips .ts but not .tsx). The banner component imports
* these.
*/
import type { EscrowContract } from "@/lib/stellar/types";

/** Deadlines within this window (seconds) trigger a reminder banner. */
export const DEADLINE_WINDOW_SECONDS = 72 * 60 * 60; // 72 hours

/** An escrow is "active" for reminder purposes when not yet released/expired. */
export function isActiveEscrow(escrow: EscrowContract): boolean {
return escrow.status === "active" || escrow.status === "funded";
}

/**
* Returns the escrows whose deadline is approaching: active, in the future, and
* within the 72h window.
*/
export function getApproachingEscrows(
escrows: EscrowContract[],
nowSeconds: number = Math.floor(Date.now() / 1000),
): EscrowContract[] {
return escrows.filter((escrow) => {
if (!isActiveEscrow(escrow)) return false;
const secondsUntil = escrow.deadlineEpoch - nowSeconds;
return secondsUntil > 0 && secondsUntil <= DEADLINE_WINDOW_SECONDS;
});
}

/** Human-readable "in 2 days" / "in 5 hours" until the deadline. */
export function formatTimeUntil(
deadlineEpoch: number,
nowSeconds: number,
): string {
const seconds = Math.max(0, deadlineEpoch - nowSeconds);
const hours = Math.floor(seconds / 3600);
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `in ${days} day${days === 1 ? "" : "s"}`;
}
if (hours >= 1) return `in ${hours} hour${hours === 1 ? "" : "s"}`;
const minutes = Math.floor(seconds / 60);
return `in ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
84 changes: 84 additions & 0 deletions components/ui/analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"use client";

/**
* Issue #222 (#718) — privacy-first page analytics.
*
* Loads Plausible Analytics (cookieless, GDPR-compliant) and exposes a small
* helper for tracking the product's key custom events. Analytics is only loaded
* when a domain is configured AND the visitor has not enabled Do Not Track, so
* privacy is respected by default and the app works with analytics disabled.
*
* Configure via env:
* NEXT_PUBLIC_PLAUSIBLE_DOMAIN — the site domain registered in Plausible
* NEXT_PUBLIC_PLAUSIBLE_SRC — optional self-hosted script URL
*/
import Script from "next/script";

const PLAUSIBLE_DOMAIN = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;
const PLAUSIBLE_SRC =
process.env.NEXT_PUBLIC_PLAUSIBLE_SRC ?? "https://plausible.io/js/script.js";

/** Custom analytics events tracked across the product. */
export type AnalyticsEvent =
| "Escrow Creation Started"
| "Wallet Connected"
| "Contribution Completed";

/** True when the visitor has asked not to be tracked (DNT / GPC). */
export function isDoNotTrackEnabled(): boolean {
if (typeof window === "undefined") return false;
const nav = window.navigator as Navigator & {
msDoNotTrack?: string;
globalPrivacyControl?: boolean;
};
const dnt =
nav.doNotTrack ??
(window as unknown as { doNotTrack?: string }).doNotTrack ??
nav.msDoNotTrack;
return dnt === "1" || dnt === "yes" || nav.globalPrivacyControl === true;
}

/** Whether analytics should load/track at all. */
export function analyticsEnabled(): boolean {
return Boolean(PLAUSIBLE_DOMAIN) && !isDoNotTrackEnabled();
}

type PlausibleFn = (
event: string,
options?: { props?: Record<string, string | number | boolean> },
) => void;

/**
* Track a custom event. No-ops when analytics is disabled, DNT is on, or the
* script hasn't loaded — so callers can fire events unconditionally.
*/
export function trackEvent(
event: AnalyticsEvent,
props?: Record<string, string | number | boolean>,
): void {
if (typeof window === "undefined" || !analyticsEnabled()) return;
const plausible = (window as unknown as { plausible?: PlausibleFn }).plausible;
if (typeof plausible === "function") {
plausible(event, props ? { props } : undefined);
}
}

/**
* Injects the Plausible script. Renders nothing when no domain is configured or
* Do Not Track is enabled. Plausible automatically records a page view on load
* and on client-side navigations.
*/
export function Analytics() {
if (!analyticsEnabled()) return null;

return (
<Script
defer
data-domain={PLAUSIBLE_DOMAIN}
src={PLAUSIBLE_SRC}
strategy="afterInteractive"
/>
);
}

export default Analytics;
Loading
Loading