diff --git a/backend/src/fraudlens/api/routes/cases.py b/backend/src/fraudlens/api/routes/cases.py index 93ae5b3..b61df4c 100644 --- a/backend/src/fraudlens/api/routes/cases.py +++ b/backend/src/fraudlens/api/routes/cases.py @@ -426,7 +426,37 @@ def freeze_requests( query = query.where(FreezeRequest.status == status) rows = s.scalars(query).all() names = _names(s, [who for r in rows for who in (r.requested_by, r.decided_by)]) - return [freeze_view(r, names) for r in rows] + claims = _open_claims(s, {r.wallet_id for r in rows if r.status == "pending"}) + return [freeze_view(r, names) | {"refunds": claims.get(r.wallet_id)} for r in rows] + + +def _open_claims(s: Session, wallets: set[str]) -> dict[str, dict]: + """Victims waiting on each wallet's freeze: those whose fraud is already confirmed are + paid the moment the freeze is approved.""" + if not wallets: + return {} + confirmed = Case.verdict == "confirmed_fraud" + rows = s.execute( + select( + Refund.wallet_id, + func.count(), + func.sum(Refund.amount_claimed), + func.count().filter(confirmed), + func.coalesce(func.sum(Refund.amount_claimed).filter(confirmed), 0), + ) + .join(Case, Case.id == Refund.case_id) + .where(Refund.wallet_id.in_(wallets), Refund.status == "open") + .group_by(Refund.wallet_id) + ).all() + return { + wallet: { + "open": n, + "claimed": float(claimed), + "paid_on_approval": n_confirmed, + "paid_on_approval_claimed": float(confirmed_claimed), + } + for wallet, n, claimed, n_confirmed, confirmed_claimed in rows + } @router.post("/freeze-requests/{request_id}/approve") diff --git a/frontend/app/(console)/approvals/page.tsx b/frontend/app/(console)/approvals/page.tsx index 4f8b2a2..0dce908 100644 --- a/frontend/app/(console)/approvals/page.tsx +++ b/frontend/app/(console)/approvals/page.tsx @@ -6,7 +6,7 @@ import { useMemo, useState } from "react"; import { Id, useSession } from "@/components/session"; import { Async, Badge, Button, Card, Chip, Empty, PageHeader, ReasonDialog, StatusBadge, Table, Td } from "@/components/ui"; import { api, ApiError, useApi } from "@/lib/api"; -import { when, words } from "@/lib/format"; +import { taka, when, words } from "@/lib/format"; import type { Freeze } from "@/lib/types"; const STATUSES = ["pending", "approved", "rejected"] as const; @@ -64,7 +64,7 @@ export default function ApprovalsPage() { <> {outcome &&
{outcome}
} {!canApprove && ( @@ -114,7 +114,16 @@ export default function ApprovalsPage() { #{request.id} - {request.reason} + + {request.reason} + {request.refunds && ( +

+ {request.refunds.paid_on_approval + ? `Approving pays back ${request.refunds.paid_on_approval} victim${request.refunds.paid_on_approval > 1 ? "s" : ""}, ${taka(request.refunds.paid_on_approval_claimed)} claimed` + : `${request.refunds.open} refund claim${request.refunds.open > 1 ? "s" : ""} waiting for a verdict`} +

+ )} + {request.requester}
{when(request.created_at)}
@@ -129,7 +138,9 @@ export default function ApprovalsPage() { you asked for this ) : ( - + )} @@ -149,7 +160,11 @@ export default function ApprovalsPage() { title={dialog.title} label="Decision note" intro={dialog.approve - ? "An approved freeze stops the wallet from sending or receiving until a supervisor lifts it." + ? `An approved freeze stops the wallet from sending or receiving until a supervisor lifts it.${ + dialog.requests.some((request) => request.refunds?.paid_on_approval) + ? " The fraud is already confirmed, so the victims who reported it are refunded from what is left in the wallet in the same step." + : "" + }` : "The wallet stays as it is. The requester sees your note."} confirm={dialog.approve ? "Approve freeze" : "Reject"} variant={dialog.approve ? "danger" : "primary"} diff --git a/frontend/app/(console)/phone/copy.ts b/frontend/app/(console)/phone/copy.ts index de049c8..67c9206 100644 --- a/frontend/app/(console)/phone/copy.ts +++ b/frontend/app/(console)/phone/copy.ts @@ -171,7 +171,7 @@ export const T = { newPayment: { bn: "নতুন লেনদেন", en: "New payment" }, reportForRefund: { bn: "প্রতারিত হয়েছেন? অভিযোগ করে টাকা ফেরত চান", en: "Scammed? Report it to claim your money back" }, refundTitle: { bn: "টাকা ফেরতের অবস্থা", en: "Your refund" }, - refundBy: { bn: "যত দেরিতেই হোক, জানাব", en: "We'll tell you by" }, + refundBy: { bn: "উত্তর পাবেন সর্বশেষ", en: "We'll tell you by" }, refundNoFee: { bn: "টাকা ফেরত পেতে কাউকে পিন, ওটিপি বা কোনো ফি দিতে হয় না। কেউ চাইলে সে প্রতারক।", en: "You never give a PIN, OTP or fee to get a refund. Anyone who asks is a scammer.", diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 7f647f9..5933c46 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -7,11 +7,17 @@ export const metadata: Metadata = { title: { default: "FraudLens", template: "%s · FraudLens" }, description: "Real-time fraud decisions, investigation and model oversight for mobile money.", robots: { index: false, follow: false }, + // Added to an iPhone's home screen, the console opens full screen like an app. + appleWebApp: { capable: true, title: "FraudLens", statusBarStyle: "black-translucent" }, }; export const viewport: Viewport = { themeColor: "#1b1b1b", colorScheme: "dark", + width: "device-width", + initialScale: 1, + // Draw under the notch and the home indicator; the bars pad themselves with the safe areas. + viewportFit: "cover", }; export default async function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/frontend/components/mobile-nav.tsx b/frontend/components/mobile-nav.tsx new file mode 100644 index 0000000..4b8c445 --- /dev/null +++ b/frontend/components/mobile-nav.tsx @@ -0,0 +1,160 @@ +"use client"; + +import Link from "next/link"; +import { type ReactNode, useEffect, useRef } from "react"; + +import { words } from "@/lib/format"; +import { current, isActive, NAV, type NavItem, slug, tabsFor } from "@/lib/nav"; +import type { Me } from "@/lib/types"; + +import { TourButton } from "./tour/tour"; +import { cx } from "./ui"; + +export function Glyph({ d, className }: { d: string; className?: string }) { + return ( + + ); +} + +const MORE = "M5 12h.01M12 12h.01M19 12h.01"; + +/** The phone's title bar: the page's name in the middle, as an iOS navigation bar has it. */ +export function TopBar({ pathname }: { pathname: string }) { + const page = current(pathname); + return ( +
+
+ + + {page?.short ?? ""} + +
+
+ ); +} + +/** The bottom tab bar: the role's four most-used pages and More. Stays under the tour's overlay (z-100). */ +export function TabBar({ me, pathname, moreOpen, onMore }: { me: Me; pathname: string; moreOpen: boolean; onMore: () => void }) { + const tabs = tabsFor(me.role); + const onTab = tabs.some((tab) => isActive(pathname, tab.href)); + const item = "flex min-h-[3.125rem] flex-1 flex-col items-center justify-center gap-0.5 text-[0.625rem] font-medium tracking-wide active:scale-95"; + return ( + + ); +} + +function Row({ item, on, onPick }: { item: NavItem; on: boolean; onPick: () => void }) { + return ( +
  • + + + + + {item.label} + + +
  • + ); +} + +/** Every page, the account and sign-out, as an iOS sheet of inset grouped lists. */ +export function MoreSheet({ + me, pathname, serving, onSignOut, onClose, +}: { + me: Me; pathname: string; serving: ReactNode; onSignOut: () => void; onClose: () => void; +}) { + const ref = useRef(null); + useEffect(() => { + const dialog = ref.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + const initials = me.display_name.split(/\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase(); + return ( + event.target === ref.current && onClose()} + className="sheet lg:hidden" + > +
    + +
    + ); +} diff --git a/frontend/components/session.tsx b/frontend/components/session.tsx index 808cb6f..271a6c1 100644 --- a/frontend/components/session.tsx +++ b/frontend/components/session.tsx @@ -6,9 +6,11 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useM import { api, getToken, setToken, useApi } from "@/lib/api"; import { idKind, maskId, words } from "@/lib/format"; -import type { Me, ModelInfo, Role } from "@/lib/types"; +import { isActive, NAV, NAV_ITEMS, slug } from "@/lib/nav"; +import type { Me, ModelInfo } from "@/lib/types"; import { TOUR_STEPS } from "./tour/steps"; +import { Glyph, MoreSheet, TabBar, TopBar } from "./mobile-nav"; import { TourButton, TourProvider } from "./tour/tour"; import { Badge, cx, ErrorNote, Loading } from "./ui"; @@ -33,64 +35,6 @@ export function useSession(): Session { return session; } -const NAV: { heading: string; items: { href: string; label: string; roles: Role[] }[] }[] = [ - { - heading: "Business", - items: [ - { href: "/", label: "Executive summary", roles: ["analyst", "supervisor", "admin"] }, - { href: "/impact", label: "Impact simulator", roles: ["analyst", "supervisor", "admin"] }, - ], - }, - { - heading: "Investigate", - items: [ - { href: "/alerts", label: "Alert queue", roles: ["analyst", "supervisor"] }, - { href: "/cases", label: "Cases", roles: ["analyst", "supervisor"] }, - { href: "/approvals", label: "Freeze approvals", roles: ["analyst", "supervisor"] }, - { href: "/appeals", label: "Customer appeals", roles: ["analyst", "supervisor"] }, - { href: "/blocklist", label: "Blocklist", roles: ["analyst", "supervisor"] }, - { href: "/refunds", label: "Victim refunds", roles: ["analyst", "supervisor"] }, - ], - }, - { - heading: "Network", - items: [ - { href: "/network", label: "Network explorer", roles: ["analyst", "supervisor"] }, - { href: "/rings", label: "Mule rings", roles: ["analyst", "supervisor"] }, - { href: "/consortium", label: "Mule consortium", roles: ["analyst", "supervisor"] }, - { href: "/agents", label: "Agent risk", roles: ["analyst", "supervisor"] }, - ], - }, - { - heading: "Model and policy", - items: [ - { href: "/model", label: "Model monitoring", roles: ["analyst", "supervisor", "admin"] }, - { href: "/fairness", label: "Fairness report", roles: ["analyst", "supervisor", "admin"] }, - { href: "/policy", label: "Decision policy", roles: ["analyst", "supervisor", "admin"] }, - { href: "/fraud-types", label: "Fraud types", roles: ["analyst", "supervisor", "admin"] }, - { href: "/audit", label: "Audit log", roles: ["supervisor", "admin"] }, - { href: "/webhooks", label: "Webhooks", roles: ["supervisor", "admin"] }, - { href: "/api-keys", label: "Partner API keys", roles: ["supervisor", "admin"] }, - ], - }, - { - heading: "Customer side", - items: [{ href: "/phone", label: "Customer phone demo", roles: ["analyst", "supervisor", "admin"] }], - }, -]; - -/** "Model and policy" → "model-and-policy", "/fraud-types" → "fraud-types". */ -function slug(text: string): string { - return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); -} - -function isActive(pathname: string, href: string): boolean { - if (href === "/") return pathname === "/"; - if (href === "/network") return pathname === "/network" || pathname.startsWith("/wallets"); - if (href === "/alerts") return pathname.startsWith("/alerts") || pathname.startsWith("/decisions"); - return pathname === href || pathname.startsWith(`${href}/`); -} - function ServingNote() { const model = useApi("/v1/model", 30_000); if (!model.data) return null; @@ -113,7 +57,7 @@ export function Console({ children }: { children: ReactNode }) { const [me, setMe] = useState(null); const [error, setError] = useState(null); const [revealed, setRevealed] = useState>(new Set()); - const [menu, setMenu] = useState(false); + const [more, setMore] = useState(false); useEffect(() => { if (!getToken()) { @@ -155,19 +99,15 @@ export function Console({ children }: { children: ReactNode }) { if (error) return
    window.location.reload()} />
    ; if (!session) return ; - const allowed = NAV.flatMap((group) => group.items).find((item) => isActive(pathname, item.href)); + const allowed = NAV_ITEMS.find((item) => isActive(pathname, item.href)); const denied = allowed && !allowed.roles.includes(session.me.role); return ( -
    -
    + setMore(true)} /> + {more && } onSignOut={signOut} onClose={() => setMore(false)} />}
    ); diff --git a/frontend/components/tour/steps.ts b/frontend/components/tour/steps.ts index fa2502f..e138dcf 100644 --- a/frontend/components/tour/steps.ts +++ b/frontend/components/tour/steps.ts @@ -2,7 +2,7 @@ import type { Role } from "@/lib/types"; import type { TourStep } from "./types"; -// Role sets mirror NAV in components/session.tsx, so nobody is walked to a page they cannot open. +// Role sets mirror NAV in lib/nav.ts, so nobody is walked to a page they cannot open. const REVIEW: Role[] = ["analyst", "supervisor"]; const AUDIT: Role[] = ["supervisor", "admin"]; @@ -20,6 +20,7 @@ export const TOUR_STEPS: TourStep[] = [ id: "brand", chapter: "Navigation", target: "brand", + mobileTarget: "bottom-nav", placement: "right", title: "Your console", body: "The sidebar is grouped by job: business impact, investigation, the mule network, model governance and the customer view.", @@ -28,6 +29,7 @@ export const TOUR_STEPS: TourStep[] = [ id: "nav-investigate", chapter: "Navigation", target: "nav-group-investigate", + mobileTarget: "tab-alerts", placement: "right", action: "hover", roles: REVIEW, @@ -38,6 +40,7 @@ export const TOUR_STEPS: TourStep[] = [ id: "nav-governance", chapter: "Navigation", target: "nav-group-model-and-policy", + mobileTarget: "tab-model", placement: "right", action: "hover", roles: ["admin"], @@ -48,6 +51,7 @@ export const TOUR_STEPS: TourStep[] = [ id: "serving", chapter: "Navigation", target: "serving", + mobileTarget: "bottom-nav", placement: "right", title: "What is deciding right now", body: "The live model and policy versions. If the model goes down this turns amber and decisions fall back to rules alone.", @@ -235,6 +239,7 @@ export const TOUR_STEPS: TourStep[] = [ id: "nav-phone", chapter: "Customer demo", target: "nav-phone", + mobileTarget: "tab-more", placement: "right", action: "click", title: "See the customer side", @@ -264,7 +269,7 @@ export const TOUR_STEPS: TourStep[] = [ { id: "done", chapter: "Finish", - title: "You're ready", + title: "Where to start", body: "Start with the highest-risk item in your queue. Every action you take is audited and feeds the next model.", }, ]; diff --git a/frontend/components/tour/tour.tsx b/frontend/components/tour/tour.tsx index e16579b..a4ee940 100644 --- a/frontend/components/tour/tour.tsx +++ b/frontend/components/tour/tour.tsx @@ -1,193 +1,998 @@ "use client"; -import { usePathname, useRouter } from "next/navigation"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +/** + * Guided product tour: a spotlight that glides between `[data-tour]` targets, an animated + * cursor that "shows" the user what to do, a tooltip card with chapter progress, and a + * first-visit welcome sheet. No dependencies: geometry runs in one rAF loop that writes + * styles straight to the DOM, so tracking scroll/resize never re-renders React. + */ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createPortal } from "react-dom"; +import { useRouter } from "next/navigation"; + +import { cx } from "@/components/ui"; import type { Role } from "@/lib/types"; -import { cx } from "../ui"; import { TOUR_SEEN_KEY, type TourApi, type TourStep } from "./types"; -const TourContext = createContext(null); +/* ------------------------------------------------------------------------------------------ */ +/* Context */ +/* ------------------------------------------------------------------------------------------ */ + +const NOOP_API: TourApi = { active: false, start: () => {}, stop: () => {} }; +const TourContext = createContext(NOOP_API); export function useTour(): TourApi { - const api = useContext(TourContext); - if (!api) throw new Error("useTour needs a TourProvider"); - return api; + return useContext(TourContext); } -interface Box { top: number; left: number; width: number; height: number } - -function seen(): boolean { +function markSeen() { try { - return localStorage.getItem(TOUR_SEEN_KEY) === "1"; + window.localStorage.setItem(TOUR_SEEN_KEY, new Date().toISOString()); } catch { - return false; + /* storage blocked: the welcome sheet may show again, which is harmless */ } } -function remember(): void { +function hasSeen(): boolean { try { - localStorage.setItem(TOUR_SEEN_KEY, "1"); + return window.localStorage.getItem(TOUR_SEEN_KEY) !== null; } catch { - /* storage can be blocked: the tour then simply offers itself again */ + return true; // can't remember a dismissal, so don't nag } } -/** Where the step's `data-tour` target is on screen, or null when there is none (a centred card). */ -function locate(target?: string): Box | null { - if (!target) return null; - const el = document.querySelector(`[data-tour="${target}"]`); - if (!el) return null; - el.scrollIntoView({ block: "center", behavior: "smooth" }); - const r = el.getBoundingClientRect(); - return { top: r.top, left: r.left, width: r.width, height: r.height }; +const SECONDS_PER_STEP = 15; + +interface RunState { + /** Index into the role-filtered steps, or null when on the finish card / inactive. */ + index: number | null; + finished: boolean; + /** Bumps on every jump so the overlay can tell a fresh run from a stale one. */ + nonce: number; } export function TourProvider({ steps, role, children }: { steps: TourStep[]; role: Role; children: ReactNode }) { - const router = useRouter(); - const pathname = usePathname(); - const mine = useMemo(() => steps.filter((s) => !s.roles || s.roles.includes(role)), [steps, role]); - const [index, setIndex] = useState(null); - const [box, setBox] = useState(null); + const visible = useMemo(() => steps.filter((s) => !s.roles || s.roles.includes(role)), [steps, role]); + const [run, setRun] = useState({ index: null, finished: false, nonce: 0 }); + const [welcome, setWelcome] = useState(false); - const stop = useCallback(() => { - remember(); - setIndex(null); + const active = run.index !== null || run.finished; + + const goto = useCallback((index: number) => { + setRun((r) => ({ index, finished: false, nonce: r.nonce + 1 })); }, []); const start = useCallback( (fromStepId?: string) => { - const at = fromStepId ? mine.findIndex((s) => s.id === fromStepId) : 0; - setIndex(at < 0 ? 0 : at); + if (visible.length === 0) return; + const at = fromStepId ? visible.findIndex((s) => s.id === fromStepId) : 0; + setWelcome(false); + markSeen(); + goto(Math.max(0, at)); }, - [mine], + [visible, goto], ); - const step = index === null ? null : (mine[index] ?? null); + const stop = useCallback(() => { + markSeen(); + setRun((r) => ({ index: null, finished: false, nonce: r.nonce + 1 })); + }, []); - // Walk to the step's page, then find its target once the page has rendered. + const finish = useCallback(() => { + markSeen(); + setRun((r) => ({ index: null, finished: true, nonce: r.nonce + 1 })); + }, []); + + // First visit: offer the tour once the console has painted. useEffect(() => { - if (!step) return; - if (step.route && step.route !== pathname) { - router.push(step.route); - return; + if (visible.length === 0) return; + const id = window.setTimeout(() => { + if (!hasSeen()) setWelcome(true); + }, 800); + return () => window.clearTimeout(id); + }, [visible.length]); + + const api = useMemo(() => ({ active, start, stop }), [active, start, stop]); + + return ( + + {children} + {welcome && !active && ( + start()} + onLater={() => { + markSeen(); + setWelcome(false); + }} + /> + )} + {active && + createPortal( + goto(0)} + />, + document.body, + )} + + ); +} + +/* ------------------------------------------------------------------------------------------ */ +/* Sidebar button */ +/* ------------------------------------------------------------------------------------------ */ + +/** `compact` renders icon-only (e.g. the mobile top bar); the label stays for screen readers. */ +export function TourButton({ className, compact }: { className?: string; compact?: boolean }) { + const { start, active } = useTour(); + return ( + + ); +} + +function CompassIcon({ className }: { className?: string }) { + return ( + + ); +} + +function SparkleIcon({ className }: { className?: string }) { + return ( + + ); +} + +/* ------------------------------------------------------------------------------------------ */ +/* Welcome sheet */ +/* ------------------------------------------------------------------------------------------ */ + +function WelcomeSheet({ count, onStart, onLater }: { count: number; onStart: () => void; onLater: () => void }) { + const startRef = useRef(null); + const titleId = useId(); + const minutes = Math.max(1, Math.round((count * SECONDS_PER_STEP) / 60)); + + useEffect(() => { + startRef.current?.focus({ preventScroll: true }); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onLater(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onLater]); + + return createPortal( +
    + +