diff --git a/web/src/app.css b/web/src/app.css index 5635865..3936314 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -572,3 +572,24 @@ opacity: 0; } } + +@keyframes page-enter { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-page-enter { + animation: page-enter 200ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .animate-page-enter { + animation: none; + } +} diff --git a/web/src/app.tsx b/web/src/app.tsx index 2043a07..4f73f74 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -6,7 +6,7 @@ import { ThemeProvider } from "./lib/theme"; import { ClerkProvider } from "clerk-solidjs/start"; import { ClerkLoaded, ClerkLoading, useAuth } from "clerk-solidjs"; import { AppShell } from "./components/layout"; -import { ToastProvider } from "./components/ui"; +import { ToastProvider, ErrorBoundary, PageTransition } from "./components/ui"; import "./app.css"; @@ -71,7 +71,19 @@ export default function App() { root={(props) => ( - {props.children} + + Skip to main content + + + + + {props.children} + + + )} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx index 5564ec0..cedf4c6 100644 --- a/web/src/components/ui/Button.tsx +++ b/web/src/components/ui/Button.tsx @@ -13,6 +13,7 @@ interface ButtonProps { children: JSX.Element; onClick?: (e: MouseEvent) => void; type?: "button" | "submit" | "reset"; + ariaLabel?: string; } const variantClasses: Record = { @@ -67,6 +68,7 @@ export default function Button(props: ButtonProps) { type={props.type ?? "button"} disabled={isDisabled()} onClick={props.onClick} + aria-label={props.ariaLabel} class={cn( "inline-flex items-center justify-center font-medium transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer", variantClasses[variant()], diff --git a/web/src/components/ui/EmptyState.tsx b/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..4d70d84 --- /dev/null +++ b/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,44 @@ +import type { JSX } from "solid-js"; +import { cn } from "~/lib/utils"; +import Button from "./Button"; + +interface EmptyStateProps { + icon?: JSX.Element; + title: string; + description?: string; + action?: { + label: string; + onClick: () => void; + }; + class?: string; +} + +export default function EmptyState(props: EmptyStateProps) { + return ( +
+ {props.icon && ( +
+ {props.icon} +
+ )} +

+ {props.title} +

+ {props.description && ( +

+ {props.description} +

+ )} + {props.action && ( + + )} +
+ ); +} diff --git a/web/src/components/ui/ErrorBoundary.tsx b/web/src/components/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..d45eb4f --- /dev/null +++ b/web/src/components/ui/ErrorBoundary.tsx @@ -0,0 +1,114 @@ +import { createSignal, type JSX } from "solid-js"; +import { ErrorBoundary as SolidErrorBoundary } from "solid-js"; +import Button from "./Button"; + +interface ErrorFallbackProps { + error: Error; + reset: () => void; +} + +function ShieldLogo() { + return ( + + + + + + + + + + + ); +} + +function ErrorFallback(props: ErrorFallbackProps) { + const [expanded, setExpanded] = createSignal(false); + + function handleReport() { + console.error("[ErrorBoundary] Error details:", props.error); + const text = `Error: ${props.error.message}\nStack: ${props.error.stack}`; + const blob = new Blob([text], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "error-report.txt"; + a.click(); + URL.revokeObjectURL(url); + } + + return ( +
+
+ +
+

+ Something went wrong +

+

+ An unexpected error occurred. Please try again or report the issue. +

+
+
+ + +
+ + {expanded() && ( +
+            {props.error.message}
+            {"\n\n"}
+            {props.error.stack}
+          
+ )} +
+
+ ); +} + +interface ErrorBoundaryProps { + children: JSX.Element; +} + +export default function ErrorBoundary(props: ErrorBoundaryProps) { + return ( + ( + + )} + > + {props.children} + + ); +} diff --git a/web/src/components/ui/PageTransition.tsx b/web/src/components/ui/PageTransition.tsx new file mode 100644 index 0000000..22be2c4 --- /dev/null +++ b/web/src/components/ui/PageTransition.tsx @@ -0,0 +1,34 @@ +import { createEffect, createSignal, type JSX } from "solid-js"; +import { useLocation } from "@solidjs/router"; +import { cn } from "~/lib/utils"; + +interface PageTransitionProps { + children: JSX.Element; + class?: string; +} + +export default function PageTransition(props: PageTransitionProps) { + const location = useLocation(); + const [entering, setEntering] = createSignal(true); + + createEffect(() => { + location.pathname; + setEntering(false); + requestAnimationFrame(() => { + requestAnimationFrame(() => setEntering(true)); + }); + }); + + return ( +
+ {props.children} +
+ ); +} diff --git a/web/src/components/ui/Skeleton.tsx b/web/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..dbd3c92 --- /dev/null +++ b/web/src/components/ui/Skeleton.tsx @@ -0,0 +1,96 @@ +import { For } from "solid-js"; +import { cn } from "~/lib/utils"; + +interface SkeletonTextProps { + lines?: number; + width?: string; + class?: string; +} + +export function SkeletonText(props: SkeletonTextProps) { + const lines = () => props.lines ?? 3; + return ( +
+ + {(_, i) => ( +
+ )} + +
+ ); +} + +interface SkeletonCardProps { + class?: string; +} + +export function SkeletonCard(props: SkeletonCardProps) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +interface SkeletonAvatarProps { + size?: number; + class?: string; +} + +export function SkeletonAvatar(props: SkeletonAvatarProps) { + const size = () => props.size ?? 40; + return ( +
+ ); +} + +interface SkeletonTableProps { + rows?: number; + columns?: number; + class?: string; +} + +export function SkeletonTable(props: SkeletonTableProps) { + const rows = () => props.rows ?? 5; + const columns = () => props.columns ?? 4; + return ( +
+ + {() => ( +
+ + {(_, i) => ( +
+ )} + +
+ )} +
+
+ ); +} diff --git a/web/src/components/ui/Toast.tsx b/web/src/components/ui/Toast.tsx index d702af9..bc1d2d5 100644 --- a/web/src/components/ui/Toast.tsx +++ b/web/src/components/ui/Toast.tsx @@ -106,6 +106,7 @@ function ToastContainer() { class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm" role="region" aria-label="Notifications" + aria-live="polite" > {(toast) => ( diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts index 4638dbd..b4e746f 100644 --- a/web/src/components/ui/index.ts +++ b/web/src/components/ui/index.ts @@ -3,4 +3,13 @@ export { default as Card } from "./Card"; export { default as Input } from "./Input"; export { default as Badge } from "./Badge"; export { default as Modal } from "./Modal"; +export { default as ErrorBoundary } from "./ErrorBoundary"; +export { default as PageTransition } from "./PageTransition"; +export { default as EmptyState } from "./EmptyState"; +export { + SkeletonText, + SkeletonCard, + SkeletonAvatar, + SkeletonTable, +} from "./Skeleton"; export { ToastProvider, useToast } from "./Toast"; diff --git a/web/src/components/ui/ui.test.tsx b/web/src/components/ui/ui.test.tsx index b94d71d..c8a0789 100644 --- a/web/src/components/ui/ui.test.tsx +++ b/web/src/components/ui/ui.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render } from "solid-js/web"; +import { ErrorBoundary as SolidErrorBoundary } from "solid-js"; import type { JSX } from "solid-js"; import Button from "./Button"; @@ -8,6 +9,14 @@ import Input from "./Input"; import Badge from "./Badge"; import Modal from "./Modal"; import { ToastProvider, useToast } from "./Toast"; +import ErrorBoundary from "./ErrorBoundary"; +import EmptyState from "./EmptyState"; +import { + SkeletonText, + SkeletonCard, + SkeletonAvatar, + SkeletonTable, +} from "./Skeleton"; function mount(comp: () => JSX.Element): HTMLDivElement { const container = document.createElement("div"); @@ -502,3 +511,101 @@ describe("Toast", () => { expect(toastEl.className).toContain("bg-[var(--color-error-bg)]"); }); }); + +describe("ErrorBoundary", () => { + it("renders children when no error", () => { + mount(() => ( + +

All good

+
+ )); + expect(document.body.textContent).toContain("All good"); + }); + + it("catches errors and shows fallback UI", () => { + const Throwing = () => { + throw new Error("Test error"); + }; + mount(() => ( + + + + )); + expect(document.body.textContent).toContain("Something went wrong"); + expect(document.body.textContent).toContain("Try again"); + expect(document.body.textContent).toContain("Report issue"); + }); +}); + +describe("Skeleton", () => { + it("SkeletonText renders with aria-label", () => { + mount(() => ); + const el = document.querySelector("[role='status']")!; + expect(el).toBeTruthy(); + expect(el.getAttribute("aria-label")).toBe("Loading"); + }); + + it("SkeletonText renders correct number of lines", () => { + mount(() => ); + const bars = document.querySelectorAll("[role='status'] > div"); + expect(bars.length).toBe(5); + }); + + it("SkeletonCard renders with aria-label", () => { + mount(() => ); + const el = document.querySelector("[role='status']")!; + expect(el).toBeTruthy(); + expect(el.getAttribute("aria-label")).toBe("Loading"); + }); + + it("SkeletonAvatar renders with correct size", () => { + mount(() => ); + const el = document.querySelector("[role='status']")! as HTMLElement; + expect(el.style.width).toBe("60px"); + expect(el.style.height).toBe("60px"); + }); + + it("SkeletonTable renders correct number of rows", () => { + mount(() => ); + const rows = document.querySelectorAll("[role='status'] > div"); + expect(rows.length).toBe(3); + }); +}); + +describe("EmptyState", () => { + it("renders title and description", () => { + mount(() => ( + + )); + expect(document.body.textContent).toContain("No items"); + expect(document.body.textContent).toContain("There are no items to display."); + }); + + it("renders action button when provided", () => { + const onClick = vi.fn(); + mount(() => ( + + )); + const btn = document.querySelector("button")!; + expect(btn).toBeTruthy(); + expect(btn.textContent).toContain("Add item"); + btn.click(); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("renders icon when provided", () => { + mount(() => ( + } + /> + )); + expect(document.querySelector("[data-testid='test-icon']")).toBeTruthy(); + }); +}); diff --git a/web/src/routes/(webapp)/darkwatch.tsx b/web/src/routes/(webapp)/darkwatch.tsx index e07f5d6..80d605a 100644 --- a/web/src/routes/(webapp)/darkwatch.tsx +++ b/web/src/routes/(webapp)/darkwatch.tsx @@ -1,12 +1,22 @@ -import { createSignal, createResource, For, Show } from "solid-js"; +import { createSignal, createResource, For, Show, Suspense } from "solid-js"; import { Title } from "@solidjs/meta"; import { Sidebar, TopBar } from "~/components/dashboard"; -import { Button, Input, Card, Badge } from "~/components/ui"; +import { Button, Input, Card, Badge, EmptyState, SkeletonText, SkeletonTable } from "~/components/ui"; import { api } from "~/lib/api"; +function WatchlistIcon() { + return ( + + + + + ); +} + export default function DarkWatchPage() { const [sidebarOpen, setSidebarOpen] = createSignal(false); const [itemValue, setItemValue] = createSignal(""); + const [adding, setAdding] = createSignal(false); const [watchlist, { refetch: refetchWatchlist }] = createResource( () => api.darkwatch.getWatchlist.query(), { initialValue: [] }, @@ -18,10 +28,15 @@ export default function DarkWatchPage() { async function addItem() { const val = itemValue().trim(); if (!val) return; - const type = val.includes("@") ? "EMAIL" : "PHONE"; - await api.darkwatch.addWatchlistItem.mutate({ type, value: val }); - setItemValue(""); - refetchWatchlist(); + setAdding(true); + try { + const type = val.includes("@") ? "EMAIL" : "PHONE"; + await api.darkwatch.addWatchlistItem.mutate({ type, value: val }); + setItemValue(""); + refetchWatchlist(); + } finally { + setAdding(false); + } } async function removeItem(itemId: string) { @@ -35,7 +50,7 @@ export default function DarkWatchPage() { setSidebarOpen(false)} />
setSidebarOpen(v => !v)} /> -
+

DarkWatch

@@ -47,7 +62,7 @@ export default function DarkWatchPage() { value={itemValue()} onInput={(e) => setItemValue(e.currentTarget.value)} /> - +
@@ -55,45 +70,60 @@ export default function DarkWatchPage() {

Watchlist

-
- - {(item: Record) => ( -
-
-

{String(item.value ?? "")}

-

{String(item.type ?? "")}

-
- -
- )} -
-
+
}> + + + {(item: Record) => ( +
+
+

{String(item.value ?? "")}

+

{String(item.type ?? "")}

+
+ +
+ )} +
+
+ }> + } + title="No watchlist items yet" + description="Add an email or phone number to monitor for dark web exposure." + action={{ label: "Add first item", onClick: () => document.querySelector("input")?.focus() }} + /> + +

Recent Exposures

-
- - {(exp: Record) => ( -
-

{String(exp.title ?? "")}

-

{String(exp.description ?? "")}

- - {String(exp.severity ?? "")} - -
- )} -
- -
- No exposures found +
}> + 0} fallback={ + + }> +
+ + {(exp: Record) => ( +
+

{String(exp.title ?? "")}

+

{String(exp.description ?? "")}

+ + {String(exp.severity ?? "")} + +
+ )} +
-
+
diff --git a/web/src/routes/(webapp)/dashboard.tsx b/web/src/routes/(webapp)/dashboard.tsx index 24a85ff..a02b8fe 100644 --- a/web/src/routes/(webapp)/dashboard.tsx +++ b/web/src/routes/(webapp)/dashboard.tsx @@ -1,6 +1,7 @@ import { createSignal, Suspense } from "solid-js"; import { Title } from "@solidjs/meta"; import { Sidebar, TopBar, ThreatScoreWidget, AlertFeedWidget, ExposureWidget, VoicePrintWidget, SpamShieldWidget, HomeTitleWidget, RemoveBrokersWidget, QuickActionsWidget } from "~/components/dashboard"; +import { SkeletonCard } from "~/components/ui"; export default function DashboardPage() { const [sidebarOpen, setSidebarOpen] = createSignal(false); @@ -11,38 +12,38 @@ export default function DashboardPage() { setSidebarOpen(false)} />
setSidebarOpen(v => !v)} /> -
+

Dashboard

-
- }> +
+ }> - }> + }> - }> + }>
- }> + }> - }> + }>
- }> + }> - }> + }> - }> + }>
diff --git a/web/src/routes/(webapp)/hometitle.tsx b/web/src/routes/(webapp)/hometitle.tsx index 4a90fb3..30b77d6 100644 --- a/web/src/routes/(webapp)/hometitle.tsx +++ b/web/src/routes/(webapp)/hometitle.tsx @@ -1,11 +1,21 @@ -import { createSignal, createResource, For, Show } from "solid-js"; +import { createSignal, createResource, For, Show, Suspense } from "solid-js"; import { Title } from "@solidjs/meta"; import { Sidebar, TopBar } from "~/components/dashboard"; -import { Button, Input, Card } from "~/components/ui"; +import { Button, Input, Card, EmptyState, SkeletonTable } from "~/components/ui"; import { api } from "~/lib/api"; +function HomeIcon() { + return ( + + + + + ); +} + export default function HomeTitlePage() { const [sidebarOpen, setSidebarOpen] = createSignal(false); + const [adding, setAdding] = createSignal(false); const [address, setAddress] = createSignal(""); const [properties, { refetch }] = createResource( () => api.hometitle.getProperties.query(), @@ -13,9 +23,14 @@ export default function HomeTitlePage() { ); async function addProperty() { - await api.hometitle.addProperty.mutate({ address: address(), parcelId: "", ownerName: "" }); - setAddress(""); - refetch(); + setAdding(true); + try { + await api.hometitle.addProperty.mutate({ address: address(), parcelId: "", ownerName: "" }); + setAddress(""); + refetch(); + } finally { + setAdding(false); + } } async function removeProperty(propertyId: string) { @@ -29,7 +44,7 @@ export default function HomeTitlePage() { setSidebarOpen(false)} />
setSidebarOpen(v => !v)} /> -
+

HomeTitle

@@ -41,7 +56,7 @@ export default function HomeTitlePage() { value={address()} onInput={(e) => setAddress(e.currentTarget.value)} /> - +
@@ -49,23 +64,29 @@ export default function HomeTitlePage() {

Monitored Properties

-
- - {(prop: Record) => ( -
-

{String(prop.address ?? "")}

- -
- )} -
- -
- No properties monitored +
}> + 0} fallback={ + } + title="No properties monitored" + description="Add a property address to monitor for title fraud and ownership changes." + action={{ label: "Add property", onClick: () => document.querySelector("input")?.focus() }} + /> + }> +
+ + {(prop: Record) => ( +
+

{String(prop.address ?? "")}

+ +
+ )} +
-
+
diff --git a/web/src/routes/(webapp)/removebrokers.tsx b/web/src/routes/(webapp)/removebrokers.tsx index 6971d4d..a39eeb9 100644 --- a/web/src/routes/(webapp)/removebrokers.tsx +++ b/web/src/routes/(webapp)/removebrokers.tsx @@ -1,9 +1,19 @@ -import { createSignal, createResource, For, Show } from "solid-js"; +import { createSignal, createResource, For, Show, Suspense } from "solid-js"; import { Title } from "@solidjs/meta"; import { Sidebar, TopBar } from "~/components/dashboard"; -import { Button, Card } from "~/components/ui"; +import { Button, Card, EmptyState, SkeletonCard, SkeletonTable } from "~/components/ui"; import { api } from "~/lib/api"; +function BrokerIcon() { + return ( + + + + + + ); +} + export default function RemoveBrokersPage() { const [sidebarOpen, setSidebarOpen] = createSignal(false); const [brokers] = createResource( @@ -31,70 +41,86 @@ export default function RemoveBrokersPage() { setSidebarOpen(false)} />
setSidebarOpen(v => !v)} /> -
+

RemoveBrokers

- -
- -

- {String((stats() as Record)?.totalRequests ?? 0)} -

-

Total Requests

-
- -

- {String((stats() as Record)?.completedRequests ?? 0)} -

-

Completed

-
- -

- {String((stats() as Record)?.pendingRequests ?? 0)} -

-

Pending

-
-
-
+
}> + +
+ +

+ {String((stats() as Record)?.totalRequests ?? 0)} +

+

Total Requests

+
+ +

+ {String((stats() as Record)?.completedRequests ?? 0)} +

+

Completed

+
+ +

+ {String((stats() as Record)?.pendingRequests ?? 0)} +

+

Pending

+
+
+
+

Data Brokers

-
- - {(broker: Record) => ( -
-

{String(broker.name ?? "")}

- -
- )} -
-
+
}> + 0} fallback={ + } + title="No data brokers found" + description="Your broker registry is empty. We'll scan for brokers automatically." + /> + }> +
+ + {(broker: Record) => ( +
+

{String(broker.name ?? "")}

+ +
+ )} +
+
+
+

Removal Requests

-
- - {(req: Record) => ( -
-

{String(req.brokerName ?? "")}

-

Status: {String(req.status ?? "")}

-
- )} -
- -
- No removal requests yet +
}> + 0} fallback={ + + }> +
+ + {(req: Record) => ( +
+

{String(req.brokerName ?? "")}

+

Status: {String(req.status ?? "")}

+
+ )} +
-
+
diff --git a/web/src/routes/(webapp)/settings.tsx b/web/src/routes/(webapp)/settings.tsx index 1b06674..48e2bb0 100644 --- a/web/src/routes/(webapp)/settings.tsx +++ b/web/src/routes/(webapp)/settings.tsx @@ -27,7 +27,7 @@ export default function SettingsPage() { setSidebarOpen(false)} />
setSidebarOpen(v => !v)} /> -
+

Settings

diff --git a/web/src/routes/(webapp)/spamshield.tsx b/web/src/routes/(webapp)/spamshield.tsx index 6d3cc6b..7e68c20 100644 --- a/web/src/routes/(webapp)/spamshield.tsx +++ b/web/src/routes/(webapp)/spamshield.tsx @@ -1,12 +1,21 @@ -import { createSignal, createResource, For, Show } from "solid-js"; +import { createSignal, createResource, For, Show, Suspense } from "solid-js"; import { Title } from "@solidjs/meta"; import { Sidebar, TopBar } from "~/components/dashboard"; -import { Button, Input, Card, Badge } from "~/components/ui"; +import { Button, Input, Card, Badge, EmptyState, SkeletonTable, SkeletonText } from "~/components/ui"; import { api } from "~/lib/api"; +function ShieldIcon() { + return ( + + + + ); +} + export default function SpamShieldPage() { const [sidebarOpen, setSidebarOpen] = createSignal(false); const [phoneNumber, setPhoneNumber] = createSignal(""); + const [checking, setChecking] = createSignal(false); const [checkResult, setCheckResult] = createSignal | null>(null); const [rulesResult, { refetch }] = createResource( () => api.spamshield.getRules.query(), @@ -19,8 +28,13 @@ export default function SpamShieldPage() { }; async function checkNumber() { - const result = await api.spamshield.checkNumber.query({ phoneNumber: phoneNumber() }); - setCheckResult(result as Record); + setChecking(true); + try { + const result = await api.spamshield.checkNumber.query({ phoneNumber: phoneNumber() }); + setCheckResult(result as Record); + } finally { + setChecking(false); + } } async function deleteRule(ruleId: string) { @@ -34,7 +48,7 @@ export default function SpamShieldPage() { setSidebarOpen(false)} />
setSidebarOpen(v => !v)} /> -
+

SpamShield

@@ -46,7 +60,7 @@ export default function SpamShieldPage() { value={phoneNumber()} onInput={(e) => setPhoneNumber(e.currentTarget.value)} /> - +
@@ -59,26 +73,32 @@ export default function SpamShieldPage() {

Blocking Rules

-
- - {(rule: Record) => ( -
-
-

{String(rule.pattern ?? "")}

- {String(rule.action ?? "")} -
- -
- )} -
- -
- No rules configured +
}> + 0} fallback={ + } + title="No custom rules" + description="Create blocking rules to automatically filter unwanted calls." + action={{ label: "Create rule", onClick: () => {} }} + /> + }> +
+ + {(rule: Record) => ( +
+
+

{String(rule.pattern ?? "")}

+ {String(rule.action ?? "")} +
+ +
+ )} +
-
+
diff --git a/web/src/routes/(webapp)/voiceprint.tsx b/web/src/routes/(webapp)/voiceprint.tsx index 3903a2d..de5dccb 100644 --- a/web/src/routes/(webapp)/voiceprint.tsx +++ b/web/src/routes/(webapp)/voiceprint.tsx @@ -1,9 +1,20 @@ -import { createSignal, createResource, For, Show } from "solid-js"; +import { createSignal, createResource, For, Show, Suspense } from "solid-js"; import { Title } from "@solidjs/meta"; import { Sidebar, TopBar } from "~/components/dashboard"; -import { Button, Card, Badge } from "~/components/ui"; +import { Button, Card, EmptyState, SkeletonTable } from "~/components/ui"; import { api } from "~/lib/api"; +function VoiceIcon() { + return ( + + + + + + + ); +} + export default function VoicePrintPage() { const [sidebarOpen, setSidebarOpen] = createSignal(false); const [enrollments, { refetch }] = createResource( @@ -22,7 +33,7 @@ export default function VoicePrintPage() { setSidebarOpen(false)} />
setSidebarOpen(v => !v)} /> -
+

VoicePrint

@@ -30,26 +41,32 @@ export default function VoicePrintPage() {

Voice Enrollments

-
- - {(enr: Record) => ( -
-
-

{String(enr.name ?? "")}

-

Created {String(enr.createdAt ?? "")}

-
- -
- )} -
- -
- No voice enrollments yet +
}> + 0} fallback={ + } + title="No voice enrollments yet" + description="Enroll your voice to enable voice biometric authentication." + action={{ label: "Enroll voice", onClick: () => {} }} + /> + }> +
+ + {(enr: Record) => ( +
+
+

{String(enr.name ?? "")}

+

Created {String(enr.createdAt ?? "")}

+
+ +
+ )} +
-
+
diff --git a/web/src/routes/[...404].tsx b/web/src/routes/[...404].tsx index 4ea71ec..e9ed4ec 100644 --- a/web/src/routes/[...404].tsx +++ b/web/src/routes/[...404].tsx @@ -1,19 +1,56 @@ import { Title } from "@solidjs/meta"; import { HttpStatusCode } from "@solidjs/start"; +import { A } from "@solidjs/router"; +import { Button } from "~/components/ui"; export default function NotFound() { return ( -
- Not Found +
+ Not Found — ShieldAI -

Page Not Found

-

- Visit{" "} - - start.solidjs.com - {" "} - to learn how to build SolidStart apps. -

+
+ + + + + + + + + + +
+

404

+

+ The page you're looking for doesn't exist or has been moved. +

+
+ + + +
); }