This commit is contained in:
2026-02-25 18:19:31 +00:00
parent 3e692eecb4
commit 378df9b47f
25 changed files with 506 additions and 159 deletions

View File

@@ -12,7 +12,7 @@ POSTGRES_DB=pulsescore
PORT=8080
# CORS — comma-separated list of allowed origins
CORS_ALLOWED_ORIGINS=https://yourdomain.com
CORS_ALLOWED_ORIGINS=https://galdr.subcult.tv
# Rate limiting — requests per minute per IP
RATE_LIMIT_RPM=100
@@ -21,7 +21,7 @@ RATE_LIMIT_RPM=100
STRIPE_BILLING_SECRET_KEY=
STRIPE_BILLING_PUBLISHABLE_KEY=
STRIPE_BILLING_WEBHOOK_SECRET=
STRIPE_BILLING_PORTAL_RETURN_URL=https://yourdomain.com/settings/billing
STRIPE_BILLING_PORTAL_RETURN_URL=https://galdr.subcult.tv/settings/billing
STRIPE_BILLING_PRICE_GROWTH_MONTHLY=
STRIPE_BILLING_PRICE_GROWTH_ANNUAL=
STRIPE_BILLING_PRICE_SCALE_MONTHLY=

View File

@@ -24,8 +24,6 @@ services:
dockerfile: Dockerfile
container_name: pulsescore-api
restart: unless-stopped
ports:
- "${PORT:-8080}:8080"
environment:
PORT: "8080"
ENVIRONMENT: production
@@ -37,6 +35,7 @@ services:
condition: service_healthy
networks:
- pulsescore-net
- web
web:
build:
@@ -44,12 +43,10 @@ services:
dockerfile: Dockerfile
container_name: pulsescore-web
restart: unless-stopped
ports:
- "80:80"
depends_on:
- api
networks:
- pulsescore-net
- web
volumes:
pgdata:
@@ -57,3 +54,5 @@ volumes:
networks:
pulsescore-net:
driver: bridge
web:
external: true

6
go.mod
View File

@@ -9,7 +9,10 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.8.0
github.com/stripe/stripe-go/v81 v81.4.0
golang.org/x/crypto v0.48.0
golang.org/x/sync v0.19.0
golang.org/x/time v0.14.0
)
require (
@@ -17,10 +20,7 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/stripe/stripe-go/v81 v81.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
)

2
go.sum
View File

@@ -37,6 +37,8 @@ github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaD
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

17
web/package-lock.json generated
View File

@@ -65,6 +65,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1750,6 +1751,7 @@
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1760,6 +1762,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1825,6 +1828,7 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@@ -2102,6 +2106,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2227,6 +2232,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2721,6 +2727,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3888,6 +3895,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3970,6 +3978,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3979,6 +3988,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3998,6 +4008,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -4098,7 +4109,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -4322,6 +4334,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4438,6 +4451,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -4559,6 +4573,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import api from "@/lib/api";
import { formatCurrency, relativeTime } from "@/lib/format";
import HealthScoreBadge from "@/components/HealthScoreBadge";
import { Loader2 } from "lucide-react";
interface AtRiskCustomer {
id: string;
name: string;
health_score: number;
risk_level: "green" | "yellow" | "red";
mrr: number;
last_seen_at: string;
}
export default function AtRiskCustomersTable() {
const [customers, setCustomers] = useState<AtRiskCustomer[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
async function fetch() {
try {
const { data } = await api.get("/customers", {
params: {
sort: "health_score",
order: "asc",
per_page: 5,
},
});
const list = data.customers ?? data.data ?? data ?? [];
setCustomers(Array.isArray(list) ? list : []);
} catch {
setCustomers([]);
} finally {
setLoading(false);
}
}
fetch();
}, []);
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
At-Risk Customers
</h3>
<Link
to="/customers?sort=health_score&order=asc"
className="text-xs font-medium text-indigo-600 hover:underline dark:text-indigo-400"
>
View all
</Link>
</div>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
) : customers.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No at-risk customers
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-gray-200 text-xs uppercase text-gray-500 dark:border-gray-700 dark:text-gray-400">
<tr>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Score</th>
<th className="pb-2 pr-4">MRR</th>
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody>
{customers.map((c) => (
<tr
key={c.id}
onClick={() => navigate(`/customers/${c.id}`)}
className="cursor-pointer border-b border-gray-100 last:border-0 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800"
>
<td className="py-3 pr-4 font-medium text-gray-900 dark:text-gray-100">
{c.name}
</td>
<td className="py-3 pr-4">
<HealthScoreBadge
score={c.health_score}
riskLevel={c.risk_level}
size="sm"
showLabel
/>
</td>
<td className="py-3 pr-4 text-gray-700 dark:text-gray-300">
{formatCurrency(c.mrr)}
</td>
<td className="py-3 text-gray-500 dark:text-gray-400">
{relativeTime(c.last_seen_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,35 @@
import { useNavigate } from "react-router-dom";
import HealthScoreBadge from "@/components/HealthScoreBadge";
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
ShieldCheck,
ShieldAlert,
ShieldX,
} from "lucide-react";
import { formatCurrency, relativeTime } from "@/lib/format";
const riskConfig = {
green: {
label: "Healthy",
Icon: ShieldCheck,
className:
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
},
yellow: {
label: "At Risk",
Icon: ShieldAlert,
className:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
},
red: {
label: "Critical",
Icon: ShieldX,
className:
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
},
} as const;
export interface Customer {
id: string;
@@ -20,27 +49,6 @@ interface CustomerTableProps {
onSort: (field: string) => void;
}
function formatCurrency(cents: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
}).format(cents / 100);
}
function relativeTime(dateStr: string): string {
if (!dateStr) return "—";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
const columns = [
{ key: "name", label: "Name" },
{ key: "company", label: "Company" },
@@ -122,17 +130,18 @@ export default function CustomerTable({
/>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
c.risk_level === "green"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: c.risk_level === "yellow"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
}`}
>
{c.risk_level}
</span>
{(() => {
const config = riskConfig[c.risk_level];
const Icon = config.Icon;
return (
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${config.className}`}
>
<Icon className="h-3 w-3" />
{config.label}
</span>
);
})()}
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
{relativeTime(c.last_seen_at)}

View File

@@ -18,7 +18,7 @@ export default function EmptyState({
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 text-gray-400 dark:text-gray-500">
<div className="mb-4 text-gray-400 dark:text-gray-500" aria-hidden="true">
{icon ?? <Inbox className="h-12 w-12" />}
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">

View File

@@ -9,6 +9,7 @@ import {
Circle,
Loader2,
} from "lucide-react";
import { relativeTime } from "@/lib/format";
interface CustomerEvent {
id: string;
@@ -36,19 +37,6 @@ const eventIcons: Record<string, { icon: typeof CheckCircle; color: string }> =
"ticket.opened": { icon: Flag, color: "text-yellow-500" },
};
function relativeTime(dateStr: string): string {
if (!dateStr) return "—";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
export default function EventTimeline({ customerId }: { customerId: string }) {
const [events, setEvents] = useState<CustomerEvent[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -1,11 +1,15 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { LogOut, User, Sun, Moon, Monitor } from "lucide-react";
import { LogOut, User, Sun, Moon, Monitor, Menu } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import NotificationBell from "@/components/NotificationBell";
export default function Header() {
interface HeaderProps {
onOpenMobileNav?: () => void;
}
export default function Header({ onOpenMobileNav }: HeaderProps) {
const { user, organization, logout } = useAuth();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
@@ -22,6 +26,15 @@ export default function Header() {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (!menuOpen) return;
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") setMenuOpen(false);
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [menuOpen]);
function handleLogout() {
logout();
navigate("/login");
@@ -45,6 +58,15 @@ export default function Header() {
return (
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900 sm:px-6">
<div className="flex items-center gap-2">
{onOpenMobileNav && (
<button
onClick={onOpenMobileNav}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 md:hidden"
aria-label="Open navigation"
>
<Menu className="h-5 w-5" />
</button>
)}
{organization && (
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{organization.name}
@@ -59,6 +81,7 @@ export default function Header() {
onClick={cycleTheme}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
title={`Theme: ${theme}`}
aria-label={`Toggle theme, current: ${theme}`}
>
{themeIcon}
</button>
@@ -67,6 +90,7 @@ export default function Header() {
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
aria-label="User menu"
>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300">
{user?.first_name?.[0]}

View File

@@ -2,6 +2,7 @@ interface HealthScoreBadgeProps {
score: number;
riskLevel?: "green" | "yellow" | "red";
size?: "sm" | "md" | "lg";
showLabel?: boolean;
}
const sizeClasses = {
@@ -16,6 +17,12 @@ const colorClasses = {
red: "bg-red-500 dark:bg-red-600",
};
const riskLabels = {
green: "Healthy",
yellow: "At Risk",
red: "Critical",
};
function deriveRiskLevel(score: number): "green" | "yellow" | "red" {
if (score >= 70) return "green";
if (score >= 40) return "yellow";
@@ -26,15 +33,28 @@ export default function HealthScoreBadge({
score,
riskLevel,
size = "md",
showLabel = false,
}: HealthScoreBadgeProps) {
const level = riskLevel ?? deriveRiskLevel(score);
const label = riskLabels[level];
return (
<span
className={`inline-flex items-center justify-center rounded-full font-semibold text-white ${sizeClasses[size]} ${colorClasses[level]}`}
title={`Health score: ${score}`}
className="inline-flex items-center gap-2"
role="img"
aria-label={`Health score: ${score}, ${label}`}
>
{score}
<span
className={`inline-flex items-center justify-center rounded-full font-semibold text-white ${sizeClasses[size]} ${colorClasses[level]}`}
>
{score}
</span>
{showLabel && (
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</span>
)}
{!showLabel && <span className="sr-only">{label}</span>}
</span>
);
}

View File

@@ -1,5 +1,6 @@
import IntegrationStatusBadge from "@/components/IntegrationStatusBadge";
import { RefreshCw, Unplug } from "lucide-react";
import { relativeTime } from "@/lib/format";
interface IntegrationCardProps {
provider: string;
@@ -10,18 +11,6 @@ interface IntegrationCardProps {
onDisconnect?: () => void;
}
function relativeTime(dateStr?: string): string {
if (!dateStr) return "Never";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export default function IntegrationCard({
provider,
status,

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Bell } from "lucide-react";
import { notificationsApi, type AppNotification } from "@/lib/api";
import { relativeTime } from "@/lib/format";
const POLL_INTERVAL = 30_000;
@@ -40,6 +41,16 @@ export default function NotificationBell() {
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Close on Escape
useEffect(() => {
if (!open) return;
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open]);
async function toggleOpen() {
if (!open) {
setLoading(true);
@@ -85,16 +96,6 @@ export default function NotificationBell() {
}
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
return (
<div className="relative" ref={ref}>
<button
@@ -156,7 +157,7 @@ export default function NotificationBell() {
{n.message}
</p>
<p className="mt-1 text-[10px] text-gray-400 dark:text-gray-500">
{timeAgo(n.created_at)}
{relativeTime(n.created_at)}
</p>
</div>
</div>

View File

@@ -6,7 +6,6 @@ import {
Settings,
ChevronLeft,
ChevronRight,
Menu,
X,
} from "lucide-react";
@@ -86,6 +85,7 @@ export default function Sidebar({
<button
onClick={onCloseMobile}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label="Close navigation"
>
<X className="h-5 w-5" />
</button>
@@ -121,6 +121,7 @@ export default function Sidebar({
onClick={onToggleCollapse}
className="flex w-full items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
@@ -131,14 +132,6 @@ export default function Sidebar({
</div>
</aside>
{/* Mobile hamburger (rendered in header area but controlled here) */}
<button
onClick={() => (mobileOpen ? onCloseMobile() : onToggleCollapse())}
className="fixed left-4 top-4 z-30 rounded-lg bg-white p-2 shadow-md dark:bg-gray-800 md:hidden"
aria-label="Toggle navigation"
>
<Menu className="h-5 w-5 text-gray-700 dark:text-gray-300" />
</button>
</>
);
}

View File

@@ -9,6 +9,12 @@ interface StatCardProps {
icon?: ReactNode;
}
const trendLabels = {
up: "increased",
down: "decreased",
neutral: "unchanged",
};
export default function StatCard({
title,
value,
@@ -37,7 +43,9 @@ export default function StatCard({
{title}
</p>
{icon && (
<span className="text-gray-400 dark:text-gray-500">{icon}</span>
<span className="text-gray-400 dark:text-gray-500" aria-hidden="true">
{icon}
</span>
)}
</div>
<p className="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">
@@ -45,8 +53,10 @@ export default function StatCard({
</p>
{trend !== undefined && (
<div className={`mt-2 flex items-center gap-1 text-sm ${trendColor}`}>
<TrendIcon className="h-4 w-4" />
<TrendIcon className="h-4 w-4" aria-hidden="true" />
<span>{Math.abs(trend)}%</span>
<span className="text-gray-500 dark:text-gray-400">vs last period</span>
<span className="sr-only">{trendLabels[trendDirection]}</span>
</div>
)}
</div>

View File

@@ -54,6 +54,7 @@ export default function Toast({ type, message, onClose }: ToastProps) {
<button
onClick={handleClose}
className={`shrink-0 ${text} hover:opacity-70`}
aria-label="Close notification"
>
<X className="h-4 w-4" />
</button>

View File

@@ -11,6 +11,7 @@ import api from "@/lib/api";
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
import EmptyState from "@/components/EmptyState";
import { TrendingUp } from "lucide-react";
import { formatCurrency } from "@/lib/format";
interface MRRPoint {
date: string;
@@ -20,14 +21,6 @@ interface MRRPoint {
const ranges = ["7d", "30d", "90d", "1y"] as const;
type Range = (typeof ranges)[number];
function formatCurrency(cents: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
}).format(cents / 100);
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
@@ -79,14 +72,26 @@ export default function MRRTrendChart() {
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
MRR Trend
</h3>
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
MRR Trend
</h3>
{data.length >= 2 && (() => {
const netChange = data[data.length - 1].mrr - data[0].mrr;
const sign = netChange >= 0 ? "+" : "";
return (
<p className="text-xs text-gray-500 dark:text-gray-400">
{sign}{formatCurrency(netChange)} over period
</p>
);
})()}
</div>
<div className="flex gap-1">
{ranges.map((r) => (
<button
key={r}
onClick={() => setRange(r)}
aria-pressed={range === r}
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${
range === r
? "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
@@ -110,20 +115,21 @@ export default function MRRTrendChart() {
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 12 }}
stroke="#9ca3af"
stroke="var(--chart-axis-stroke)"
/>
<YAxis
tickFormatter={(v) => formatCurrency(v)}
tick={{ fontSize: 12 }}
stroke="#9ca3af"
stroke="var(--chart-axis-stroke)"
/>
<Tooltip
formatter={(value) => [formatCurrency(value as number), "MRR"]}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={{
backgroundColor: "var(--color-white, #fff)",
borderColor: "#e5e7eb",
backgroundColor: "var(--chart-tooltip-bg)",
borderColor: "var(--chart-tooltip-border)",
borderRadius: 8,
color: "var(--chart-tooltip-text)",
}}
/>
<Area
@@ -132,6 +138,7 @@ export default function MRRTrendChart() {
stroke="#6366f1"
strokeWidth={2}
fill="url(#mrrGradient)"
activeDot={{ r: 6, strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>

View File

@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import api from "@/lib/api";
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
import EmptyState from "@/components/EmptyState";
import { PieChart as PieChartIcon } from "lucide-react";
interface BucketData {
range: string;
count: number;
min_score: number;
max_score: number;
}
interface RiskSegment {
name: string;
value: number;
color: string;
}
function aggregateRisk(buckets: BucketData[]): RiskSegment[] {
let healthy = 0;
let atRisk = 0;
let critical = 0;
for (const b of buckets) {
if (b.min_score >= 70) {
healthy += b.count;
} else if (b.min_score >= 40) {
atRisk += b.count;
} else {
critical += b.count;
}
}
return [
{ name: "Healthy", value: healthy, color: "#22c55e" },
{ name: "At Risk", value: atRisk, color: "#eab308" },
{ name: "Critical", value: critical, color: "#ef4444" },
].filter((s) => s.value > 0);
}
export default function RiskDistributionChart() {
const [segments, setSegments] = useState<RiskSegment[]>([]);
const [loading, setLoading] = useState(true);
const [empty, setEmpty] = useState(false);
useEffect(() => {
async function fetch() {
try {
const { data: res } = await api.get("/dashboard/score-distribution");
const buckets = res.buckets ?? res.distribution ?? res ?? [];
if (Array.isArray(buckets) && buckets.length > 0) {
const agg = aggregateRisk(buckets);
if (agg.length > 0) {
setSegments(agg);
} else {
setEmpty(true);
}
} else {
setEmpty(true);
}
} catch {
setEmpty(true);
} finally {
setLoading(false);
}
}
fetch();
}, []);
if (loading) return <ChartSkeleton />;
if (empty) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
<EmptyState
icon={<PieChartIcon className="h-12 w-12" />}
title="No risk data yet"
description="Risk distribution will appear once customers are scored."
/>
</div>
);
}
const total = segments.reduce((sum, s) => sum + s.value, 0);
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-medium text-gray-900 dark:text-gray-100">
Risk Distribution
</h3>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={segments}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
dataKey="value"
label={({ name, value }) =>
`${name}: ${value} (${Math.round((value / total) * 100)}%)`
}
>
{segments.map((s) => (
<Cell key={s.name} fill={s.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "var(--chart-tooltip-bg)",
borderColor: "var(--chart-tooltip-border)",
borderRadius: 8,
color: "var(--chart-tooltip-text)",
}}
formatter={(value) => [
`${value} (${Math.round(((value as number) / total) * 100)}%)`,
"Customers",
]}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
Tooltip,
ResponsiveContainer,
Cell,
LabelList,
} from "recharts";
import api from "@/lib/api";
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
@@ -64,6 +65,8 @@ export default function ScoreDistributionChart() {
);
}
const total = data.reduce((sum, d) => sum + d.count, 0);
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -71,27 +74,39 @@ export default function ScoreDistributionChart() {
</h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={data}>
<XAxis dataKey="range" tick={{ fontSize: 12 }} stroke="#9ca3af" />
<XAxis dataKey="range" tick={{ fontSize: 12 }} stroke="var(--chart-axis-stroke)" />
<YAxis
tick={{ fontSize: 12 }}
stroke="#9ca3af"
stroke="var(--chart-axis-stroke)"
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--color-white, #fff)",
borderColor: "#e5e7eb",
backgroundColor: "var(--chart-tooltip-bg)",
borderColor: "var(--chart-tooltip-border)",
borderRadius: 8,
color: "var(--chart-tooltip-text)",
}}
formatter={(value) => [value, "Customers"]}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((entry, index) => (
<Cell
key={index}
key={entry.range}
fill={getBarColor(entry.min_score ?? index * 10)}
/>
))}
<LabelList
dataKey="count"
position="top"
formatter={(value: unknown) => {
const v = value as number;
return total > 0
? `${v} (${Math.round((v / total) * 100)}%)`
: `${v}`;
}}
style={{ fontSize: 11, fill: "var(--chart-tooltip-text)" }}
/>
</Bar>
</BarChart>
</ResponsiveContainer>

View File

@@ -81,16 +81,17 @@ export default function ScoreHistoryChart({
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 12 }}
stroke="#9ca3af"
stroke="var(--chart-axis-stroke)"
/>
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} stroke="#9ca3af" />
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} stroke="var(--chart-axis-stroke)" />
<Tooltip
formatter={(value) => [value, "Score"]}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={{
backgroundColor: "var(--color-white, #fff)",
borderColor: "#e5e7eb",
backgroundColor: "var(--chart-tooltip-bg)",
borderColor: "var(--chart-tooltip-border)",
borderRadius: 8,
color: "var(--chart-tooltip-text)",
}}
/>
<Line

View File

@@ -1,3 +1,28 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--chart-tooltip-bg: #ffffff;
--chart-tooltip-border: #e5e7eb;
--chart-tooltip-text: #111827;
--chart-axis-stroke: #9ca3af;
}
.dark {
--chart-tooltip-bg: #1f2937;
--chart-tooltip-border: #374151;
--chart-tooltip-text: #f3f4f6;
--chart-axis-stroke: #6b7280;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -24,18 +24,12 @@ export default function AppLayout() {
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar
collapsed={collapsed}
onToggleCollapse={() => {
if (window.innerWidth < 768) {
openMobile();
} else {
toggleCollapse();
}
}}
onToggleCollapse={toggleCollapse}
mobileOpen={mobileOpen}
onCloseMobile={closeMobile}
/>
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<Header onOpenMobileNav={openMobile} />
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<Outlet />
</main>

29
web/src/lib/format.ts Normal file
View File

@@ -0,0 +1,29 @@
interface FormatCurrencyOptions {
minimumFractionDigits?: number;
maximumFractionDigits?: number;
}
export function formatCurrency(
cents: number,
opts?: FormatCurrencyOptions,
): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: opts?.minimumFractionDigits ?? 0,
maximumFractionDigits: opts?.maximumFractionDigits ?? 0,
}).format(cents / 100);
}
export function relativeTime(dateStr?: string): string {
if (!dateStr) return "—";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}

View File

@@ -7,6 +7,7 @@ import EventTimeline from "@/components/EventTimeline";
import ScoreHistoryChart from "@/components/charts/ScoreHistoryChart";
import ProfileSkeleton from "@/components/skeletons/ProfileSkeleton";
import { ChevronRight, Mail, Building, DollarSign, Clock } from "lucide-react";
import { formatCurrency, relativeTime } from "@/lib/format";
interface CustomerDetail {
id: string;
@@ -38,26 +39,6 @@ interface ScoreFactor {
type Tab = "overview" | "events" | "subscriptions";
function formatCurrency(cents: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
}).format(cents / 100);
}
function relativeTime(dateStr: string): string {
if (!dateStr) return "—";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const [customer, setCustomer] = useState<CustomerDetail | null>(null);

View File

@@ -5,7 +5,10 @@ import StatCard from "@/components/StatCard";
import CardSkeleton from "@/components/skeletons/CardSkeleton";
import ScoreDistributionChart from "@/components/charts/ScoreDistributionChart";
import MRRTrendChart from "@/components/charts/MRRTrendChart";
import RiskDistributionChart from "@/components/charts/RiskDistributionChart";
import AtRiskCustomersTable from "@/components/AtRiskCustomersTable";
import { Users, AlertTriangle, DollarSign, Activity } from "lucide-react";
import { formatCurrency } from "@/lib/format";
interface DashboardSummary {
total_customers: number;
@@ -14,15 +17,6 @@ interface DashboardSummary {
average_health_score: number;
}
function formatCurrency(cents: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(cents / 100);
}
export default function DashboardPage() {
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [loading, setLoading] = useState(true);
@@ -62,7 +56,7 @@ export default function DashboardPage() {
))}
</div>
) : error ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-800 dark:bg-red-950">
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-800 dark:bg-red-950">
<p className="text-sm text-red-700 dark:text-red-300">
Failed to load dashboard data.
</p>
@@ -103,6 +97,14 @@ export default function DashboardPage() {
<ScoreDistributionChart />
<MRRTrendChart />
</div>
{/* Risk overview */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<RiskDistributionChart />
<div className="lg:col-span-2">
<AtRiskCustomersTable />
</div>
</div>
</div>
);
}