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
6 changes: 6 additions & 0 deletions apps/web/src/app/api/copilot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ ${signalsContext}`;
});

const data = await res.json();
if (!res.ok || data?.type === 'error') {
console.error('Anthropic API error:', JSON.stringify(data));
return NextResponse.json({
reply: `Iris error: ${data?.error?.message ?? data?.error?.type ?? 'Unknown error from Anthropic API'}`,
});
}
const reply = data?.content?.[0]?.text ?? 'No response.';
return NextResponse.json({ reply });
}
22 changes: 22 additions & 0 deletions apps/web/src/app/api/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';

const PASSWORD = 'adonis2026';
const COOKIE_NAME = 'adonis_auth';

export async function POST(req: NextRequest) {
const { password } = await req.json();

if (password !== PASSWORD) {
return NextResponse.json({ error: 'Incorrect password' }, { status: 401 });
}

const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, PASSWORD, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
return res;
}
19 changes: 2 additions & 17 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import './globals.css';
import { Suspense } from 'react';
import type { Metadata } from 'next';
import Nav from '@/components/Nav';
import CoPilot from '@/components/CoPilot';
import MobileNav from '@/components/MobileNav';
import DigestTracker from '@/components/DigestTracker';
import UserProvider from '@/components/UserProvider';
import SidebarUser from '@/components/SidebarUser';
import ShellGuard from '@/components/ShellGuard';

export const metadata: Metadata = {
title: 'Adonis Account Intelligence',
Expand All @@ -17,18 +13,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en">
<body className="font-sans antialiased">
{/* Mobile top bar */}
<UserProvider>
<MobileNav />
<div className="min-h-screen flex">
<Sidebar />
<main className="flex-1 min-w-0">{children}</main>
</div>
<CoPilot />
<Suspense>
<DigestTracker />
</Suspense>
</UserProvider>
<ShellGuard sidebar={<Sidebar />}>{children}</ShellGuard>
</body>
</html>
);
Expand Down
81 changes: 81 additions & 0 deletions apps/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');

const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});

if (res.ok) {
router.push('/');
router.refresh();
} else {
setError('Incorrect password.');
setLoading(false);
}
}

return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{
background:
'radial-gradient(circle at 110% 100%, rgba(63, 215, 190, 0.55) 0%, transparent 55%), linear-gradient(135deg, #0A2A2B 0%, #0F3D3E 50%, #1A5E5C 100%)',
}}
>
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<h1 className="font-serif text-3xl font-bold" style={{ color: '#EFEFC8' }}>
Account Intel
</h1>
<p className="text-sm mt-1" style={{ color: 'rgba(239,239,200,0.5)' }}>
for Adonis · Healthcare Revenue-Cycle Software
</p>
</div>
<div className="bg-white/10 border border-white/20 rounded-xl p-8 backdrop-blur-sm">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
className="block text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: 'rgba(239,239,200,0.6)' }}
>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg px-3 py-2 text-sm bg-white/10 border border-white/20 text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Enter password"
autoFocus
/>
</div>
{error && <p className="text-xs text-red-300">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full text-sm font-semibold py-2 rounded-lg hover:opacity-90 disabled:opacity-50"
style={{ background: '#EFEFC8', color: '#0F3D3E' }}
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
);
}
12 changes: 9 additions & 3 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ export default async function HomePage({ searchParams }: PageProps) {
const hospitalMap = Object.fromEntries(hospitals.map((h) => [h.id, h.name]));

// Strip garbage — filtered_out signals should never appear in the feed
const cleanSignals = allSignals.filter(
(s) => s.tier !== 'filtered_out' && s.signal_type !== 'filtered_out'
);
const seenUrls = new Set<string>();
const cleanSignals = allSignals.filter((s) => {
if (s.tier === 'filtered_out' || s.signal_type === 'filtered_out') return false;
if (s.source_url) {
if (seenUrls.has(s.source_url)) return false;
seenUrls.add(s.source_url);
}
return true;
});

// Filter by category
let signals = category
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/review/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default async function ReviewPage() {
return (
<div className="px-4 py-5 md:px-8 md:py-7 pb-20 md:pb-7">
<header className="mb-6">
<h1 className="font-serif text-2xl font-semibold text-brand">Review queue</h1>
<h1 className="font-serif text-2xl font-semibold text-brand">Review Queue</h1>
<p className="text-sm text-slate-500 mt-1">
Low-confidence signals (under 70%) awaiting Danielle&apos;s approval before digest.
{signals.length > 0 && (
Expand Down
51 changes: 31 additions & 20 deletions apps/web/src/components/CoPilot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function IrisMessage({ text }: { text: string }) {
const lines = text.split('\n');
const nodes: React.ReactNode[] = [];
let i = 0;
let k = 0;

while (i < lines.length) {
const line = lines[i] ?? '';
Expand All @@ -31,7 +32,7 @@ function IrisMessage({ text }: { text: string }) {
i++;
}
nodes.push(
<ul key={i} className="list-disc list-outside pl-4 space-y-0.5 my-1">
<ul key={k++} className="list-disc list-outside pl-4 space-y-0.5 my-1">
{items.map((item, j) => (
<li key={j}>{renderInline(item)}</li>
))}
Expand All @@ -48,7 +49,7 @@ function IrisMessage({ text }: { text: string }) {
i++;
}
nodes.push(
<ol key={i} className="list-decimal list-outside pl-4 space-y-0.5 my-1">
<ol key={k++} className="list-decimal list-outside pl-4 space-y-0.5 my-1">
{items.map((item, j) => (
<li key={j}>{renderInline(item)}</li>
))}
Expand All @@ -59,7 +60,7 @@ function IrisMessage({ text }: { text: string }) {

// Regular paragraph
nodes.push(
<p key={i} className="my-0.5">
<p key={k++} className="my-0.5">
{renderInline(line)}
</p>
);
Expand Down Expand Up @@ -131,7 +132,7 @@ function defaultPos() {
const isMobile = window.innerWidth < 768;
return {
x: window.innerWidth - BUBBLE_SIZE - (isMobile ? 16 : 24),
y: window.innerHeight - BUBBLE_SIZE - (isMobile ? 88 : 24),
y: window.innerHeight - BUBBLE_SIZE - (isMobile ? 96 : 48),
};
}

Expand All @@ -146,7 +147,7 @@ export default function CoPilot() {
const [pulsing, setPulsing] = useState(false);
const [hasUnread, setHasUnread] = useState(false);
// null = not yet mounted (SSR safe)
const { userId, isAdmin } = useUser();
const { userId, isAdmin, userName } = useUser();
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const pillDismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All @@ -170,11 +171,11 @@ export default function CoPilot() {
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) drag.current.moved = true;
const newX = Math.max(
0,
Math.min(window.innerWidth - BUBBLE_SIZE, drag.current.startPosX + dx)
Math.min(window.innerWidth - BUBBLE_SIZE - 8, drag.current.startPosX + dx)
);
const newY = Math.max(
0,
Math.min(window.innerHeight - BUBBLE_SIZE, drag.current.startPosY + dy)
Math.min(window.innerHeight - BUBBLE_SIZE - 16, drag.current.startPosY + dy)
);
setPos({ x: newX, y: newY });
}
Expand All @@ -185,8 +186,8 @@ export default function CoPilot() {
setPos((prev) => {
if (!prev) return defaultPos();
return {
x: Math.max(0, Math.min(window.innerWidth - BUBBLE_SIZE, prev.x)),
y: Math.max(0, Math.min(window.innerHeight - BUBBLE_SIZE, prev.y)),
x: Math.max(0, Math.min(window.innerWidth - BUBBLE_SIZE - 8, prev.x)),
y: Math.max(0, Math.min(window.innerHeight - BUBBLE_SIZE - 16, prev.y)),
};
});
}
Expand Down Expand Up @@ -338,12 +339,10 @@ export default function CoPilot() {
};
}

const gap = 8;
let top = pos.y - PANEL_HEIGHT - gap;
if (top < 8) top = pos.y + BUBBLE_SIZE + gap;
let left = pos.x + BUBBLE_SIZE - PANEL_WIDTH;
left = Math.max(8, Math.min(window.innerWidth - PANEL_WIDTH - 8, left));
return { position: 'fixed', top, left, zIndex: 50, width: PANEL_WIDTH };
const gap = 12;
const bottom = window.innerHeight - pos.y + gap;
const right = Math.max(8, window.innerWidth - pos.x - BUBBLE_SIZE);
return { position: 'fixed', bottom, right, zIndex: 50, width: PANEL_WIDTH };
}

if (!pos) return null;
Expand Down Expand Up @@ -534,6 +533,11 @@ export default function CoPilot() {
>
{messages.length === 0 && (
<div className="space-y-2">
{userName && (
<p className="text-xs text-slate-500 pb-1">
Hi {userName.split(' ')[0]}, what do you need?
</p>
)}
<button
onClick={() =>
handleSend(
Expand All @@ -545,11 +549,18 @@ export default function CoPilot() {
>
Brief me on my territory
</button>
{[
'Who should I call this week?',
'Draft an outreach email for Ascension',
"What's urgent across my accounts?",
].map((prompt) => (
{(isAdmin
? [
'Draft my weekly digest',
"What's urgent across all accounts?",
'Which AEs need a follow-up this week?',
]
: [
'Who should I call this week?',
'Summarize my most important account',
"What's urgent across my accounts?",
]
).map((prompt) => (
<button
key={prompt}
onClick={() => handleSend(prompt)}
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/ShellGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client';

import { usePathname } from 'next/navigation';
import { Suspense } from 'react';
import CoPilot from '@/components/CoPilot';
import MobileNav from '@/components/MobileNav';
import DigestTracker from '@/components/DigestTracker';
import UserProvider from '@/components/UserProvider';

export default function ShellGuard({
children,
sidebar,
}: {
children: React.ReactNode;
sidebar: React.ReactNode;
}) {
const pathname = usePathname();

if (pathname === '/login') {
return <>{children}</>;
}

return (
<UserProvider>
<MobileNav />
<div className="min-h-screen flex">
{sidebar}
<main className="flex-1 min-w-0">{children}</main>
</div>
<CoPilot />
<Suspense>
<DigestTracker />
</Suspense>
</UserProvider>
);
}
26 changes: 26 additions & 0 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';

const PASSWORD = 'adonis2026';
const COOKIE_NAME = 'adonis_auth';

export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;

// Allow login page and its API route through
if (pathname === '/login' || pathname === '/api/login') {
return NextResponse.next();
}

const cookie = req.cookies.get(COOKIE_NAME);
if (cookie?.value === PASSWORD) {
return NextResponse.next();
}

const loginUrl = req.nextUrl.clone();
loginUrl.pathname = '/login';
return NextResponse.redirect(loginUrl);
}

export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Loading