Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useAuth } from '@/lib/AuthContext';
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
const DefaultFallback = () => (
<div className="fixed inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-slate-200 border-t-slate-800 rounded-full animate-spin"></div>
</div>
);
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
useEffect(() => {
if (!authChecked && !isLoadingAuth) {
checkUserAuth();
}
}, [authChecked, isLoadingAuth, checkUserAuth]);
if (isLoadingAuth || !authChecked) {
return fallback;
}
if (authError) {
if (authError.type === 'user_not_registered') {
return <UserNotRegisteredError />;
}
return unauthenticatedElement;
}
if (!isAuthenticated) {
return unauthenticatedElement;
}
return <Outlet />;
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from "react";
import { useLocation, useNavigationType } from "react-router-dom";
const getHashId = (hash) => {
const rawId = hash.slice(1);
try {
return decodeURIComponent(rawId);
} catch {
return rawId;
}
};
export default function ScrollToTop() {
const { pathname, hash } = useLocation();
const navigationType = useNavigationType();
useEffect(() => {
if (navigationType === "POP") return;
if (hash) {
const id = getHashId(hash);
const timer = window.setTimeout(() => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
}, 50);
return () => window.clearTimeout(timer);
}
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}, [pathname, hash, navigationType]);
return null;
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
const UserNotRegisteredError = () => {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50">
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg border border-slate-100">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-6 rounded-full bg-orange-100">
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-4">Access Restricted</h1>
<p className="text-slate-600 mb-8">
You are not registered to use this application. Please contact the app administrator to request access.
</p>
<div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600">
<p>If you believe this is an error, you can:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Verify you are logged in with the correct account</li>
<li>Contact the app administrator for access</li>
<li>Try logging out and back in again</li>
</ul>
</div>
</div>
</div>
</div>
);
};
export default UserNotRegisteredError;

View File

@@ -0,0 +1,150 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Renders the light markup the capabilities emit — `**bold**`, bullets,
* numbered items, `_asides_`, and ✓/⚠ check lines.
*
* A full markdown renderer is available in the project, but this content is
* produced locally in a known shape, and parsing five constructs keeps the
* assistant's typography inside the design system rather than inheriting a
* library's defaults.
*/
const BOLD = /\*\*(.+?)\*\*/g;
function inline(line, keyPrefix) {
const parts = [];
let last = 0;
let match;
BOLD.lastIndex = 0;
while ((match = BOLD.exec(line)) !== null) {
if (match.index > last) parts.push(line.slice(last, match.index));
parts.push(
<strong key={`${keyPrefix}-${match.index}`} className="font-semibold text-ink-1">
{match[1]}
</strong>
);
last = match.index + match[0].length;
}
if (last < line.length) parts.push(line.slice(last));
return parts.length ? parts : line;
}
export function RichText({ text, className }) {
const lines = React.useMemo(() => String(text).split('\n'), [text]);
return (
<div className={cn('space-y-1.5 text-body-sm text-ink-2', className)}>
{lines.map((line, i) => {
if (!line.trim()) return <div key={i} className="h-1.5" aria-hidden="true" />;
/* Aside — a caveat the capabilities wrap in underscores. */
if (line.startsWith('_') && line.endsWith('_')) {
return (
<p key={i} className="border-l-2 border-border pl-2.5 text-caption italic text-ink-3 leading-relaxed">
{inline(line.slice(1, -1), i)}
</p>
);
}
/* Bullet. */
if (line.startsWith('• ')) {
return (
<div key={i} className="flex gap-2 leading-relaxed">
<span className="text-krow-blue shrink-0" aria-hidden="true">•</span>
<span className="min-w-0">{inline(line.slice(2), i)}</span>
</div>
);
}
/* Indented continuation of a numbered item. */
if (line.startsWith(' ')) {
return (
<p key={i} className="pl-6 text-caption text-ink-3 leading-relaxed">
{inline(line.trim(), i)}
</p>
);
}
/* Numbered item. */
const numbered = line.match(/^(\d+)\.\s+(.*)$/);
if (numbered) {
return (
<div key={i} className="flex gap-2.5 leading-relaxed">
<span className="grid place-items-center w-4 h-4 mt-0.5 shrink-0 rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{numbered[1]}
</span>
<span className="min-w-0">{inline(numbered[2], i)}</span>
</div>
);
}
/* Health-check line. */
if (line.startsWith('✓ ') || line.startsWith('⚠ ')) {
const ok = line.startsWith('✓');
return (
<div key={i} className="flex gap-2 leading-relaxed">
<span className={cn('shrink-0 font-semibold', ok ? 'text-success' : 'text-warning')} aria-hidden="true">
{ok ? '✓' : '⚠'}
</span>
<span className="min-w-0">{inline(line.slice(2), i)}</span>
</div>
);
}
return <p key={i} className="leading-relaxed">{inline(line, i)}</p>;
})}
</div>
);
}
/** The three-dot indicator shown before the first delta arrives. */
export function ThinkingIndicator() {
return (
<div className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-3 py-2.5 shadow-xs">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing"
style={{ animationDelay: `${i * 0.18}s` }}
/>
))}
<span className="sr-only">Assistant is thinking</span>
</div>
);
}
/**
* One turn in the conversation. The user's own words sit in a filled bubble; the
* assistant's answer runs full width, because these answers are structured
* multi-paragraph reports and a chat bubble would waste a third of a 380px
* column on padding.
*/
export function Message({ role, text, streaming, stopped }) {
if (role === 'user') {
return (
<div className="flex justify-end animate-slide-up">
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
{text}
</div>
</div>
);
}
return (
<div className="animate-slide-up">
<RichText text={text} />
{streaming && (
<span
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret"
aria-hidden="true"
/>
)}
{stopped && (
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import * as React from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import KrowAssistant from './KrowAssistant';
import { useAssistantPanel } from './AssistantPanelContext';
/** Panel width, plus the gutter separating it from the dashboard. */
const PANEL_WIDTH = 380;
const GUTTER = 24;
/**
* AssistantPanel — the sliding column the layout renders.
*
* Animating the aside's `width` (rather than overlaying) is what makes the
* dashboard reflow smoothly instead of being covered, and what lets closing
* restore the original layout exactly.
*
* Two things about the structure matter, and both were bugs before:
*
* 1. The clip lives *inside* the sticky element, not around it. An ancestor with
* `overflow: hidden` becomes the nearest scroll container, which silently
* disables `position: sticky` on everything beneath it — the panel computed
* as sticky but never actually stuck.
*
* 2. The aside must stretch to the shell's full height. As a flex item under
* `items-start` it collapsed to its own height, so its sticky context ended
* partway down the page and the panel scrolled away, leaving empty space
* beside a long dashboard.
*
* With both fixed the panel stays pinned for the whole scroll, and because the
* assistant is a fixed-height frame, only its conversation scrolls.
*/
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
const { context, isOpen, close } = useAssistantPanel();
return (
<AnimatePresence initial={false}>
{isOpen && context && (
<motion.aside
key="assistant"
aria-label="Krow AI Assistant"
initial={{ width: 0, opacity: 0 }}
animate={{ width: PANEL_WIDTH + GUTTER, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
/* No `overflow-hidden` here — see (1) above. `self-stretch` keeps the
sticky context running the full height of the shell — see (2). */
className="relative shrink-0 self-stretch"
>
<div className={cn('sticky', stickyClassName)}>
{/* The clip: contains the slide-in and the fixed panel width while
the track animates open, without breaking the sticky above it. */}
<div className="overflow-hidden" style={{ width: PANEL_WIDTH + GUTTER }}>
<motion.div
className="pl-6"
initial={{ x: 32, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 32, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
>
{/* Keyed on the context so switching pages gives a clean panel
and aborts any response still streaming for the last one. */}
<KrowAssistant
key={context.id}
context={context}
onClose={close}
className={panelHeightClassName}
/>
</motion.div>
</div>
</div>
</motion.aside>
)}
</AnimatePresence>
);
}
export default AssistantPanel;

View File

@@ -0,0 +1,94 @@
import * as React from 'react';
import { resolveAssistantContext } from './placement';
/**
* Open/closed state for the Krow AI Assistant.
*
* Lives in context rather than in the layout so the trigger can sit anywhere in
* the page — beside that page's own actions — without the layout having to know
* where, and without threading props through every page.
*
* The assistant is closed by default. The dashboard is the primary experience;
* the assistant is something you open when you want it.
*/
const AssistantPanelContext = React.createContext(null);
const STORAGE_KEY = 'krow_assistant_open';
/**
* Whether the panel was open before. Kept for the session so moving between
* supported pages does not close a panel the user deliberately opened, while a
* new session still starts clean on the dashboard.
*/
function readStoredOpen() {
try {
return sessionStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
}
export function AssistantPanelProvider({ role, pathname, children }) {
const [open, setOpen] = React.useState(readStoredOpen);
const context = React.useMemo(
() => resolveAssistantContext(role, pathname),
[role, pathname]
);
const supported = Boolean(context);
// A stored "open" must not force the panel onto a page that has no assistant.
const isOpen = supported && open;
const persist = React.useCallback((next) => {
setOpen(next);
try {
sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0');
} catch {
// Private mode — the panel simply reverts to closed next session.
}
}, []);
/* Escape closes the panel, as it does for every other dismissible surface. */
React.useEffect(() => {
if (!isOpen) return undefined;
const onKey = (e) => {
if (e.key === 'Escape') persist(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, persist]);
const value = React.useMemo(() => ({
context,
supported,
isOpen,
open: () => persist(true),
close: () => persist(false),
toggle: () => persist(!isOpen),
}), [context, supported, isOpen, persist]);
return (
<AssistantPanelContext.Provider value={value}>
{children}
</AssistantPanelContext.Provider>
);
}
/**
* Panel state for triggers and the layout.
*
* Returns a safe inert value outside a provider, so a page rendering an
* `AssistantTrigger` in isolation (a test, a storybook) does not crash.
*/
export function useAssistantPanel() {
return React.useContext(AssistantPanelContext) ?? {
context: null,
supported: false,
isOpen: false,
open: () => {},
close: () => {},
toggle: () => {},
};
}

View File

@@ -0,0 +1,72 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { KROW_LOGO_URL } from '@/assets/brand';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAssistantPanel } from './AssistantPanelContext';
/**
* AssistantTrigger — the KROW mark used as a secondary action.
*
* This is not the application's branding: the navigation logo is untouched.
* Here the mark is deliberately smaller and sits in a page's action row, where
* it reads as "the intelligent thing this page can do" rather than as a chrome
* element. Pages render it beside their own actions.
*
* It self-hides on pages with no assistant, so a page can render it
* unconditionally and never needs to know the placement rules.
*/
export function AssistantTrigger({ className, size = 'default' }) {
const { supported, isOpen, toggle, context } = useAssistantPanel();
if (!supported) return null;
const label = isOpen ? 'Close Krow AI' : `Ask Krow AI about ${context.page}`;
const heights = { sm: 'h-8 px-2.5', default: 'h-9 px-3' };
const logoHeights = { sm: 'h-3.5', default: 'h-4' };
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
aria-label={label}
aria-expanded={isOpen}
className={cn(
'group relative inline-flex items-center justify-center rounded-full border',
'transition-all duration-base ease-out cursor-pointer',
'outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-2',
'hover:scale-[1.04] active:scale-100',
heights[size],
isOpen
? 'border-krow-blue/40 bg-krow-blue-tint shadow-[0_0_0_3px_rgba(10,57,223,0.10)]'
: 'border-border bg-surface shadow-xs hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:shadow-[0_0_18px_-2px_rgba(10,57,223,0.35)]',
className
)}
>
<img
src={KROW_LOGO_URL}
alt=""
aria-hidden="true"
className={cn('w-auto object-contain', logoHeights[size])}
/>
{/* A quiet indicator that this mark does something intelligent —
enough signal without adding a competing icon or label. */}
<span
aria-hidden="true"
className={cn(
'absolute -top-0.5 -right-0.5 block w-2 h-2 rounded-full ring-2 ring-white transition-colors duration-base',
isOpen ? 'bg-krow-blue' : 'bg-krow-yellow group-hover:bg-krow-blue'
)}
/>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export default AssistantTrigger;

View File

@@ -0,0 +1,167 @@
import * as React from 'react';
import { RotateCcw, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { KROW_LOGO_URL } from '@/assets/brand';
import { useAssistantFacts, useConversation } from './useAssistant';
import { Message, ThinkingIndicator } from './AssistantMessage';
import { PromptInput } from './PromptInput';
/**
* Krow AI Assistant — the dashboard's contextual assistant.
*
* One component, reused on every supported page; only `context` differs.
*
* The layout is a fixed three-part frame — header, conversation, composer —
* where only the conversation scrolls. That is what lets the panel stay pinned
* beside a long dashboard without ever growing the page or losing its input.
*
* The empty state is deliberately a conversational entry point rather than a
* menu: an icon, a question, and an input. Suggested chips fill the input rather
* than firing an action, so the user always sees the question before it is asked
* and can edit it — which is what makes this read as an assistant instead of a
* second dashboard.
*
* Independent of Owliver. It talks to a provider (see provider.js) which can
* later be pointed at an external service without changing anything here.
*/
export default function KrowAssistant({ context, className, onClose }) {
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
facts,
});
const [input, setInput] = React.useState('');
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
/* Follow the newest content as it streams. */
React.useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, pending]);
/**
* A chip's prompt resolves to its capability so the answer is precise, while
* the same question typed by hand still resolves through keyword matching.
*/
const submit = (text) => {
const question = text.trim();
if (!question || busy) return;
const chip = context.chips.find((c) => c.prompt === question);
send({ question, capability: chip?.capability ?? null });
setInput('');
};
return (
<Surface
variant="glass"
radius="xl"
padding="none"
elevation="sm"
className={cn('flex flex-col overflow-hidden', className)}
aria-label="Krow AI Assistant"
>
{/* Header — fixed */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Krow AI</h2>
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div>
{messages.length > 0 && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
{onClose && (
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
)}
</div>
{/* Conversation — the only scrolling region */}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite">
{isEmpty ? (
/* Landing: an invitation to ask, not a list of features. */
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in">
<span className="relative mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-white shadow-brand ring-1 ring-krow-blue/10">
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" />
<span
aria-hidden="true"
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
>
<Sparkles className="h-2.5 w-2.5" />
</span>
</span>
<h3 className="font-heading text-title text-ink-1">
What would you like to analyze today?
</h3>
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3">
{context.tagline}
</p>
</div>
) : (
<div className="space-y-4 px-4 py-4">
{messages.map((message, i) => (
<Message key={i} role={message.role} text={message.text} stopped={message.stopped} />
))}
{pending?.thinking && <ThinkingIndicator />}
{pending && !pending.thinking && <Message role="assistant" text={pending.text} streaming />}
{error && <Alert tone="destructive">{error}</Alert>}
</div>
)}
</div>
{/* Suggested prompts + composer — fixed */}
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5">
{!busy && (
/* On the landing there is room to show every suggestion, so they wrap.
Once a conversation is underway the space belongs to the thread, so
they collapse to a single scrollable row. */
<div
className={cn(
'flex gap-1.5',
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar'
)}
>
{context.chips.map((chip) => (
<button
key={chip.label}
type="button"
/* Fills the input — never sends, never navigates. */
onClick={() => setInput(chip.prompt)}
className="shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-caption
text-ink-3 transition-colors duration-base hover:border-krow-blue/40
hover:bg-krow-blue-tint hover:text-krow-blue focus-ring"
>
{chip.label}
</button>
))}
</div>
)}
<PromptInput
value={input}
onChange={setInput}
onSubmit={submit}
onStop={stop}
busy={busy}
placeholders={context.placeholders}
size={isEmpty ? 'large' : 'default'}
/>
<p className="px-1 text-[10px] leading-tight text-ink-4">
Krow AI reads this page&apos;s data. Check anything you act on.
</p>
</div>
</Surface>
);
}

View File

@@ -0,0 +1,128 @@
import * as React from 'react';
import { ArrowUp, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* PromptInput — the assistant's primary action.
*
* A textarea rather than an input, because these prompts are sentences: it grows
* with the content up to a cap, keeps Enter as send, and leaves Shift+Enter for a
* deliberate newline. That combination is what makes typing a real question feel
* natural instead of cramped.
*
* `placeholders` rotate while the field is empty and unfocused, which teaches
* the range of what can be asked without spending vertical space on a legend.
*/
export function PromptInput({
value,
onChange,
onSubmit,
onStop,
busy = false,
placeholders = ['Ask anything…'],
/** Larger treatment for the landing state, where the input is the focus. */
size = 'default',
autoFocus = false,
className,
}) {
const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false);
const [placeholderIndex, setPlaceholderIndex] = React.useState(0);
/* Grow to fit the content, up to a cap, then scroll internally. */
React.useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
}, [value]);
/* Rotate the examples only while the field is idle — changing the placeholder
under someone who is mid-thought is distracting. */
React.useEffect(() => {
if (value || focused || placeholders.length < 2) return undefined;
const timer = setInterval(
() => setPlaceholderIndex((i) => (i + 1) % placeholders.length),
3800
);
return () => clearInterval(timer);
}, [value, focused, placeholders.length]);
React.useEffect(() => {
if (autoFocus) textareaRef.current?.focus();
}, [autoFocus]);
const submit = () => {
if (!value.trim() || busy) return;
onSubmit(value);
};
const onKeyDown = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
};
const large = size === 'large';
return (
<form
onSubmit={(e) => { e.preventDefault(); submit(); }}
className={cn(
'relative flex items-end gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
large ? 'px-4 py-3' : 'px-3 py-2.5',
focused
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
: 'border-border shadow-md hover:border-krow-blue/30',
className
)}
>
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={placeholders[placeholderIndex]}
aria-label="Ask Krow AI"
className={cn(
'flex-1 min-w-0 resize-none bg-transparent leading-relaxed text-ink-1',
'placeholder:text-ink-4 outline-none no-scrollbar',
large ? 'text-body py-1' : 'text-body-sm py-0.5'
)}
/>
{busy ? (
<button
type="button"
onClick={onStop}
aria-label="Stop generating"
className={cn(
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
large ? 'w-9 h-9' : 'w-8 h-8'
)}
>
<Square className={large ? 'w-3.5 h-3.5' : 'w-3 h-3'} aria-hidden="true" />
</button>
) : (
<button
type="submit"
disabled={!value.trim()}
aria-label="Send message"
className={cn(
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
large ? 'w-9 h-9' : 'w-8 h-8'
)}
>
<ArrowUp className={large ? 'w-4 h-4' : 'w-3.5 h-3.5'} aria-hidden="true" />
</button>
)}
</form>
);
}

View File

@@ -0,0 +1,277 @@
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
/**
* Admin capabilities — Control Center, Candidates Analysis, Activity.
*
* Admin answers are platform-wide rather than pipeline-specific: data integrity,
* talent supply, audit reconciliation.
*/
/* ── Control Center ─────────────────────────────────────────────────────── */
const platformHealth = (f) => {
const checks = [
{
label: 'Data integrity',
ok: f.applications.every((a) => a.job_posting_id),
good: 'Every application is linked to a live posting.',
bad: 'Some applications reference a posting that no longer exists.',
},
{
label: 'Screening coverage',
ok: f.standardizedPct >= 80,
good: `${f.standardizedPct}% of applicants carry a score.`,
bad: `Only ${f.standardizedPct}% of applicants are scored — ${plural(f.unscreened.length, 'record')} unstandardized.`,
},
{
label: 'Interview integrity',
ok: f.flagged.length === 0,
good: 'No interviews flagged.',
bad: `${plural(f.flagged.length, 'interview')} flagged below full integrity.`,
},
{
label: 'Profile completeness',
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
good: `${f.profiles.length - f.unscoredProfiles.length} of ${f.profiles.length} talent profiles have a career score.`,
bad: `${plural(f.unscoredProfiles.length, 'profile')} of ${f.profiles.length} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score, so ${verb(f.unscoredProfiles.length, 'it cannot', 'they cannot')} be matched or ranked.`,
},
{
label: 'Throughput',
ok: f.hired.length > 0,
good: `${plural(f.hired.length, 'hire')} closed at a ${f.hireRate}% hire rate.`,
bad: 'No hires closed yet, so there is no conversion baseline.',
},
];
const passing = checks.filter((c) => c.ok).length;
return compose(
heading(`Platform health: ${passing}/${checks.length} checks passing`),
'',
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.ok ? c.good : c.bad}`).join('\n'),
'',
heading('Scale'),
bullets([
`${plural(f.postings.length, 'position')}, ${f.openPositions.length} open`,
`${plural(f.total, 'application')}`,
`${plural(f.interviews.length, 'AI interview')}`,
`${plural(f.profiles.length, 'talent profile')}, ${f.elite.length} Elite`,
`${plural(f.activity.length, 'logged event')}`,
]),
'',
passing === checks.length
? 'Nothing needs intervention at the platform level.'
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
);
};
const recommendations = (f) => {
const actions = [];
if (f.unscoredProfiles.length) {
actions.push(`**Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding.** Without a career score they are invisible to matching, so employers cannot find them even when they fit.`);
}
if (f.unscreened.length) {
actions.push(`**${plural(f.unscreened.length, 'application')} unscored.** Each one is a decision made without a standard.`);
}
if (f.starvedPositions.length) {
actions.push(`**${plural(f.starvedPositions.length, 'posting')} with zero applicants** — ${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`);
}
if (f.unratedStaff.length) {
actions.push(`**${plural(f.unratedStaff.length, 'hire')} unrated.** Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.`);
}
if (f.flagged.length) {
actions.push(`**Review ${plural(f.flagged.length, 'flagged interview')}** before those candidates progress.`);
}
if (!actions.length) return 'No platform actions outstanding. Data is complete and nothing is flagged.';
return compose(heading('Recommended actions'), '', numbered(actions));
};
/* ── Candidates Analysis ────────────────────────────────────────────────── */
const recruitmentInsights = (f) => {
const bands = {
Elite: f.profiles.filter((p) => (p.krow_score || 0) >= 90).length,
Excellent: f.profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length,
Solid: f.profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length,
Building: f.profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
Unscored: f.unscoredProfiles.length,
};
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
const topHeavy = bands.Elite + bands.Excellent;
return compose(
heading('Talent supply'),
'',
bullets(Object.entries(bands).map(([band, count]) => `${band} — ${count}`)),
'',
heading('Pipeline'),
bullets([
`${plural(f.total, 'applicant')} averaging ${f.avgScore}`,
`${plural(f.ranked.filter((a) => a.ai_score >= 80).length, 'candidate')} at 80+`,
`${plural(verified, 'worker')} with at least one verified capability`,
`${plural(f.eventCounts.apply_job || 0, 'application event')} logged`,
]),
'',
bands.Unscored > topHeavy
? `The pool is bottom-heavy: ${bands.Unscored} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`
: 'The scored portion outweighs the unscored, so verification is keeping pace with sign-ups.',
'',
heading('By role category'),
bullets(
f.byRole.slice().sort((a, b) => b.applied - a.applied)
.map((r) => `${r.title} — ${plural(r.applied, 'applicant')}, ${r.qualified} at 70+`)
)
);
};
const candidateRiskAnalysis = (f) => {
const risks = [];
if (f.missingCredentials.length) {
risks.push(`**No credentials on file** — ${plural(f.missingCredentials.length, 'candidate')}. Compliance risk if placed on a role that requires certification.`);
}
if (f.narrowAvailability.length) {
risks.push(`**Narrow availability** — ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. Most likely to fall through on the day.`);
}
if (f.flagged.length) {
risks.push(`**Interview integrity** — ${plural(f.flagged.length, 'interview')} below full integrity, usually suspiciously fast responses. Worth a human re-interview.`);
}
const noHistory = f.scored.filter((a) => !a.years_experience);
if (noHistory.length) {
risks.push(`**No recorded experience** — ${plural(noHistory.length, 'candidate')}. Not disqualifying, but unverifiable on paper.`);
}
if (f.singleCandidateRoles.length) {
risks.push(`**Concentration risk** — ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`);
}
if (!risks.length) return 'No material risk flags: credentials, availability and interview integrity all check out across the scored pool.';
return compose(
heading(`${plural(risks.length, 'risk flag')}`),
'',
numbered(risks),
'',
'_Flags to check, not judgements. Each is a question for a human, not a rejection._'
);
};
/* ── Activity ───────────────────────────────────────────────────────────── */
const auditSummary = (f) => {
const count = (type) => f.activity.filter((e) => e.event_type === type).length;
const hires = count('hire_candidate');
const screens = count('screen_candidate');
const creates = count('create_position');
return compose(
heading('Audit summary'),
'',
bullets([
`${plural(f.activity.length, 'event')} total`,
`${plural(f.activity7d.length, 'event')} in the last 7 days`,
`${plural(f.activity24h.length, 'event')} in the last 24 hours`,
`${plural(new Set(f.activity.map((e) => e.user_email)).size, 'distinct user')}`,
]),
'',
heading('Privileged actions'),
bullets([
`${plural(creates, 'position creation')}`,
`${plural(screens, 'screening action')}`,
`${plural(hires, 'hire')}`,
]),
'',
heading('Reconciliation'),
bullets([
hires === f.staff.length
? `All ${f.staff.length} people on staff have a matching hire event.`
: `${f.staff.length} on staff but ${hires} hire ${verb(hires, 'event', 'events')} — ${Math.abs(f.staff.length - hires)} unexplained.`,
screens < f.scored.length
? `${f.scored.length} candidates carry scores but only ${plural(screens, 'screening event')} logged. Batch screening records one event per run, not per candidate.`
: 'Screening events reconcile with scored candidates.',
]),
'',
'_An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review._'
);
};
const userActivityInsights = (f) => {
const perUser = f.activity.reduce((acc, e) => {
acc[e.user_email] ||= { name: e.user_name, role: e.account_type, count: 0, types: new Set() };
acc[e.user_email].count += 1;
acc[e.user_email].types.add(e.event_type);
return acc;
}, {});
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
if (!ranked.length) return 'No activity to profile yet.';
const byRole = f.activity.reduce((acc, e) => {
const role = e.account_type || 'unknown';
acc[role] = (acc[role] || 0) + 1;
return acc;
}, {});
const [topEmail, top] = ranked[0];
return compose(
heading('Most active accounts'),
'',
bullets(ranked.slice(0, 6).map(([email, d]) =>
`**${d.name || email}** (${d.role || 'unknown'}) — ${plural(d.count, 'event')} across ${plural(d.types.size, 'action type')}`
)),
'',
heading('By role'),
bullets(Object.entries(byRole).sort((a, b) => b[1] - a[1])
.map(([role, c]) => `${role.charAt(0).toUpperCase() + role.slice(1)} — ${plural(c, 'event')}`)),
'',
`${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, one account dominating the log is worth confirming is a person and not an integration.`,
'',
f.activity24h.length === 0
? 'No activity in the last day. Expected for a demo dataset; on a live platform it would be worth checking ingestion.'
: 'Activity is flowing normally.'
);
};
/* ── Free-text responders ───────────────────────────────────────────────── */
const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondControlCenter = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
return platformHealth(f);
};
export const respondAdminCandidates = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f);
return recruitmentInsights(f);
};
export const respondActivity = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account')) return userActivityInsights(f);
return auditSummary(f);
};
/* ── Capability manifests ───────────────────────────────────────────────── */
export const CONTROL_CENTER_CAPABILITIES = [
{ id: 'platform-health', label: 'Platform Health', icon: Gauge, run: platformHealth },
{ id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations },
];
export const ADMIN_CANDIDATE_CAPABILITIES = [
{ id: 'recruitment-insights', label: 'Recruitment Insights', icon: TrendingUp, run: recruitmentInsights },
{ id: 'candidate-risk', label: 'Candidate Risk Analysis', icon: AlertTriangle, run: candidateRiskAnalysis },
];
export const ACTIVITY_CAPABILITIES = [
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
{ id: 'user-activity', label: 'User Activity Insights', icon: Eye, run: userActivityInsights },
];

View File

@@ -0,0 +1,462 @@
import {
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers,
} from 'lucide-react';
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
/**
* Employer capabilities — Overview, Candidates, Analytics.
*
* Each capability is a pure function of the fact sheet. Keeping them pure is
* what allows the same code to run against local data today and against a
* remote AI service later without the UI changing.
*/
/* ── Overview ───────────────────────────────────────────────────────────── */
const hiringSummary = (f) => {
if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.';
return compose(
heading('Where hiring stands'),
bullets([
`${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`,
`${f.screened.length} screened · ${f.unscreened.length} awaiting a score`,
`${plural(f.interviewing.length, 'candidate')} mid-interview`,
`${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`,
]),
'',
f.stalled.length
? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.`
: f.unscreened.length
? `Most urgent: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
);
};
const hiringHealth = (f) => {
const checks = [
{
label: 'Screening coverage',
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
note: f.standardizedPct >= 80
? 'Applicants are being scored consistently.'
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
},
{
label: 'Response speed',
value: f.timeToHire ? `${f.timeToHire}d` : '—',
ok: f.timeToHire > 0 && f.timeToHire <= 3,
note: f.timeToHire && f.timeToHire <= 3
? 'Inside the window where good candidates are still available.'
: 'Event staff accept other work within days — past 3 days you lose people.',
},
{
label: 'Quality of hire',
value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—',
ok: f.hiredAvgScore >= 80,
note: f.hiredAvgScore >= 80
? 'You are hiring from the top of your own pool.'
: 'Hires are scoring mid-pack — widen the funnel before lowering the bar.',
},
{
label: 'Pipeline supply',
value: `${f.openPositions.length} open`,
ok: f.starvedPositions.length === 0,
note: f.starvedPositions.length
? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.`
: 'Every open position has applicants.',
},
];
const passing = checks.filter((c) => c.ok).length;
return compose(
heading(`${passing} of ${checks.length} signals healthy`),
'',
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n')
);
};
const pendingActions = (f) => {
const actions = [];
if (f.stalled.length) {
actions.push(`**Move your 80+ candidates.** ${f.stalled.slice(0, 3).map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`);
}
if (f.unscreened.length) {
actions.push(`**Screen the backlog.** ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} no score. One pass clears it.`);
}
if (f.starvedPositions.length) {
actions.push(`**Fix the postings nobody applies to.** ${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`);
}
if (f.unratedStaff.length) {
actions.push(`**Rate your hires.** ${plural(f.unratedStaff.length, 'person', 'people')} ${verb(f.unratedStaff.length, 'has', 'have')} no rating. Ratings sharpen future matching.`);
}
if (f.elite.length) {
actions.push(`**Reach out directly.** ${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`);
}
if (!actions.length) return 'Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.';
return compose(heading('Pending actions'), '', numbered(actions.slice(0, 4)));
};
const hiringRisks = (f) => {
const risks = [];
if (f.singleCandidateRoles.length) {
risks.push(`**Single-candidate roles.** ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`);
}
if (f.stalled.length) {
risks.push(`**Losing strong candidates to delay.** ${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'has', 'have')} been screened but not moved. This is where good hires quietly disappear.`);
}
if (f.missingCredentials.length) {
risks.push(`**Compliance exposure.** ${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`);
}
if (f.narrowAvailability.length) {
risks.push(`**Availability risk.** ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. ${verb(f.narrowAvailability.length, 'This is the placement', 'These are the placements')} most likely to fall through on the day.`);
}
if (f.starvedPositions.length) {
risks.push(`**Unfillable as posted.** ${plural(f.starvedPositions.length, 'open role')} with zero applicants after being live.`);
}
if (f.flagged.length) {
risks.push(`**Interview integrity.** ${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`);
}
if (!risks.length) return 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.';
return compose(
heading(`${plural(risks.length, 'risk')} to watch`),
'',
numbered(risks),
'',
'_Flags to check, not conclusions. Each is a question for a human._'
);
};
/* ── Candidates ─────────────────────────────────────────────────────────── */
const resumeAnalysis = (f) => {
const subject = f.top[0];
if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.';
return compose(
heading(`${subject.applicant_name} — ${subject.ai_score}/100`),
`${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`,
'',
subject.ai_summary || '',
'',
subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '',
'',
subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '',
'',
subject.certifications?.length
? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.`
: '**No credentials on file.** Request these before scheduling.'
);
};
const candidateComparison = (f) => {
const pair = f.ranked.slice(0, 2);
if (pair.length < 2) return 'I need at least two scored candidates to compare.';
const [a, b] = pair;
const gap = a.ai_score - b.ai_score;
const dims = ['experience', 'english', 'reliability', 'certifications', 'availability'];
const rows = dims.map((dim) => {
const av = a.score_breakdown?.[dim] ?? 0;
const bv = b.score_breakdown?.[dim] ?? 0;
const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0];
return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`;
});
const biggest = dims.reduce((best, d) =>
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, dims[0]);
return compose(
heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`),
'',
bullets(rows),
'',
gap <= 3
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
: `${a.applicant_name} leads by ${gap}, driven mostly by ${biggest}.`
);
};
const interviewQuestions = (f) => {
const subject = f.stalled[0] || f.ranked[0];
if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.';
const gaps = subject.ai_gaps || [];
return compose(
heading(`Questions for ${subject.applicant_name}`),
'Built from their gaps rather than a generic list, so the interview tests what the score could not.',
'',
numbered([
`Walk me through your busiest ${subject.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
gaps[0]
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
: 'Tell me about a shift that went wrong. What was your part in it?',
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
subject.certifications?.length
? `Your ${subject.certifications[0]} is on file — when did you last apply it on a live shift?`
: 'Which certifications are you working toward, and why that one?',
'What does your real availability look like over the next eight weeks?',
])
);
};
const skillGapAnalysis = (f) => {
if (!f.scored.length) return 'No scored candidates yet, so there are no gaps to analyse.';
const tally = {};
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: ');
tally[key] = (tally[key] || 0) + 1;
}));
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
if (!ranked.length) return 'No recurring gaps across your scored candidates — the pool matches your postings well.';
return compose(
heading('Recurring gaps'),
'',
bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)),
'',
f.missingCredentials.length >= 2
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
);
};
const hiringRecommendation = (f) => {
if (!f.scored.length) return 'Nothing scored yet, so I have no basis for a recommendation.';
const open = (a) => !['hired', 'rejected'].includes(a.status);
const shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a));
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a));
const pass = f.weak.filter(open);
return compose(
heading('Recommendation'),
'',
shortlist.length
? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}`
: '**Shortlist** — nobody currently clears 80.',
'',
interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
'',
pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
'',
'_A recommendation, not a decision. I score what is on file — I have not met these people._'
);
};
/* ── Analytics ──────────────────────────────────────────────────────────── */
const explainCharts = (f) => compose(
heading('What these charts are saying'),
'',
`**Pipeline Stages** — ${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
'',
`**Candidate Scores** — ${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length}; that bucket is your real hiring pool.`,
'',
`**Interview Completion** — ${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
'',
`**Cost Saved** — $${f.costSaved.toLocaleString()}, from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80. Industry per-unit costs for doing it manually, so treat it as a floor.`
);
const forecastHiring = (f) => {
const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status));
const conversion = f.hireRate || 14;
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
const short = f.openPositions.length - expected;
return compose(
heading('Forecast'),
'',
bullets([
`${plural(available.length, 'candidate')} at 60+ still in play`,
`At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`,
f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet',
`${plural(f.openPositions.length, 'open position')} to fill`,
]),
'',
short > 0
? `That leaves you roughly ${plural(short, 'role')} short. Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`
: 'That covers your open roles, assuming the strong candidates do not go elsewhere first.',
'',
'_Projected from your own conversion rate, not a promise — small pools move a lot._'
);
};
const departmentInsights = (f) => {
if (!f.byRole.length) return 'No open positions to break down.';
const byCategory = {};
f.byRole.forEach((r) => {
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: [] };
byCategory[r.category].applied += r.applied;
byCategory[r.category].hired += r.hired;
byCategory[r.category].qualified += r.qualified;
byCategory[r.category].roles.push(r.title);
});
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
const starved = entries.filter(([, d]) => d.applied === 0);
return compose(
heading('By role category'),
'',
entries.map(([category, d]) =>
`**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}`
).join('\n'),
'',
starved.length
? `${starved.map(([c]) => c).join(' and ')} ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
: 'Every category has applicants, so your reach is working across the board.'
);
};
const generateReports = (f) => {
const stamp = f.today.toISOString().slice(0, 10);
return compose(
heading(`Hiring report — ${stamp}`),
'',
heading('Volume'),
bullets([
`${plural(f.total, 'applicant')} received`,
`${f.screened.length} screened (${f.standardizedPct}% coverage)`,
`${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`,
`${plural(f.hired.length, 'hire')} closed`,
]),
'',
heading('Quality'),
bullets([
`Average candidate score ${f.avgScore}`,
`Average hire score ${f.hiredAvgScore}`,
`Hire rate ${f.hireRate}%`,
`Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`,
]),
'',
heading('Efficiency'),
bullets([
`$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`,
`≈${f.hoursSaved} recruiter hours returned`,
]),
'',
heading('By role'),
bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)),
'',
f.bottleneck
? `**Headline finding.** The ${f.bottleneck.from} → ${f.bottleneck.to} step passes only ${f.bottleneck.rate}%, losing ${plural(f.bottleneck.lost, 'candidate')}. That is the single biggest recoverable loss in the funnel.`
: '**Headline finding.** The funnel is passing through evenly with no single bottleneck.',
'',
'_Copy this into your own template — export to PDF and CSV is not wired up in this build._'
);
};
/* ── Free-text responders ───────────────────────────────────────────────── */
const has = (q, ...words) => words.some((w) => q.includes(w));
const stateOfPlay = (f) =>
`I do not have a specific read on that. What I can see: ${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}, ${f.screened.length} screened, ${plural(f.interviewing.length, 'candidate')} interviewing, ${plural(f.hired.length, 'hire')}. Ask about any of those and I will go deeper.`;
const bottleneckAnswer = (f) => {
if (!f.bottleneck) return 'Not enough pipeline movement yet to locate a bottleneck.';
const b = f.bottleneck;
const diagnosis = {
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap in the funnel to close.`,
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
}[b.to];
return compose(
heading(`Bottleneck: ${b.from} → ${b.to}`),
`Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`,
'',
diagnosis || '',
'',
bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`))
);
};
export const respondOverview = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next')) return pendingActions(f);
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
if (has(q, 'summary', 'today', 'brief', 'catch me up', 'overview')) return hiringSummary(f);
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
return stateOfPlay(f);
};
export const respondCandidates = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(f);
if (has(q, 'compare', 'versus', ' vs ', 'between')) return candidateComparison(f);
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
if (has(q, 'unscreened', 'not screened', 'pending')) {
return f.unscreened.length
? compose(
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
'',
bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)),
f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : ''
)
: 'Everyone has been screened.';
}
return stateOfPlay(f);
};
export const respondAnalytics = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
if (has(q, 'forecast', 'predict', 'projection', 'will i')) return forecastHiring(f);
if (has(q, 'department', 'category', 'role', 'team')) return departmentInsights(f);
if (has(q, 'report', 'export', 'summar', 'download')) return generateReports(f);
if (has(q, 'bottleneck', 'drop', 'stuck', 'funnel', 'lose')) return bottleneckAnswer(f);
if (has(q, 'hire rate', 'conversion')) {
return `${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`;
}
if (has(q, 'cost', 'saved', 'roi', 'money')) {
return `$${f.costSaved.toLocaleString()} — ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each. Also about ${f.hoursSaved} recruiter hours you did not spend.`;
}
return stateOfPlay(f);
};
/* ── Capability manifests ───────────────────────────────────────────────── */
export const OVERVIEW_CAPABILITIES = [
{ id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary },
{ id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth },
{ id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions },
{ id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks },
];
export const CANDIDATE_CAPABILITIES = [
{ id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis },
{ id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison },
{ id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions },
{ id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis },
{ id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation },
];
export const ANALYTICS_CAPABILITIES = [
{ id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts },
{ id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring },
{ id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights },
{ id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports },
];

View File

@@ -0,0 +1,157 @@
import { BookOpen, Compass, FileText } from 'lucide-react';
import { bullets, compose, heading, numbered, plural } from '../insights';
import { getScoreBand, toFICO } from '@/lib/talentHome';
/**
* Talent capabilities — My Portal.
*
* The register shifts here: an employer wants a read on a pipeline, a worker
* wants to know what to do next to earn more. Answers name the specific course
* or step, because "improve your skills" helps nobody.
*/
const scoreOf = (p) => Number(p?.krow_score) || 0;
const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give you specifics instead of generalities.';
const resumeReview = (f) => {
const p = f.profile;
if (!p) return NO_PROFILE;
const missing = [];
if (!p.phone) missing.push('phone number');
if (!p.address) missing.push('location');
if (!(p.skills || []).length) missing.push('skills');
if (!(p.experience || []).length) missing.push('work history');
if (!(p.certifications || []).length) missing.push('certifications');
if (!p.career_goals) missing.push('career goals');
if (!(p.availability || []).length) missing.push('availability');
if (!p.selfie_url) missing.push('photo');
const present = [];
if ((p.experience || []).length) present.push(`${plural(p.experience.length, 'role')} of work history`);
if ((p.skills || []).length) present.push(`${plural(p.skills.length, 'skill')} listed`);
if ((p.certifications || []).length) present.push(p.certifications.join(', '));
if ((p.capabilities || []).length) present.push(`${plural(p.capabilities.length, 'capability')} verified by evidence`);
return compose(
heading(`Your profile is ${p.profile_completion || 0}% complete`),
'',
present.length ? compose(heading('What is working'), bullets(present)) : '',
'',
missing.length
? compose(
heading('What is missing'),
bullets(missing),
'',
'Employers filter on availability and certifications before they read anything else. If you fix two things, fix those.'
)
: compose(heading('Nothing is missing'), 'From here, more verified evidence moves you — not more fields.'),
'',
(p.capabilities || []).length
? '_Your verified capabilities are the part employers actually trust — those came with evidence attached, not a checkbox._'
: '_Nothing is verified yet. One completed challenge is worth more than ten claimed skills._'
);
};
const careerGuidance = (f) => {
const p = f.profile;
if (!p) return NO_PROFILE;
const score = scoreOf(p);
const band = getScoreBand(score);
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
return compose(
heading(`You are at ${toFICO(score)} — ${band.label}`),
'',
bullets([
`Reliability ${p.reliability_score || 0}/100`,
`Attendance ${p.attendance_score ?? 0}/100`,
`${plural((p.completed_courses || []).length, 'course')} completed, ${plural((p.earned_badges || []).length, 'badge')} earned`,
`${plural((p.capabilities || []).length, 'verified capability')}`,
`${plural(p.shifts_completed || 0, 'shift')} worked`,
]),
'',
target
? `**To reach ${target}:** verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.`
: '**You are in the top band.** From here it is about holding it — the score decays if shifts stop.',
'',
p.desired_position
? `You want to be ${p.desired_position}. ${score >= 75 ? 'You are already scoring in the range employers shortlist for that.' : 'The gap to that role is verification, not ambition.'}`
: 'You have not set a target role. Setting one lets me tell you which courses matter and which are noise.',
'',
(p.shifts_completed || 0) === 0
? '**One concrete step:** work a single shift. Attendance, ratings and reliability all need a first data point before anything else compounds.'
: ''
);
};
const learningRecommendations = (f) => {
const p = f.profile;
if (!p) return NO_PROFILE;
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
const done = (p.completed_courses || []).length;
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
if (!available.length) {
return compose(
heading('You have completed everything available'),
`${plural(done, 'course')} done, ${plural((p.earned_badges || []).length, 'badge')} earned, ${p.xp || 0} XP.`,
'',
'The next lever is verified shifts — that is what employers weight most.'
);
}
const byValue = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
const shifts = p.shifts_completed || 0;
return compose(
heading('What to learn next'),
'',
numbered(byValue.slice(0, 3).map((c) => {
const gate = c.unlock_requirements?.min_shifts || 0;
const locked = gate > shifts;
return `**${c.title}** — ${c.difficulty}, ${c.estimated_minutes} min, +${c.xp} XP\n Proves: ${c.proof_skill || c.title}.${locked ? ` Locked until ${plural(gate, 'shift')} (you have ${shifts}).` : ' Available now.'}`;
})),
'',
heading('Where you stand'),
bullets([
`${plural(done, 'course')} completed`,
`${plural((p.earned_badges || []).length, 'badge')} earned`,
`${p.xp || 0} XP`,
`${plural((p.capabilities || []).length, 'capability')} verified`,
]),
'',
done === 0
? 'Start with the shortest one. The point of the first course is not the XP — it is getting one verified capability on the board.'
: done < 3
? `Three completions is where the score starts moving noticeably, because education carries real weight in the formula. You are at ${done}.`
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
'',
'Each of these ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored. That is why the badge means something to an employer.'
);
};
/* ── Free-text responder ────────────────────────────────────────────────── */
const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondTalent = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'resume', 'résumé', 'cv', 'profile')) return resumeReview(f);
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge')) return learningRecommendations(f);
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next')) return careerGuidance(f);
const p = f.profile;
return p
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
: NO_PROFILE;
};
export const TALENT_CAPABILITIES = [
{ id: 'resume-review', label: 'Resume Review', icon: FileText, run: resumeReview },
{ id: 'career-guidance', label: 'Career Guidance', icon: Compass, run: careerGuidance },
{ id: 'learning-recommendations', label: 'Learning Recommendations', icon: BookOpen, run: learningRecommendations },
];

View File

@@ -0,0 +1,163 @@
import {
ANALYTICS_CAPABILITIES, CANDIDATE_CAPABILITIES, OVERVIEW_CAPABILITIES,
respondAnalytics, respondCandidates, respondOverview,
} from './capabilities/employer';
import {
ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CONTROL_CENTER_CAPABILITIES,
respondActivity, respondAdminCandidates, respondControlCenter,
} from './capabilities/admin';
import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
/**
* Page contexts for the Krow AI Assistant.
*
* The component is identical on every page. Only what is in this file changes:
* the supporting line, the placeholder examples, the suggested chips, the
* capabilities and the responder.
*
* Chips are phrased as things a person would actually type, because clicking one
* fills the input rather than firing an action. The user always sees the
* question before it is asked, so the assistant reads as a conversation rather
* than a menu of buttons.
*
* `capability` binds a chip to a specific capability, so a chip gets a precise
* answer while an equivalent typed question still resolves through the
* responder's keyword matching.
*
* `needsWorkerProfile` gates the one query with a side effect, so employer and
* admin sessions never touch worker-profile records.
*/
export const ASSISTANT_CONTEXTS = {
'employer.overview': {
id: 'employer.overview',
page: 'Overview',
tagline: 'Find insights from your hiring data, spot what needs attention, and ask questions about this page.',
placeholders: [
"Ask about today's hiring…",
'What should I do first?',
'Which roles are at risk?',
'How healthy is my pipeline?',
],
chips: [
{ label: "Today's Hiring", prompt: 'Give me a summary of hiring today', capability: 'hiring-summary' },
{ label: 'Pipeline Health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
{ label: 'Pending Actions', prompt: 'What is pending and needs my attention?', capability: 'pending-actions' },
{ label: 'Hiring Risks', prompt: 'What risks should I know about?', capability: 'hiring-risks' },
],
capabilities: OVERVIEW_CAPABILITIES,
respond: respondOverview,
},
'employer.candidates': {
id: 'employer.candidates',
page: 'Candidates',
tagline: 'Analyse résumés, compare candidates, generate interview questions, and decide who to move forward.',
placeholders: [
'Compare two candidates…',
'Who should I interview next?',
'What are this candidate’s gaps?',
'Draft interview questions…',
],
chips: [
{ label: 'Top Candidates', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
{ label: 'Compare Candidates', prompt: 'Compare my top two candidates', capability: 'candidate-comparison' },
{ label: 'Interview Questions', prompt: 'Generate interview questions for my top candidate', capability: 'interview-questions' },
{ label: 'Skill Gap Analysis', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
{ label: 'Hiring Recommendation', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
],
capabilities: CANDIDATE_CAPABILITIES,
respond: respondCandidates,
},
'employer.analytics': {
id: 'employer.analytics',
page: 'Analytics',
tagline: 'Explain the numbers on this page, forecast what your pipeline will yield, and generate reports.',
placeholders: [
'Explain this chart…',
'Generate a hiring report…',
'Where do candidates drop off?',
'How many hires can I expect?',
],
chips: [
{ label: 'Explain Charts', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
{ label: 'Forecast Hiring', prompt: 'Forecast my hiring from the current pipeline', capability: 'forecast-hiring' },
{ label: 'Department Insights', prompt: 'Break down performance by role category', capability: 'department-insights' },
{ label: 'Generate Report', prompt: 'Generate a hiring report', capability: 'generate-reports' },
],
capabilities: ANALYTICS_CAPABILITIES,
respond: respondAnalytics,
},
'admin.controlCenter': {
id: 'admin.controlCenter',
page: 'Control Center',
tagline: 'Check platform health, reconcile the data, and see what needs intervention across every account.',
placeholders: [
'Is anything broken?',
'What should I fix first?',
'How is the platform performing?',
],
chips: [
{ label: 'Platform Health', prompt: 'Run a platform health check', capability: 'platform-health' },
{ label: 'Recommendations', prompt: 'What should I fix first?', capability: 'recommendations' },
],
capabilities: CONTROL_CENTER_CAPABILITIES,
respond: respondControlCenter,
},
'admin.candidates': {
id: 'admin.candidates',
page: 'Candidates Analysis',
tagline: 'Analyse talent supply across the platform and see where the risk sits in the candidate pool.',
placeholders: [
'How healthy is talent supply?',
'Any candidate risk flags?',
'What are the recruitment trends?',
],
chips: [
{ label: 'Recruitment Insights', prompt: 'Show me recruitment insights across the platform', capability: 'recruitment-insights' },
{ label: 'Candidate Risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
],
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates,
},
'admin.activity': {
id: 'admin.activity',
page: 'Activity',
tagline: 'Summarize the audit trail, reconcile it against real records, and see how accounts are behaving.',
placeholders: [
'Summarize the audit trail…',
'Who is most active?',
'Are there any gaps in the log?',
],
chips: [
{ label: 'Audit Summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
{ label: 'User Activity', prompt: 'Which accounts are most active?', capability: 'user-activity' },
],
capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity,
},
'talent.portal': {
id: 'talent.portal',
page: 'My Portal',
tagline: 'Understand your score, improve your profile, and find the next thing worth learning.',
placeholders: [
'How do I earn more?',
'Review my profile…',
'What should I learn next?',
],
chips: [
{ label: 'Review My Profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
{ label: 'Career Guidance', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
{ label: 'What To Learn Next', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
],
capabilities: TALENT_CAPABILITIES,
respond: respondTalent,
needsWorkerProfile: true,
},
};
export const getContext = (id) => ASSISTANT_CONTEXTS[id] ?? null;

View File

@@ -0,0 +1,22 @@
/**
* Krow AI Assistant — the dashboard's contextual productivity assistant.
*
* Architecture:
*
* AssistantPanelProvider open/closed state, resolved page context
* AssistantTrigger the KROW mark, rendered beside a page's actions
* AssistantPanel the sliding column the layout renders
* KrowAssistant the one assistant UI, reused on every page
* provider.js the transport seam — local today, HTTP later
*
* Independent of Owliver, which remains a separate product. Nothing here imports
* Owliver code, and the two never share UI.
*/
export { AssistantPanelProvider, useAssistantPanel } from './AssistantPanelContext';
export { AssistantTrigger } from './AssistantTrigger';
export { AssistantPanel } from './AssistantPanel';
export { default as KrowAssistant } from './KrowAssistant';
export { ASSISTANT_CONTEXTS, getContext } from './contexts';
export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement';
export { createHttpProvider, createLocalProvider } from './provider';

View File

@@ -0,0 +1,186 @@
/**
* Dashboard fact sheet.
*
* Every assistant answer is derived from here, using the same formulas the
* dashboard components use — so the assistant can never state a figure that
* contradicts the card or chart sitting next to it.
*
* This module is pure: dashboard data in, derived facts out. No UI, no
* transport, no dependency on any AI product.
*/
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
export const plural = (n, word, irregular) =>
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
const avg = (values) =>
values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
const DAY_MS = 1000 * 60 * 60 * 24;
/** `today` is injected rather than read from the clock so answers are stable. */
export function buildFacts({
applications = [],
postings = [],
interviews = [],
staff = [],
profiles = [],
activity = [],
courses = [],
profile = null,
today = new Date(),
}) {
const scored = applications.filter((a) => a.ai_score > 0);
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
const byStatus = (...statuses) => applications.filter((a) => statuses.includes(a.status));
const unscreened = byStatus('applied');
const screened = byStatus('ai_screened', 'shortlisted', 'interview', 'hired');
const interviewing = byStatus('interview');
const hired = byStatus('hired');
const openPositions = postings.filter((p) => p.status === 'active');
const applicationsFor = (id) => applications.filter((a) => a.job_posting_id === id);
const completedInterviews = interviews.filter((i) => i.overall_interview_score > 0);
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
const timeToHire = hired.length
? Math.round(avg(hired.map((a) =>
Math.max(1, (new Date(a.updated_date) - new Date(a.created_date)) / DAY_MS))))
: 0;
/* Open roles attracting nobody. */
const starvedPositions = openPositions.filter((p) => applicationsFor(p.id).length === 0);
/* Roles resting on a single candidate — one withdrawal and the role reopens. */
const singleCandidateRoles = openPositions
.map((p) => ({ posting: p, viable: applicationsFor(p.id).filter((a) => a.ai_score >= 60) }))
.filter((e) => e.viable.length === 1);
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
const stalled = ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened');
const funnel = [
{ key: 'applied', label: 'Applied', count: applications.length },
{ key: 'ai_screened', label: 'AI Screened', count: screened.length },
{ key: 'shortlisted', label: 'Shortlisted', count: byStatus('shortlisted', 'interview', 'hired').length },
{ key: 'interview', label: 'Interviewed', count: interviewing.length + hired.length },
{ key: 'hired', label: 'Hired', count: hired.length },
];
const transitions = funnel.slice(1).map((stage, i) => ({
from: funnel[i].label,
to: stage.label,
rate: pct(stage.count, funnel[i].count),
lost: Math.max(0, funnel[i].count - stage.count),
}));
const bottleneck = [...transitions].sort((a, b) => a.rate - b.rate)[0] || null;
const byRole = openPositions.map((p) => {
const apps = applicationsFor(p.id);
const roleScored = apps.filter((a) => a.ai_score > 0);
return {
posting: p,
title: p.title,
category: p.role_category || 'Uncategorized',
applied: apps.length,
screened: apps.filter((a) => a.status !== 'applied').length,
hired: apps.filter((a) => a.status === 'hired').length,
avgScore: avg(roleScored.map((a) => a.ai_score)),
qualified: roleScored.filter((a) => a.ai_score >= 70).length,
};
});
const since = (days) => {
const cutoff = today.getTime() - days * DAY_MS;
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
};
const eventCounts = activity.reduce((acc, e) => {
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
return acc;
}, {});
return {
today,
/* Applications */
applications,
total: applications.length,
scored,
ranked,
top: ranked.slice(0, 3),
weak: ranked.filter((a) => a.ai_score > 0 && a.ai_score < 40),
unscreened,
screened,
interviewing,
hired,
stalled,
avgScore: avg(scored.map((a) => a.ai_score)),
hiredAvgScore: avg(hired.map((a) => a.ai_score)),
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
/* Positions */
postings,
openPositions,
starvedPositions,
singleCandidateRoles,
byRole,
applicationsFor,
/* Interviews */
interviews,
completedInterviews,
avgInterviewScore: avg(completedInterviews.map((i) => i.overall_interview_score)),
interviewCompletion: pct(completedInterviews.length, interviews.length),
flagged: interviews.filter((i) => (i.integrity_score ?? 100) < 100),
/* Hires */
staff,
unratedStaff: staff.filter((s) => !s.client_rating),
timeToHire,
hireRate: pct(hired.length, applications.length),
/* Funnel */
funnel,
transitions,
bottleneck,
/* Talent pool */
profiles,
elite: profiles.filter((p) => (p.krow_score || 0) >= 90),
unscoredProfiles: profiles.filter((p) => !(p.krow_score > 0)),
/* Activity */
activity,
activity7d: since(7),
activity24h: since(1),
eventCounts,
/* Signed-in worker (talent context only) */
profile,
courses,
/* Economics — mirrors ImpactMetrics */
costSaved: scored.length * 45 + interviews.length * 80,
hoursSaved: Math.round((scored.length * 15 + interviews.length * 30) / 60),
standardizedPct: pct(scored.length, applications.length),
};
}
/* ── Answer formatting ──────────────────────────────────────────────────── */
export const bullets = (items) => items.filter(Boolean).map((t) => `• ${t}`).join('\n');
export const numbered = (items) =>
items.filter(Boolean).map((t, i) => `${i + 1}. ${t}`).join('\n\n');
export const heading = (text) => `**${text}**`;
/** Joins answer blocks, dropping empties so templates can use conditionals. */
export const compose = (...blocks) => blocks.filter((b) => b !== null && b !== undefined && b !== '').join('\n');

View File

@@ -0,0 +1,57 @@
import { ASSISTANT_CONTEXTS } from './contexts';
/**
* Where the Krow AI Assistant appears.
*
* The assistant occupies a permanent column in the page layout, so enabling it
* on a page is a layout decision, not a feature flag — which is exactly why the
* decision lives in one table rather than being scattered across pages.
*
* Matching is exact, not prefix-based: `/candidates` must not leak an assistant
* onto a future `/candidates/:id`, and `/positions/:id` must not inherit one
* from `/positions`.
*/
const PLACEMENT = {
employer: {
'/': 'employer.overview',
'/candidates': 'employer.candidates',
'/analytics': 'employer.analytics',
},
admin: {
'/admin': 'admin.controlCenter', // Control Center
'/candidates': 'admin.candidates', // Candidates Analysis
'/tracking': 'admin.activity', // Activity
},
talent: {
'/employee': 'talent.portal', // My Portal
},
};
/**
* Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission.
*
* Employer: Positions, Position Detail, Create Position, Hired History,
* Talent Pool, Profile.
* Admin: everything except the three above.
* Talent: everything except My Portal.
*/
export const EXCLUDED_ROUTES = [
'/positions', '/positions/new', '/positions/:id',
'/hired', '/talent-pool', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity',
'/owliver', '/design-system',
];
/** Returns the context for a role and path, or `null` to render no assistant. */
export function resolveAssistantContext(role, pathname) {
const id = PLACEMENT[role]?.[pathname];
return id ? ASSISTANT_CONTEXTS[id] : null;
}
/** Every enabled role/route pair — used by the placement verification. */
export function enabledRoutes() {
return Object.entries(PLACEMENT).flatMap(([role, routes]) =>
Object.entries(routes).map(([path, contextId]) => ({ role, path, contextId }))
);
}

View File

@@ -0,0 +1,139 @@
/**
* Provider seam for the Krow AI Assistant.
*
* The UI never generates an answer and never knows where one came from. It calls
* `provider.stream(request)` and renders the chunks. That single boundary is
* what makes the backend swappable: today a local provider computes answers from
* dashboard data, and later an HTTP provider can call a real service without a
* single change to the component.
*
* ── Contract ────────────────────────────────────────────────────────────────
*
* provider.id: string
* provider.stream(request): AsyncIterable<string> // yields text deltas
*
* request = {
* contextId: string, // e.g. 'employer.overview'
* capability: string | null, // capability id, or null for free text
* question: string, // what the user typed, or the capability label
* facts: object, // the dashboard fact sheet (see insights.js)
* signal: AbortSignal, // aborts an in-flight response
* }
*
* Deltas rather than a whole string is deliberate: it is the shape every
* streaming completion API returns, so the local provider and a future remote
* one are interchangeable without the UI learning a second pattern.
*/
import { getContext } from './contexts';
/** Words per delta. Chunking by word keeps reflow to once per word. */
const WORDS_PER_CHUNK = 3;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Local provider — answers computed from the dashboard's own data.
*
* Deterministic: the same question against the same data returns the same
* answer, which is what makes the assistant demonstrable and testable.
*/
export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
return {
id: 'local',
async *stream({ contextId, capability, question, facts, signal }) {
const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
const answer = capability
? context.capabilities.find((c) => c.id === capability)?.run(facts)
?? context.respond(question, facts)
: context.respond(question, facts);
// A brief pause before the first delta, so the answer reads as considered
// rather than precomputed.
await sleep(latency);
if (signal?.aborted) return;
const tokens = String(answer).split(/(\s+)/);
for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
if (signal?.aborted) return;
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
await sleep(chunkDelay);
}
},
};
}
/**
* HTTP provider — the path to a real backend.
*
* Reads Server-Sent-Event style `data:` lines from a streaming endpoint and
* yields the text deltas. Unused today; it exists so the shape of the
* integration is settled rather than guessed at later. Swapping providers is a
* one-line change in `AssistantProvider`.
*
* The request body sends `contextId`, `capability` and `question` — not the
* fact sheet. Dashboard data should be fetched server-side from the caller's own
* session rather than posted from the browser, so the client cannot ask about
* records it is not entitled to see.
*/
export function createHttpProvider({ endpoint, headers = {} }) {
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
return {
id: 'http',
async *stream({ contextId, capability, question, signal }) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ contextId, capability, question }),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`Assistant request failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// The final element may be a partial line; keep it for the next read.
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
const parsed = JSON.parse(payload);
if (parsed.delta) yield parsed.delta;
} catch {
// A non-JSON data line is treated as a raw delta.
yield payload;
}
}
}
},
};
}
/**
* The provider the app uses.
*
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
* switch, with no other code change.
*/
export function createAssistantProvider() {
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
}

View File

@@ -0,0 +1,156 @@
import * as React from 'react';
import {
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();
/**
* Reads the same React Query caches the pages render from, so the assistant
* costs no extra requests and cannot be looking at a different snapshot than the
* card beside it.
*/
export function useAssistantFacts({ needsWorkerProfile = false } = {}) {
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const { data: activity = [] } = useUserActivity();
const { data: courses = [] } = useCourses();
// Gated: this query creates the record if absent, so it must not run for
// employer or admin sessions.
const { data: profile = null } = useWorkerProfile({ enabled: needsWorkerProfile });
return React.useMemo(
() => buildFacts({
applications, postings, interviews, staff, profiles, activity, courses,
profile: needsWorkerProfile ? profile : null,
}),
[applications, postings, interviews, staff, profiles, activity, courses, profile, needsWorkerProfile]
);
}
/**
* Conversation state for one page context.
*
* Owns the thread, the in-flight response, and abort handling. The thread is
* kept per context in sessionStorage so navigating away from Candidates and back
* does not silently discard the conversation — while a stale thread from last
* week is still not resurrected.
*/
export function useConversation({ contextId, facts }) {
const storageKey = `krow_assistant:${contextId}`;
const [messages, setMessages] = React.useState([]);
const [pending, setPending] = React.useState(null); // { question, text, thinking }
const [error, setError] = React.useState(null);
const abortRef = React.useRef(null);
/* Load the stored thread, and reload when the context changes. */
React.useEffect(() => {
try {
const raw = sessionStorage.getItem(storageKey);
setMessages(raw ? JSON.parse(raw) : []);
} catch {
setMessages([]);
}
setPending(null);
setError(null);
}, [storageKey]);
const persist = React.useCallback((next) => {
setMessages(next);
try {
sessionStorage.setItem(storageKey, JSON.stringify(next));
} catch {
// Quota or private mode — the thread stays in memory only.
}
}, [storageKey]);
/* Abort any in-flight response when the context changes or we unmount. */
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
// The stream callback runs outside render, so it needs the latest thread
// rather than the value captured when the request started.
const messagesRef = React.useRef(messages);
messagesRef.current = messages;
const send = React.useCallback(async ({ question, capability = null }) => {
const text = String(question).trim();
if (!text) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const withUser = [...messagesRef.current, { role: 'user', text }];
messagesRef.current = withUser;
persist(withUser);
setError(null);
setPending({ question: text, text: '', thinking: true });
try {
let assembled = '';
for await (const delta of provider.stream({
contextId, capability, question: text, facts, signal: controller.signal,
})) {
if (controller.signal.aborted) break;
assembled += delta;
setPending({ question: text, text: assembled, thinking: false });
}
if (controller.signal.aborted) {
// Keep whatever streamed before the user stopped it — discarding a
// half-written answer loses information the user was already reading.
if (assembled) {
const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }];
messagesRef.current = next;
persist(next);
}
} else {
const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
messagesRef.current = next;
persist(next);
}
} catch (e) {
if (e?.name !== 'AbortError') {
setError('I could not complete that. Try again in a moment.');
}
} finally {
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);
const reset = React.useCallback(() => {
abortRef.current?.abort();
messagesRef.current = [];
setMessages([]);
setPending(null);
setError(null);
try {
sessionStorage.removeItem(storageKey);
} catch {
// Ignore.
}
}, [storageKey]);
return {
messages,
pending,
error,
busy: Boolean(pending),
send,
stop,
reset,
providerId: provider.id,
};
}

View File

@@ -0,0 +1,100 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Avatar } from '@/components/ds/Avatar';
import { Surface } from '@/components/ds/Surface';
import { StatusBadge } from '@/components/ds/StatusBadge';
/**
* ActivityCard — one event in a feed: who did what, when.
*
* Renders as a row by default (feeds are dense) and as a bordered card when
* `variant="card"`. Either way the anatomy is fixed — actor, sentence, meta,
* trailing slot — so a feed built from mixed event types stays aligned.
*/
export function ActivityCard({
/** Person or system that acted. */
actor,
actorSrc,
/** Icon shown instead of an avatar, for system events. */
icon: Icon,
iconTone = 'soft',
/** What happened — "Screened Sofia Mendez". */
title,
/** Supporting detail. */
description,
timestamp,
status,
/** Right-aligned slot — a score, a button. */
trailing,
variant = 'row',
onClick,
className,
}) {
const iconTones = {
soft: 'bg-krow-blue-tint text-krow-blue',
brand: 'bg-krow-blue text-white',
success: 'bg-success-muted text-success',
warning: 'bg-warning-muted text-warning',
destructive: 'bg-destructive-muted text-destructive',
neutral: 'bg-surface-sunken text-ink-3',
};
const body = (
<>
{Icon ? (
<span className={cn('grid place-items-center w-9 h-9 rounded-xl shrink-0', iconTones[iconTone])}>
<Icon className="w-4 h-4" aria-hidden="true" />
</span>
) : (
<Avatar name={actor} src={actorSrc} size="default" />
)}
<div className="flex-1 min-w-0">
<p className="text-body-sm text-ink-1">
{actor && <span className="font-semibold">{actor}</span>}
{actor && title && ' '}
{title && <span className={actor ? 'text-ink-2' : 'font-medium'}>{title}</span>}
</p>
{description && <p className="text-caption text-ink-3 mt-0.5 truncate-2">{description}</p>}
{timestamp && <p className="text-caption text-ink-4 mt-1">{timestamp}</p>}
</div>
{(status || trailing) && (
<div className="flex items-center gap-2 shrink-0">
{status && <StatusBadge status={status} size="sm" />}
{trailing}
</div>
)}
</>
);
if (variant === 'card') {
return (
<Surface
as={onClick ? 'button' : 'div'}
onClick={onClick}
interactive={onClick ? true : undefined}
variant="solid"
radius="lg"
padding="sm"
className={cn('flex items-start gap-3 w-full text-left', className)}
>
{body}
</Surface>
);
}
const Comp = onClick ? 'button' : 'div';
return (
<Comp
onClick={onClick}
className={cn(
'flex items-start gap-3 w-full text-left rounded-xl p-2.5 transition-colors duration-fast',
onClick && 'hover:bg-white/60 cursor-pointer focus-ring',
className
)}
>
{body}
</Comp>
);
}

View File

@@ -0,0 +1,71 @@
import * as React from 'react';
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva('flex items-start gap-3 rounded-xl border p-4 animate-slide-down', {
variants: {
tone: {
info: 'bg-info-muted border-krow-blue/20 text-ink-2',
success: 'bg-success-muted border-success/20 text-ink-2',
warning: 'bg-warning-muted border-warning/25 text-ink-2',
destructive: 'bg-destructive-muted border-destructive/20 text-ink-2',
neutral: 'bg-surface-subtle border-border text-ink-2',
},
},
defaultVariants: { tone: 'info' },
});
const ICONS = {
info: Info,
success: CheckCircle2,
warning: TriangleAlert,
destructive: AlertCircle,
neutral: Info,
};
const ICON_COLORS = {
info: 'text-krow-blue',
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
neutral: 'text-ink-4',
};
/**
* Alert — an inline, persistent message about the state of the page.
*
* For transient confirmation of something the user just did, use `toast`
* instead: an Alert that appears and disappears is easy to miss, and a toast
* that persists is noise.
*/
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
const Icon = icon || ICONS[tone];
return (
<div role="alert" className={cn(alertVariants({ tone }), className)} {...props}>
{Icon && <Icon className={cn('w-4.5 h-4.5 shrink-0 mt-0.5', ICON_COLORS[tone])} aria-hidden="true" />}
<div className="flex-1 min-w-0">
{title && <p className="text-body-sm font-semibold text-ink-1">{title}</p>}
{children && (
<div className={cn('text-body-sm text-ink-2 leading-relaxed', title && 'mt-0.5')}>
{children}
</div>
)}
{action && <div className="mt-3">{action}</div>}
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="grid place-items-center w-6 h-6 rounded-md text-ink-4 hover:text-ink-1 hover:bg-black/5 transition-colors shrink-0 focus-ring-inset"
>
<X className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const SIZES = {
xs: 'w-6 h-6 text-[9px]',
sm: 'w-7 h-7 text-[10px]',
default: 'w-9 h-9 text-[12px]',
lg: 'w-11 h-11 text-body-sm',
xl: 'w-16 h-16 text-title',
'2xl': 'w-20 h-20 text-title-lg',
};
const avatarVariants = cva(
'relative inline-grid place-items-center shrink-0 overflow-hidden font-semibold select-none',
{
variants: {
shape: { circle: 'rounded-full', rounded: 'rounded-xl' },
tone: {
brand: 'bg-krow-blue text-white',
navy: 'bg-krow-navy text-white',
soft: 'bg-krow-blue-tint text-krow-blue',
neutral: 'bg-surface-sunken text-ink-3',
},
},
defaultVariants: { shape: 'circle', tone: 'brand' },
}
);
/** First letters of the first two words — "Chef Antoine Dubois" → "CA". */
export function initialsFrom(name = '') {
const words = String(name).trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
return words.slice(0, 2).map((w) => w[0]).join('').toUpperCase();
}
/**
* Avatar — a photo when one exists, initials otherwise.
*
* The app shows people constantly and most have no photo, so initials are a
* first-class state rather than a fallback afterthought. A failed image load
* degrades to initials instead of a broken-image icon.
*/
export const Avatar = React.forwardRef(
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
const [failed, setFailed] = React.useState(false);
const showImage = src && !failed;
// Reset when the subject changes, so a new src gets its own chance to load.
React.useEffect(() => setFailed(false), [src]);
return (
<span ref={ref} className={cn('relative inline-block shrink-0', className)} {...props}>
<span
className={cn(avatarVariants({ shape, tone }), SIZES[size] || SIZES.default)}
aria-hidden={showImage ? 'true' : undefined}
>
{showImage ? (
<img
src={src}
alt={name ? `${name}` : ''}
onError={() => setFailed(true)}
className="w-full h-full object-cover"
/>
) : (
<span aria-label={name || undefined}>{initialsFrom(name)}</span>
)}
</span>
{status && (
<span
className={cn(
'absolute bottom-0 right-0 block rounded-full ring-2 ring-white',
size === 'xs' || size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5',
status === 'online' && 'bg-success',
status === 'busy' && 'bg-warning',
status === 'offline' && 'bg-ink-4'
)}
aria-label={status}
/>
)}
</span>
);
}
);
Avatar.displayName = 'Avatar';
/**
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
* people in a tight row (applicants on a position, endorsers on a skill).
*/
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
const visible = people.slice(0, max);
const overflow = people.length - visible.length;
return (
<div className={cn('flex items-center', className)}>
{visible.map((person, i) => (
<Avatar
key={person.id ?? person.name ?? i}
name={person.name}
src={person.src}
size={size}
className={cn('ring-2 ring-white', i > 0 && '-ml-2')}
/>
))}
{overflow > 0 && (
<span
className={cn(
'grid place-items-center rounded-full bg-surface-sunken text-ink-3 font-semibold ring-2 ring-white -ml-2',
SIZES[size] || SIZES.default
)}
>
+{overflow}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,172 @@
import * as React from 'react';
import { ResponsiveContainer } from 'recharts';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { SectionHeader } from '@/components/ds/SectionHeader';
import { EmptyState } from '@/components/ds/EmptyState';
import { SkeletonChart } from '@/components/ds/Skeleton';
import { BarChart3 } from 'lucide-react';
/**
* The chart palette. Recharts needs literal colours, not Tailwind classes, so
* the brand values are mirrored here — this array is the only place a chart
* should get a colour from.
*/
export const CHART_COLORS = ['#0A39DF', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#082EB4'];
export const CHART_TONES = {
brand: '#0A39DF',
brandDark: '#082EB4',
accent: '#F9E547',
accentPale: '#F8E08E',
navy: '#333F48',
mint: '#D1E0D7',
grid: 'hsl(150 8% 87%)',
axis: 'hsl(220 9% 64%)',
};
/** Shared axis props so every chart's ticks match. */
export const AXIS_PROPS = {
stroke: CHART_TONES.axis,
tick: { fontSize: 11, fill: CHART_TONES.axis },
tickLine: false,
axisLine: false,
};
/**
* A tooltip matching the design system's surfaces. Recharts' default is a plain
* white box that ignores the token set.
*/
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2 shadow-lg">
{label != null && (
<p className="text-caption font-semibold text-ink-1 mb-1">
{labelFormatter ? labelFormatter(label) : label}
</p>
)}
<div className="space-y-0.5">
{payload.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-caption">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: entry.color || entry.fill }}
aria-hidden="true"
/>
<span className="text-ink-3">{entry.name}</span>
<span className="ml-auto font-semibold text-ink-1 tabular-nums">
{valueFormatter ? valueFormatter(entry.value) : entry.value}
</span>
</div>
))}
</div>
</div>
);
}
/**
* A legend that reads as part of the card rather than as chart furniture.
* Items: `{ label, color, value? }`.
*/
export function ChartLegend({ items = [], className }) {
return (
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
{items.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: item.color }}
aria-hidden="true"
/>
<span className="text-caption text-ink-3">{item.label}</span>
{item.value != null && (
<span className="text-caption font-semibold text-ink-1 tabular-nums">{item.value}</span>
)}
</div>
))}
</div>
);
}
/**
* ChartContainer — the card a chart lives in.
*
* Owns the title row, the fixed plot height, the loading skeleton, the empty
* state, and an optional headline figure. Charts inside it are pure Recharts
* children, so swapping a bar chart for a line chart changes nothing else.
*/
export function ChartContainer({
title,
subtitle,
icon,
/** A big number in the top-right — an average, a total. */
headline,
headlineLabel,
/** Right-aligned controls, e.g. a SegmentedToggle for the range. */
actions,
legend,
height = 260,
loading = false,
isEmpty = false,
emptyMessage = 'No data for this period',
children,
/** Skips ResponsiveContainer for children that are not Recharts charts. */
raw = false,
footer,
className,
}) {
if (loading) return <SkeletonChart className={className} height={height} />;
return (
<Surface radius="xl" padding="lg" className={cn('flex flex-col', className)}>
{(title || headline || actions) && (
<div className="flex items-start justify-between gap-4 mb-1">
<div className="min-w-0 flex-1">
{title && (
<SectionHeader
title={title}
subtitle={subtitle}
icon={icon}
className={cn(subtitle ? 'mb-0' : 'mb-0')}
/>
)}
</div>
<div className="flex items-start gap-3 shrink-0">
{actions}
{headline != null && (
<div className="text-right">
<div className="text-title-lg font-heading font-bold text-krow-blue tabular-nums leading-none">
{headline}
</div>
{headlineLabel && (
<div className="text-[10px] uppercase tracking-wide text-ink-4 mt-1">
{headlineLabel}
</div>
)}
</div>
)}
</div>
</div>
)}
{legend && <ChartLegend items={legend} className="mb-4 mt-3" />}
<div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}>
{isEmpty ? (
<EmptyState icon={BarChart3} variant="filtered" title="Nothing to chart" description={emptyMessage} size="sm" />
) : raw ? (
children
) : (
<ResponsiveContainer width="100%" height={height}>
{children}
</ResponsiveContainer>
)}
</div>
{footer && <div className="mt-4 pt-4 border-t border-border">{footer}</div>}
</Surface>
);
}

View File

@@ -0,0 +1,309 @@
import * as React from 'react';
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { EmptyState } from '@/components/ds/EmptyState';
import { SkeletonTable } from '@/components/ds/Skeleton';
import { LoadingOverlay } from '@/components/ds/LoadingState';
import { Pagination } from '@/components/ds/Pagination';
import { Checkbox } from '@/components/ui/checkbox';
/**
* DataTable — the app's one table.
*
* Columns are declared as data:
*
* { key, header, accessor?, sortValue?, cell?, align?, width?, sortable?, hideBelow? }
*
* Sorting, selection and pagination are handled internally by default, but each
* can be lifted by passing the corresponding value + handler — so the same
* component serves a simple static list and a server-paginated view.
*
* `hideBelow: 'md'` drops a column on small screens, which is what keeps the
* table usable on mobile without a horizontal scroll for every view.
*/
export function DataTable({
columns = [],
rows = [],
/** Stable row identity. Falls back to `row.id`, then the index. */
getRowId = (row, i) => row?.id ?? i,
loading = false,
/** Dims existing rows instead of replacing them — for refetches. */
refreshing = false,
/* Sorting — omit `sort` to let the table own it */
sort: controlledSort,
onSortChange,
defaultSort,
/* Selection — pass `selectedIds` to enable */
selectedIds,
onSelectionChange,
/* Pagination — pass `pageSize` to enable */
pageSize: initialPageSize,
page: controlledPage,
onPageChange,
totalItems,
pageSizeOptions,
onRowClick,
emptyState,
/** True when filters are active, so the empty copy can say so. */
isFiltered = false,
onClearFilters,
caption,
className,
rowClassName,
stickyHeader = false,
}) {
/* ── Sorting ─────────────────────────────────────────────────────────── */
const [internalSort, setInternalSort] = React.useState(defaultSort ?? null);
const sort = controlledSort !== undefined ? controlledSort : internalSort;
const setSort = (next) => {
if (onSortChange) onSortChange(next);
if (controlledSort === undefined) setInternalSort(next);
};
const toggleSort = (column) => {
if (sort?.key !== column.key) return setSort({ key: column.key, direction: 'asc' });
if (sort.direction === 'asc') return setSort({ key: column.key, direction: 'desc' });
// Third click clears sorting, so the original order is reachable.
return setSort(null);
};
const valueFor = (column, row) => {
if (column.sortValue) return column.sortValue(row);
if (column.accessor) return column.accessor(row);
return row?.[column.key];
};
const sortedRows = React.useMemo(() => {
// A controlled `sort` means the parent already ordered the rows.
if (!sort || controlledSort !== undefined) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const factor = sort.direction === 'desc' ? -1 : 1;
return [...rows].sort((a, b) => {
const av = valueFor(column, a);
const bv = valueFor(column, b);
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor;
return String(av).localeCompare(String(bv)) * factor;
});
}, [rows, sort, columns, controlledSort]);
/* ── Pagination ──────────────────────────────────────────────────────── */
const paginated = initialPageSize != null;
const [internalPage, setInternalPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(initialPageSize ?? 25);
const page = controlledPage !== undefined ? controlledPage : internalPage;
const setPage = (next) => {
if (onPageChange) onPageChange(next);
if (controlledPage === undefined) setInternalPage(next);
};
// Snap back to page 1 whenever the result set changes underneath us,
// otherwise filtering to 3 rows while on page 4 shows an empty table.
const rowCount = rows.length;
React.useEffect(() => {
if (controlledPage === undefined) setInternalPage(1);
}, [rowCount, sort, controlledPage]);
const serverPaginated = totalItems != null && onPageChange != null;
const pageCount = paginated
? Math.max(1, Math.ceil((totalItems ?? sortedRows.length) / pageSize))
: 1;
const visibleRows = React.useMemo(() => {
if (!paginated || serverPaginated) return sortedRows;
const start = (page - 1) * pageSize;
return sortedRows.slice(start, start + pageSize);
}, [sortedRows, paginated, serverPaginated, page, pageSize]);
/* ── Selection ───────────────────────────────────────────────────────── */
const selectable = selectedIds !== undefined;
const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]);
const pageIds = visibleRows.map((row, i) => getRowId(row, i));
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
const someOnPageSelected = pageIds.some((id) => selected.has(id));
const toggleAll = () => {
if (allOnPageSelected) {
onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id)));
} else {
onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]);
}
};
const toggleRow = (id) => {
onSelectionChange?.(
selected.has(id) ? selectedIds.filter((s) => s !== id) : [...selectedIds, id]
);
};
/* ── Render ──────────────────────────────────────────────────────────── */
const hideBelowClass = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell' };
const alignClass = { right: 'text-right', center: 'text-center' };
if (loading) {
return (
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
<SkeletonTable rows={initialPageSize && initialPageSize < 8 ? initialPageSize : 6} columns={columns.length} />
</Surface>
);
}
if (sortedRows.length === 0) {
return (
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
{emptyState || (
<EmptyState
icon={Inbox}
variant={isFiltered ? 'filtered' : 'empty'}
title={isFiltered ? 'No matching results' : 'Nothing here yet'}
description={
isFiltered
? 'No rows match the current filters. Try widening or clearing them.'
: 'Rows will appear here once there is data.'
}
action={isFiltered && onClearFilters ? { label: 'Clear filters', onClick: onClearFilters } : undefined}
/>
)}
</Surface>
);
}
return (
<div className={cn('space-y-4', className)}>
<Surface padding="none" radius="xl" className="relative overflow-hidden">
<LoadingOverlay show={refreshing} />
<div className="overflow-x-auto">
<table className="w-full border-collapse">
{caption && <caption className="sr-only">{caption}</caption>}
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
<tr className="bg-surface-subtle border-b border-border">
{selectable && (
<th scope="col" className="w-10 px-4 py-3">
<Checkbox
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll}
aria-label="Select all rows on this page"
/>
</th>
)}
{columns.map((column) => {
const active = sort?.key === column.key;
const SortIcon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown;
return (
<th
key={column.key}
scope="col"
style={column.width ? { width: column.width } : undefined}
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined}
className={cn(
'px-4 py-3 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
column.align ? alignClass[column.align] : 'text-left',
column.hideBelow && hideBelowClass[column.hideBelow]
)}
>
{column.sortable ? (
<button
type="button"
onClick={() => toggleSort(column)}
className={cn(
'inline-flex items-center gap-1.5 rounded-sm transition-colors hover:text-ink-2 focus-ring-inset',
active && 'text-krow-blue'
)}
>
{column.header}
<SortIcon className="w-3 h-3" aria-hidden="true" />
</button>
) : (
column.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{visibleRows.map((row, i) => {
const id = getRowId(row, i);
const isSelected = selected.has(id);
return (
<tr
key={id}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={cn(
'border-b border-border last:border-0 transition-colors duration-fast',
onRowClick && 'cursor-pointer',
isSelected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle',
typeof rowClassName === 'function' ? rowClassName(row) : rowClassName
)}
>
{selectable && (
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(id)}
aria-label={`Select row ${i + 1}`}
/>
</td>
)}
{columns.map((column) => (
<td
key={column.key}
className={cn(
'px-4 py-3.5 text-body-sm text-ink-2 align-middle',
column.align ? alignClass[column.align] : 'text-left',
column.hideBelow && hideBelowClass[column.hideBelow],
column.className
)}
>
{column.cell
? column.cell(row)
: column.accessor
? column.accessor(row)
: row?.[column.key] ?? '—'}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</Surface>
{paginated && (
<Pagination
page={page}
pageCount={pageCount}
onPageChange={setPage}
totalItems={totalItems ?? sortedRows.length}
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
pageSizeOptions={pageSizeOptions}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
const WIDTHS = {
sm: 'w-full sm:w-80',
default: 'w-full sm:w-[28rem]',
lg: 'w-full sm:w-[36rem]',
xl: 'w-full sm:w-[44rem]',
};
/**
* Drawer — an edge-anchored panel for detail views, filters and mobile nav.
*
* Same header/body/footer contract as Modal, so the two are interchangeable:
* a detail view can be a Drawer on desktop and a bottom sheet on mobile without
* its contents changing.
*/
export function Drawer({
open,
onOpenChange,
title,
description,
icon: Icon,
side = 'right',
size = 'default',
footer,
children,
className,
/** Hides the header for a fully custom panel (the mobile nav does this). */
bare = false,
}) {
const isVertical = side === 'top' || side === 'bottom';
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side={side}
className={cn(
'p-0 gap-0 flex flex-col',
isVertical ? 'h-auto max-h-[85vh]' : WIDTHS[size],
className
)}
>
{!bare && (
<SheetHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 space-y-0 text-left">
<div className="flex items-start gap-3">
{Icon && (
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
<Icon className="w-4.5 h-4.5" aria-hidden="true" />
</span>
)}
<div className="min-w-0">
<SheetTitle className="text-title font-heading text-ink-1">{title}</SheetTitle>
{description && (
<SheetDescription className="text-body-sm text-ink-3 mt-1">
{description}
</SheetDescription>
)}
</div>
</div>
</SheetHeader>
)}
<div className="flex-1 overflow-y-auto px-5 py-5">{children}</div>
{footer && (
<div className="px-5 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
{footer}
</div>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
/**
* EmptyState — what a region shows when it has nothing to show.
*
* Deliberately distinguishes two situations that need different copy and
* different actions:
*
* - `empty` — nothing exists yet. Offer the action that creates the first one.
* - `filtered` — things exist but none match. Offer to clear the filters.
*
* Pages that conflate them end up telling a user with 22 candidates that they
* have no candidates.
*/
export function EmptyState({
icon: Icon,
title,
description,
/** Primary action — a <Button>, or `{ label, onClick }`. */
action,
secondaryAction,
variant = 'empty',
size = 'default',
className,
}) {
const renderAction = (spec, buttonVariant) => {
if (!spec) return null;
if (React.isValidElement(spec)) return spec;
return (
<Button variant={buttonVariant} size="sm" onClick={spec.onClick}>
{spec.icon && <spec.icon aria-hidden="true" />}
{spec.label}
</Button>
);
};
return (
<div
className={cn(
'flex flex-col items-center justify-center text-center',
size === 'sm' ? 'py-8 px-4' : size === 'lg' ? 'py-20 px-6' : 'py-14 px-6',
className
)}
>
{Icon && (
<span
className={cn(
'grid place-items-center rounded-full mb-4',
variant === 'filtered' ? 'bg-surface-sunken text-ink-4' : 'bg-krow-blue-tint text-krow-blue',
size === 'sm' ? 'w-10 h-10' : 'w-14 h-14'
)}
>
<Icon className={size === 'sm' ? 'w-5 h-5' : 'w-6 h-6'} aria-hidden="true" />
</span>
)}
<h3 className={cn('font-heading text-ink-1', size === 'sm' ? 'text-body' : 'text-title')}>
{title}
</h3>
{description && (
<p className="text-body-sm text-ink-3 mt-1.5 max-w-sm leading-relaxed">{description}</p>
)}
{(action || secondaryAction) && (
<div className="flex items-center gap-2 mt-5">
{renderAction(action, 'default')}
{renderAction(secondaryAction, 'outline')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Field — the label / hint / error scaffolding every form control shares.
*
* Controls stay presentational and Field owns the surrounding text, so label
* spacing, required markers and error styling are identical across the app.
* It wires `id`, `aria-describedby` and `aria-invalid` onto its child.
*/
export function Field({
label,
hint,
error,
required,
htmlFor,
/** Lays the label beside the control instead of above it (toggles, checkboxes). */
inline = false,
className,
children,
}) {
const reactId = React.useId();
const id = htmlFor || reactId;
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
// Only clone when the child is a single element that can accept the wiring.
const control = React.isValidElement(children)
? React.cloneElement(children, {
id: children.props.id || id,
'aria-describedby': children.props['aria-describedby'] || describedBy,
'aria-invalid': error ? true : children.props['aria-invalid'],
})
: children;
if (inline) {
return (
<div className={cn('flex items-start gap-3', className)}>
{control}
<div className="min-w-0">
{label && (
<label htmlFor={id} className="text-body-sm font-medium text-ink-2 cursor-pointer">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
)}
{hint && !error && <p id={hintId} className="text-caption text-ink-3 mt-0.5">{hint}</p>}
{error && <p id={errorId} className="text-caption text-destructive mt-0.5">{error}</p>}
</div>
</div>
);
}
return (
<div className={cn('space-y-1.5', className)}>
{label && (
<label htmlFor={id} className="block text-body-sm font-medium text-ink-2">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
)}
{control}
{hint && !error && <p id={hintId} className="text-caption text-ink-3">{hint}</p>}
{error && <p id={errorId} className="text-caption text-destructive">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,189 @@
import * as React from 'react';
import { SlidersHorizontal, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { SearchInput } from '@/components/ds/SearchInput';
import { MultiSelect } from '@/components/ds/MultiSelect';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
/**
* FilterBar — the search + filters row above a list, table or grid.
*
* Filters are declared as data so every list page gets the same layout,
* the same responsive behaviour, and the same "Clear all" affordance:
*
* { key, label, type: 'select' | 'multi', options, placeholder, width }
*
* On mobile the filters collapse behind a "Filters" toggle with an active count,
* because four side-by-side selects are unusable at 375px.
*/
export function FilterBar({
search,
onSearchChange,
searchPlaceholder = 'Search...',
filters = [],
/** `{ [key]: string | string[] }` */
values = {},
onChange,
/** Right-aligned actions — primary buttons, view switchers. */
actions,
/** Rendered under the bar, e.g. a result count. */
meta,
/** Value that means "no filter" for select-type filters. */
allValue = 'all',
className,
}) {
const [expanded, setExpanded] = React.useState(false);
const isActive = (filter) => {
const value = values[filter.key];
if (filter.type === 'multi') return Array.isArray(value) && value.length > 0;
return value != null && value !== allValue && value !== '';
};
const activeFilters = filters.filter(isActive);
const activeCount = activeFilters.length + (search ? 1 : 0);
const set = (key, value) => onChange?.({ ...values, [key]: value });
const clearAll = () => {
const cleared = {};
filters.forEach((f) => {
cleared[f.key] = f.type === 'multi' ? [] : allValue;
});
onChange?.(cleared);
onSearchChange?.('');
};
const clearOne = (filter) => set(filter.key, filter.type === 'multi' ? [] : allValue);
const labelFor = (filter) => {
const value = values[filter.key];
if (filter.type === 'multi') {
return `${filter.label}: ${value.length}`;
}
const option = (filter.options || [])
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
.find((o) => o.value === value);
return `${filter.label}: ${option?.label ?? value}`;
};
return (
<div className={cn('space-y-3', className)}>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
{onSearchChange && (
<SearchInput
value={search}
onChange={onSearchChange}
placeholder={searchPlaceholder}
containerClassName="flex-1 min-w-0"
/>
)}
{/* Mobile: collapse the filter controls behind a toggle. */}
{filters.length > 0 && (
<Button
variant="outline"
shape="rounded"
onClick={() => setExpanded((v) => !v)}
className="sm:hidden justify-between"
>
<span className="inline-flex items-center gap-2">
<SlidersHorizontal aria-hidden="true" />
Filters
</span>
{activeFilters.length > 0 && (
<Badge variant="default" size="sm">{activeFilters.length}</Badge>
)}
</Button>
)}
<div
className={cn(
'flex flex-col sm:flex-row gap-3 sm:items-center',
expanded ? 'flex' : 'hidden sm:flex'
)}
>
{filters.map((filter) => {
const value = values[filter.key];
if (filter.type === 'multi') {
return (
<MultiSelect
key={filter.key}
options={filter.options}
value={Array.isArray(value) ? value : []}
onChange={(next) => set(filter.key, next)}
placeholder={filter.placeholder || filter.label}
className={cn('sm:w-auto', filter.width || 'sm:min-w-44')}
/>
);
}
const options = (filter.options || []).map((o) =>
typeof o === 'string' ? { value: o, label: o } : o
);
return (
<Select
key={filter.key}
value={value ?? allValue}
onValueChange={(next) => set(filter.key, next)}
>
<SelectTrigger
aria-label={filter.label}
className={cn('sm:w-auto', filter.width || 'sm:min-w-40')}
>
<SelectValue placeholder={filter.placeholder || filter.label} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
})}
</div>
{actions && <div className="flex items-center gap-2 sm:ml-auto shrink-0">{actions}</div>}
</div>
{/* Active filter chips — the escape hatch when a list looks wrong and the
user cannot see why. */}
{(activeCount > 0 || meta) && (
<div className="flex flex-wrap items-center gap-2">
{meta && <span className="text-caption text-ink-3 mr-1">{meta}</span>}
{activeFilters.map((filter) => (
<button
key={filter.key}
type="button"
onClick={() => clearOne(filter)}
className="inline-flex items-center gap-1 rounded-full bg-krow-blue-tint px-2.5 py-1 text-caption font-medium text-krow-blue hover:bg-[#DBEAFE] transition-colors focus-ring"
>
{labelFor(filter)}
<X className="w-3 h-3" aria-hidden="true" />
</button>
))}
{activeCount > 1 && (
<button
type="button"
onClick={clearAll}
className="text-caption font-medium text-ink-3 hover:text-ink-1 hover:underline transition-colors focus-ring rounded-sm"
>
Clear all
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
/**
* IconButton — a square Button carrying only an icon.
*
* It delegates entirely to Button so the two can never diverge, and requires a
* `label`: an icon-only control with no accessible name is a bug, so the API
* makes it impossible to omit. The label doubles as the tooltip.
*/
export const IconButton = React.forwardRef(
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
const sizeMap = {
xs: 'icon-xs',
sm: 'icon-sm',
default: 'icon',
lg: 'icon-lg',
};
const button = (
<Button ref={ref} size={sizeMap[size] || 'icon'} aria-label={label} {...props}>
<Icon aria-hidden="true" />
</Button>
);
if (!tooltip) return button;
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={side}>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
);
IconButton.displayName = 'IconButton';

View File

@@ -0,0 +1,177 @@
import * as React from 'react';
import { motion } from 'framer-motion';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { Skeleton } from '@/components/ds/Skeleton';
const TONES = {
brand: { tile: 'bg-krow-blue text-white', glow: 'shadow-brand', value: 'text-krow-blue' },
navy: { tile: 'bg-krow-navy text-white', glow: 'shadow-md', value: 'text-krow-blue' },
accent: { tile: 'bg-krow-yellow text-krow-blue', glow: 'shadow-[0_0_24px_rgba(249,229,71,0.35)]', value: 'text-krow-blue' },
success: { tile: 'bg-success-muted text-success', glow: 'shadow-xs', value: 'text-ink-1' },
warning: { tile: 'bg-warning-muted text-warning', glow: 'shadow-xs', value: 'text-ink-1' },
neutral: { tile: 'bg-surface-sunken text-ink-3', glow: 'shadow-xs', value: 'text-ink-1' },
};
/**
* KpiCard — one headline number in a dashboard row.
*
* Deliberately minimal: icon tile, value, label. When a number needs context
* (a target, a breakdown, a sparkline) use `MetricCard`; keeping the two
* separate stops KPI rows from drifting into inconsistent heights.
*/
export function KpiCard({
icon: Icon,
value,
label,
tone = 'brand',
/** Stagger position in a row — drives the entrance delay. */
index = 0,
loading = false,
onClick,
className,
}) {
const t = TONES[tone] || TONES.brand;
if (loading) {
return (
<Surface radius="xl" padding="sm" className={className}>
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
<Skeleton className="h-7 w-14 mb-2" />
<Skeleton className="h-2.5 w-20" />
</Surface>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.06, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
>
<Surface
as={onClick ? 'button' : 'div'}
onClick={onClick}
interactive={onClick ? true : undefined}
radius="xl"
padding="sm"
className={cn('w-full text-left hover:shadow-lg', className)}
>
{Icon && (
<div className={cn('grid place-items-center w-10 h-10 rounded-xl mb-3', t.tile, t.glow)}>
<Icon className="w-5 h-5" aria-hidden="true" />
</div>
)}
{/* Keyed so the number re-animates when it changes, which draws the eye
to the figure that actually moved. */}
<motion.div
key={value}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.06 + 0.2, type: 'spring', stiffness: 200 }}
className={cn('text-display font-heading font-bold tabular-nums', t.value)}
>
{value}
</motion.div>
<div className="text-overline text-ink-4 mt-0.5 normal-case tracking-wide">{label}</div>
</Surface>
</motion.div>
);
}
/** Formats a delta as a signed percentage: 12 → "+12%". */
const formatDelta = (delta, suffix = '%') =>
`${delta > 0 ? '+' : ''}${delta}${suffix}`;
/**
* MetricCard — a number with supporting context: a trend, a target, a
* sub-caption, or a small chart.
*
* `invertTrend` exists because "up" is not always good — time-to-hire rising is
* bad, and colouring it green would be actively misleading.
*/
export function MetricCard({
label,
value,
/** Signed number; sign drives the arrow and colour. */
delta,
deltaSuffix = '%',
deltaLabel,
invertTrend = false,
sub,
icon: Icon,
tone = 'neutral',
/** A sparkline or mini chart, rendered under the value. */
chart,
loading = false,
onClick,
className,
}) {
const t = TONES[tone] || TONES.neutral;
if (loading) {
return (
<Surface radius="xl" padding="default" className={className}>
<Skeleton className="h-2.5 w-24 mb-3" />
<Skeleton className="h-8 w-20 mb-2" />
<Skeleton className="h-2.5 w-32" />
</Surface>
);
}
const hasDelta = typeof delta === 'number' && delta !== 0;
const isGood = invertTrend ? delta < 0 : delta > 0;
const TrendIcon = !hasDelta ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
return (
<Surface
as={onClick ? 'button' : 'div'}
onClick={onClick}
interactive={onClick ? true : undefined}
radius="xl"
padding="default"
className={cn('w-full text-left', className)}
>
<div className="flex items-start justify-between gap-3 mb-3">
<p className="text-body-sm text-ink-3 font-medium">{label}</p>
{Icon && (
<span className={cn('grid place-items-center w-8 h-8 rounded-lg shrink-0', t.tile)}>
<Icon className="w-4 h-4" aria-hidden="true" />
</span>
)}
</div>
<div className="flex items-end gap-2.5 flex-wrap">
<span className="text-display font-heading font-bold text-ink-1 tabular-nums leading-none">
{value}
</span>
{(hasDelta || delta === 0) && (
<span
className={cn(
'inline-flex items-center gap-0.5 text-caption font-semibold rounded-full px-1.5 py-0.5 mb-0.5',
!hasDelta
? 'bg-neutral-muted text-ink-3'
: isGood
? 'bg-success-muted text-success'
: 'bg-destructive-muted text-destructive'
)}
>
<TrendIcon className="w-3 h-3" aria-hidden="true" />
{hasDelta ? formatDelta(delta, deltaSuffix) : 'No change'}
</span>
)}
</div>
{(deltaLabel || sub) && (
<p className="text-caption text-ink-4 mt-2">{deltaLabel || sub}</p>
)}
{chart && <div className="mt-4">{chart}</div>}
</Surface>
);
}

View File

@@ -0,0 +1,78 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Layout helpers.
*
* These exist so vertical rhythm and grid behaviour are declared once. The
* reference pages each invented their own spacing (`space-y-5`, `space-y-6`,
* `space-y-8` on sibling pages), which is exactly the drift a design system is
* supposed to prevent.
*/
const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
/**
* Grid — responsive column layouts with the app's standard breakpoints.
*
* `cols` is the count at the largest breakpoint; the intermediate steps are
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
*/
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
const columns = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
}[cols];
return (
<div className={cn('grid', columns, GAPS[gap], className)} {...props}>
{children}
</div>
);
}
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
export function Stack({ space = 'default', className, children, ...props }) {
const spacing = {
xs: 'space-y-2',
sm: 'space-y-3',
default: 'space-y-5',
lg: 'space-y-6',
xl: 'space-y-8',
}[space];
return (
<div className={cn(spacing, className)} {...props}>
{children}
</div>
);
}
/**
* PageSection — a titled region of a page. Pairs a SectionHeader-style label
* with its content at consistent spacing.
*/
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
return (
<section className={cn('space-y-3', className)}>
{title && (
<div className="flex items-center justify-between gap-3">
{level === 'overline' ? (
<h2 className="text-overline uppercase text-ink-4 font-heading">{title}</h2>
) : (
<div>
<h2 className="text-title-lg font-heading text-ink-1">{title}</h2>
{subtitle && <p className="text-body-sm text-ink-3 mt-0.5">{subtitle}</p>}
</div>
)}
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
)}
{children}
</section>
);
}

View File

@@ -0,0 +1,74 @@
import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Spinner — the only spinner in the system.
*/
export function Spinner({ size = 'default', className, label = 'Loading' }) {
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
return (
<Loader2
role="status"
aria-label={label}
className={cn('animate-spin text-ink-4', sizes[size] || sizes.default, className)}
/>
);
}
/**
* LoadingState — a centred spinner with optional copy, for a region whose
* shape is not known ahead of time.
*
* Prefer `Skeleton*` when the shape *is* known: a skeleton that matches the
* final layout prevents the content jump a spinner always causes.
*/
export function LoadingState({ message, size = 'default', className }) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3',
size === 'sm' ? 'py-8' : size === 'lg' ? 'py-24' : 'py-16',
className
)}
>
<Spinner size={size === 'sm' ? 'default' : 'lg'} />
{message && <p className="text-body-sm text-ink-3">{message}</p>}
</div>
);
}
/**
* InlineLoading — a spinner and label on one line, for inside buttons rows,
* card headers, or beside a control that is refreshing.
*/
export function InlineLoading({ message = 'Loading…', className }) {
return (
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
<Spinner size="sm" />
{message}
</span>
);
}
/**
* LoadingOverlay — covers content that is being replaced, keeping layout
* height stable so the page does not jump. Used by DataTable when refetching.
*/
export function LoadingOverlay({ show, message, className }) {
if (!show) return null;
return (
<div
className={cn(
'absolute inset-0 z-10 grid place-items-center rounded-[inherit]',
'bg-white/60 backdrop-blur-[2px] animate-fade-in',
className
)}
>
<div className="flex flex-col items-center gap-2">
<Spinner />
{message && <p className="text-caption text-ink-3">{message}</p>}
</div>
</div>
);
}

120
src/components/ds/Modal.jsx Normal file
View File

@@ -0,0 +1,120 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
const WIDTHS = {
sm: 'sm:max-w-sm',
default: 'sm:max-w-lg',
lg: 'sm:max-w-2xl',
xl: 'sm:max-w-4xl',
};
/**
* Modal — the standard dialog shape: header, scrollable body, footer.
*
* Owning the scroll boundary here is the point. A dialog that lets its whole
* body scroll pushes the title and the confirm button off-screen on a laptop;
* this keeps both pinned and scrolls only the middle.
*/
export function Modal({
open,
onOpenChange,
title,
description,
icon: Icon,
size = 'default',
/** Footer content. Omit for a dialog whose body carries its own actions. */
footer,
children,
className,
/** Blocks closing — use while a submit is in flight. */
busy = false,
}) {
return (
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
<DialogContent
className={cn('p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col', WIDTHS[size], className)}
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
>
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border shrink-0 space-y-0">
<div className="flex items-start gap-3">
{Icon && (
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
<Icon className="w-4.5 h-4.5" aria-hidden="true" />
</span>
)}
<div className="min-w-0 text-left">
<DialogTitle className="text-title font-heading text-ink-1">{title}</DialogTitle>
{description && (
<DialogDescription className="text-body-sm text-ink-3 mt-1">
{description}
</DialogDescription>
)}
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-5">{children}</div>
{footer && (
<DialogFooter className="px-6 py-4 border-t border-border bg-surface-subtle shrink-0">
{footer}
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
/**
* ConfirmModal — a destructive or irreversible confirmation.
*
* Defaults to the destructive tone because that is what confirmation is almost
* always for, and it labels the action with the verb ("Delete") rather than
* "OK", so the button says what will happen.
*/
export function ConfirmModal({
open,
onOpenChange,
title = 'Are you sure?',
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
tone = 'destructive',
icon,
busy = false,
}) {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
icon={icon}
size="sm"
busy={busy}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange?.(false)} disabled={busy}>
{cancelLabel}
</Button>
<Button variant={tone === 'destructive' ? 'destructive' : 'default'} onClick={onConfirm} loading={busy}>
{confirmLabel}
</Button>
</>
}
>
{null}
</Modal>
);
}

View File

@@ -0,0 +1,171 @@
import * as React from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { SearchInput } from '@/components/ds/SearchInput';
/**
* MultiSelect — pick several values from a list.
*
* Selections render as removable chips in the trigger. Above `searchThreshold`
* options a filter field appears, so short lists stay uncluttered and long ones
* stay usable. Options may be strings or `{ value, label, count }`.
*/
export function MultiSelect({
options = [],
value = [],
onChange,
placeholder = 'Select...',
/** Chips shown before collapsing into "+N more". */
maxChips = 2,
searchThreshold = 8,
disabled,
className,
align = 'start',
emptyMessage = 'No options',
}) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const normalized = React.useMemo(
() => options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o)),
[options]
);
const filtered = React.useMemo(() => {
if (!query) return normalized;
const q = query.toLowerCase();
return normalized.filter((o) => o.label.toLowerCase().includes(q));
}, [normalized, query]);
const labelFor = (v) => normalized.find((o) => o.value === v)?.label ?? v;
const toggle = (optionValue) => {
const next = value.includes(optionValue)
? value.filter((v) => v !== optionValue)
: [...value, optionValue];
onChange?.(next);
};
const remove = (optionValue, event) => {
// The chip's X sits inside the trigger — don't open the popover.
event.stopPropagation();
onChange?.(value.filter((v) => v !== optionValue));
};
const visible = value.slice(0, maxChips);
const overflow = value.length - visible.length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
'flex min-h-10 w-full items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-left shadow-xs',
'transition-all duration-base ease-out',
'outline-none focus-visible:border-krow-blue focus-visible:ring-2 focus-visible:ring-krow-blue/20',
'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:opacity-60',
open && 'border-krow-blue ring-2 ring-krow-blue/20',
className
)}
>
<span className="flex flex-1 flex-wrap items-center gap-1 min-w-0">
{value.length === 0 && <span className="text-body-sm text-ink-4">{placeholder}</span>}
{visible.map((v) => (
<Badge key={v} variant="soft" size="lg" className="max-w-[160px] pr-1">
<span className="truncate">{labelFor(v)}</span>
<span
role="button"
tabIndex={-1}
aria-label={`Remove ${labelFor(v)}`}
onClick={(e) => remove(v, e)}
className="grid place-items-center w-3.5 h-3.5 rounded-full hover:bg-krow-blue/20 shrink-0"
>
<X className="w-2.5 h-2.5" aria-hidden="true" />
</span>
</Badge>
))}
{overflow > 0 && (
<Badge variant="neutral" size="lg">+{overflow} more</Badge>
)}
</span>
<ChevronDown
className={cn(
'w-4 h-4 text-ink-4 shrink-0 transition-transform duration-base',
open && 'rotate-180'
)}
aria-hidden="true"
/>
</button>
</PopoverTrigger>
<PopoverContent align={align} className="w-[--radix-popover-trigger-width] min-w-52 p-0">
{normalized.length >= searchThreshold && (
<div className="p-2 border-b border-border">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Filter options"
size="sm"
debounce={0}
autoFocus
/>
</div>
)}
<div className="max-h-64 overflow-y-auto p-1.5">
{filtered.length === 0 && (
<p className="px-2 py-6 text-center text-body-sm text-ink-3">{emptyMessage}</p>
)}
{filtered.map((option) => {
const selected = value.includes(option.value);
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
onClick={() => toggle(option.value)}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-body-sm transition-colors duration-fast',
'hover:bg-krow-blue-tint focus-ring-inset',
selected ? 'text-krow-blue font-medium' : 'text-ink-2'
)}
>
<span
className={cn(
'grid place-items-center w-4 h-4 rounded border shrink-0 transition-colors duration-fast',
selected ? 'bg-krow-blue border-krow-blue text-white' : 'border-border'
)}
>
{selected && <Check className="w-3 h-3" aria-hidden="true" />}
</span>
<span className="flex-1 truncate">{option.label}</span>
{option.count != null && (
<span className="text-caption text-ink-4 tabular-nums">{option.count}</span>
)}
</button>
);
})}
</div>
{value.length > 0 && (
<div className="border-t border-border p-1.5">
<button
type="button"
onClick={() => onChange?.([])}
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
>
Clear selection
</button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,87 @@
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
/**
* PageHeader — the top of every page: optional icon, title, subtitle, and a
* right-aligned action slot. Owning the entrance animation here is what keeps
* page transitions feeling like one product.
*/
export function PageHeader({
title,
subtitle,
icon: Icon,
/** Right-aligned actions — buttons, counts, filters. */
actions,
/** Rendered above the title, e.g. a Breadcrumb. */
breadcrumb,
/** Rendered below the subtitle, e.g. a Tabs row. */
children,
/** `display` for dashboard heroes, `title` for standard pages. */
size = 'title',
animate = true,
className,
}) {
const Wrapper = animate ? motion.div : 'div';
const motionProps = animate
? {
initial: { opacity: 0, y: -12 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
}
: {};
return (
<Wrapper {...motionProps} className={cn('space-y-4', className)}>
{breadcrumb}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<span className="grid place-items-center w-10 h-10 rounded-xl bg-krow-blue text-white shadow-brand shrink-0">
<Icon className="w-5 h-5" aria-hidden="true" />
</span>
)}
<div className="min-w-0">
<h1
className={cn(
'font-heading text-ink-1 truncate',
size === 'display' ? 'text-display' : 'text-title-lg'
)}
>
{title}
</h1>
{subtitle && <p className="text-body-sm text-ink-3 mt-1">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
{children}
</Wrapper>
);
}
/**
* A dashboard-style greeting header where part of the title is emphasized —
* "Good day. Let's hire smarter."
*/
export function HeroHeader({ lead, highlight, subtitle, actions, className }) {
return (
<motion.div
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className={cn('flex items-center justify-between flex-wrap gap-4', className)}
>
<div>
<h1 className="text-display font-heading text-ink-1">
{lead} {highlight && <span className="text-krow-blue">{highlight}</span>}
</h1>
{subtitle && <p className="text-body text-ink-3 mt-1">{subtitle}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</motion.div>
);
}

View File

@@ -0,0 +1,127 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
/**
* Builds a compact page list with ellipses: 1 … 4 5 6 … 20.
*
* Always keeps the first page, the last page, and a window around the current
* one, so the control's width stays stable as the user pages through.
*/
export function buildPageRange(current, total, siblings = 1) {
const totalSlots = siblings * 2 + 5;
if (total <= totalSlots) return Array.from({ length: total }, (_, i) => i + 1);
const left = Math.max(current - siblings, 1);
const right = Math.min(current + siblings, total);
const showLeftEllipsis = left > 2;
const showRightEllipsis = right < total - 1;
const pages = [1];
if (showLeftEllipsis) pages.push('…');
for (let p = Math.max(2, left); p <= Math.min(total - 1, right); p += 1) pages.push(p);
if (showRightEllipsis) pages.push('…');
pages.push(total);
return pages;
}
/**
* Pagination — page navigation with an optional result summary and page-size
* control. Renders nothing when there is only one page.
*/
export function Pagination({
page = 1,
pageCount = 1,
onPageChange,
/** Total rows across all pages — enables the "Showing x–y of z" summary. */
totalItems,
pageSize,
onPageSizeChange,
pageSizeOptions = [10, 25, 50, 100],
className,
}) {
if (pageCount <= 1 && !onPageSizeChange) return null;
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
const to = totalItems != null && pageSize ? Math.min(page * pageSize, totalItems) : null;
const range = buildPageRange(page, pageCount);
return (
<nav
aria-label="Pagination"
className={cn('flex flex-wrap items-center justify-between gap-3', className)}
>
<div className="flex items-center gap-3">
{from != null && (
<p className="text-caption text-ink-3 tabular-nums">
Showing <span className="font-medium text-ink-2">{from}–{to}</span> of{' '}
<span className="font-medium text-ink-2">{totalItems}</span>
</p>
)}
{onPageSizeChange && (
<label className="flex items-center gap-1.5 text-caption text-ink-3">
<span className="hidden sm:inline">Rows</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-7 rounded-md border border-border bg-surface pl-2 pr-6 text-caption text-ink-2 focus-ring-inset cursor-pointer"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
)}
</div>
{pageCount > 1 && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
shape="rounded"
aria-label="Previous page"
disabled={page <= 1}
onClick={() => onPageChange?.(page - 1)}
>
<ChevronLeft aria-hidden="true" />
</Button>
{range.map((entry, i) =>
entry === '…' ? (
<span key={`gap-${i}`} className="px-1.5 text-caption text-ink-4" aria-hidden="true">
…
</span>
) : (
<Button
key={entry}
variant={entry === page ? 'default' : 'ghost'}
size="icon-sm"
shape="rounded"
aria-label={`Page ${entry}`}
aria-current={entry === page ? 'page' : undefined}
onClick={() => onPageChange?.(entry)}
className="tabular-nums"
>
{entry}
</Button>
)
)}
<Button
variant="outline"
size="icon-sm"
shape="rounded"
aria-label="Next page"
disabled={page >= pageCount}
onClick={() => onPageChange?.(page + 1)}
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
)}
</nav>
);
}

View File

@@ -0,0 +1,255 @@
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0));
const TRACK_TONES = {
brand: 'bg-krow-blue',
navy: 'bg-krow-navy',
accent: 'bg-krow-yellow',
success: 'bg-success',
warning: 'bg-warning',
destructive: 'bg-destructive',
};
/**
* Score-band colour, shared by every progress component.
*
* The app scores people constantly, and the band boundaries (80/60/40) are the
* same ones the pipeline tiers use — so a 72 is the same colour on a bar, a ring
* and a leaderboard row.
*/
export function toneForScore(score) {
const s = clamp(score);
if (s >= 80) return 'brand';
if (s >= 60) return 'success';
if (s >= 40) return 'warning';
return 'destructive';
}
/**
* ProgressBar — a horizontal track.
*
* `tone="score"` picks the colour from the value, which is what most usages in
* this app want.
*/
export function ProgressBar({
value,
max = 100,
tone = 'brand',
size = 'default',
label,
showValue = false,
valueSuffix = '',
className,
trackClassName,
}) {
const pct = clamp((Number(value) / max) * 100);
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
const heights = { xs: 'h-1', sm: 'h-1.5', default: 'h-2', lg: 'h-2.5' };
return (
<div className={className}>
{(label || showValue) && (
<div className="flex items-center justify-between gap-2 mb-1.5">
{label && <span className="text-caption font-medium text-ink-2">{label}</span>}
{showValue && (
<span className="text-caption font-semibold text-ink-1 tabular-nums">
{value}
{valueSuffix}
</span>
)}
</div>
)}
<div
role="progressbar"
aria-valuenow={clamp(value)}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label || undefined}
className={cn(
'w-full overflow-hidden rounded-full bg-surface-sunken',
heights[size] || heights.default,
trackClassName
)}
>
<div
className={cn(
'h-full rounded-full transition-all duration-slow ease-out',
TRACK_TONES[resolvedTone]
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
/**
* ProgressRing — a circular gauge.
*
* Supports the app's two scales: 0–100 percentages and the 300–850 career
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
* same component.
*/
export function ProgressRing({
value,
min = 0,
max = 100,
size = 88,
strokeWidth = 8,
tone = 'brand',
/** Big text in the centre. Defaults to the value. */
label,
/** Small text above the label. */
caption,
/** Small text below the label. */
footnote,
className,
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
const resolvedTone = tone === 'score' ? toneForScore(ratio * 100) : tone;
const strokeColor = {
brand: '#0A39DF',
navy: '#333F48',
accent: '#F9E547',
success: 'hsl(142 71% 36%)',
warning: 'hsl(32 95% 44%)',
destructive: 'hsl(0 84% 60%)',
}[resolvedTone];
return (
<div
className={cn('relative inline-grid place-items-center shrink-0', className)}
style={{ width: size, height: size }}
role="img"
aria-label={`${label ?? value} ${caption ?? ''}`.trim()}
>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
className="text-surface-sunken"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${ratio * circumference} ${circumference}`}
className="transition-[stroke-dasharray] duration-slow ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center leading-none">
{caption && (
<span className="text-[8px] uppercase tracking-wide text-ink-4 font-medium">{caption}</span>
)}
<span
className="font-heading font-bold text-ink-1 tabular-nums"
style={{ fontSize: Math.max(12, size * 0.24) }}
>
{label ?? value}
</span>
{footnote && <span className="text-[8px] text-ink-4 font-medium mt-0.5">{footnote}</span>}
</div>
</div>
);
}
/**
* ProgressSteps — a linear multi-step indicator for wizards.
*
* Completed steps show a check rather than their number, so "done" is readable
* at a glance without relying on colour.
*/
export function ProgressSteps({ steps = [], current = 0, className, onStepClick }) {
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
return (
<ol className={cn('flex items-center gap-2', className)}>
{normalized.map((step, i) => {
const done = i < current;
const active = i === current;
const clickable = onStepClick && i <= current;
return (
<li key={step.label ?? i} className="flex items-center gap-2 flex-1 last:flex-none">
<button
type="button"
disabled={!clickable}
onClick={clickable ? () => onStepClick(i) : undefined}
aria-current={active ? 'step' : undefined}
className={cn(
'flex items-center gap-2 min-w-0',
clickable ? 'cursor-pointer' : 'cursor-default'
)}
>
<span
className={cn(
'grid place-items-center w-6 h-6 rounded-full text-[11px] font-bold shrink-0 transition-colors duration-base',
done && 'bg-krow-blue text-white',
active && 'bg-krow-blue-tint text-krow-blue ring-2 ring-krow-blue',
!done && !active && 'bg-surface-sunken text-ink-4'
)}
>
{done ? <Check className="w-3 h-3" aria-hidden="true" /> : i + 1}
</span>
{step.label && (
<span
className={cn(
'text-caption font-medium truncate hidden sm:block',
active ? 'text-ink-1' : done ? 'text-ink-2' : 'text-ink-4'
)}
>
{step.label}
</span>
)}
</button>
{i < normalized.length - 1 && (
<span
aria-hidden="true"
className={cn(
'h-px flex-1 min-w-4 transition-colors duration-base',
done ? 'bg-krow-blue' : 'bg-border'
)}
/>
)}
</li>
);
})}
</ol>
);
}
/**
* ScoreMeter — a labelled bar row used in score breakdowns, where many
* dimensions are listed with a weight and a value.
*/
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2 truncate">{label}</span>
<span className="flex items-baseline gap-1.5 shrink-0">
{weight != null && <span className="text-[10px] text-ink-4">{weight}% weight</span>}
<span className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
</span>
</div>
<ProgressBar value={value} tone={tone} size="sm" />
</div>
);
}

View File

@@ -0,0 +1,101 @@
import * as React from 'react';
import { Search, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { inputVariants } from '@/components/ui/input';
/**
* SearchInput — a text input with a leading search icon and a clear button.
*
* Debounces `onChange` by default so pages can filter on every keystroke
* without re-running expensive work, while the field itself stays instantly
* responsive. Pass `debounce={0}` for immediate updates.
*/
export const SearchInput = React.forwardRef(
(
{
value,
onChange,
placeholder = 'Search...',
debounce = 200,
size = 'default',
shape = 'rounded',
className,
containerClassName,
onClear,
autoFocus,
...props
},
ref
) => {
// Local state keeps typing immediate; the debounced value is what escapes.
const [local, setLocal] = React.useState(value ?? '');
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
// Follow controlled resets (a Clear all in a FilterBar) without fighting typing.
React.useEffect(() => {
setLocal((prev) => (value !== undefined && value !== prev ? value : prev));
}, [value]);
React.useEffect(() => {
if (!debounce) return undefined;
if (local === (value ?? '')) return undefined;
const timer = setTimeout(() => onChangeRef.current?.(local), debounce);
return () => clearTimeout(timer);
}, [local, debounce, value]);
const handleChange = (event) => {
const next = event.target.value;
setLocal(next);
if (!debounce) onChange?.(next);
};
const clear = () => {
setLocal('');
onChange?.('');
onClear?.();
};
const iconInset = size === 'sm' ? 'left-2.5' : 'left-3.5';
const padLeft = size === 'sm' ? 'pl-8' : 'pl-10';
return (
<div className={cn('relative', containerClassName)}>
<Search
className={cn(
'absolute top-1/2 -translate-y-1/2 w-4 h-4 text-ink-4 pointer-events-none',
iconInset
)}
aria-hidden="true"
/>
<input
ref={ref}
type="search"
role="searchbox"
value={local}
onChange={handleChange}
placeholder={placeholder}
autoFocus={autoFocus}
className={cn(
inputVariants({ size, shape }),
padLeft,
'pr-9 [&::-webkit-search-cancel-button]:hidden',
className
)}
{...props}
/>
{local && (
<button
type="button"
onClick={clear}
aria-label="Clear search"
className="absolute right-2.5 top-1/2 -translate-y-1/2 grid place-items-center w-5 h-5 rounded-full text-ink-4 hover:text-ink-2 hover:bg-surface-sunken transition-colors focus-ring-inset"
>
<X className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</div>
);
}
);
SearchInput.displayName = 'SearchInput';

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* SectionHeader — the label row above a section or inside a card. Three levels
* so the hierarchy below a PageHeader stays predictable:
*
* - `overline` — a small all-caps group label ("INTERVIEW COMPLETION")
* - `card` — a card's own title row
* - `section` — a page-level section title
*/
export function SectionHeader({
title,
subtitle,
icon: Icon,
level = 'card',
actions,
/** Renders a "View all ›" affordance on the right. */
onViewAll,
viewAllLabel = 'View all',
className,
}) {
if (level === 'overline') {
return (
<div className={cn('flex items-center justify-between gap-3 mb-3', className)}>
<h2 className="text-overline uppercase text-ink-4 font-heading">{title}</h2>
{actions}
</div>
);
}
return (
<div
className={cn(
'flex items-start justify-between gap-3',
level === 'section' ? 'mb-4' : 'mb-5',
className
)}
>
<div className="flex items-start gap-2.5 min-w-0">
{Icon && (
<span className="grid place-items-center w-8 h-8 rounded-lg bg-krow-blue-tint text-krow-blue shrink-0">
<Icon className="w-4 h-4" aria-hidden="true" />
</span>
)}
<div className="min-w-0">
<h3
className={cn(
'font-heading text-ink-1 truncate',
level === 'section' ? 'text-title-lg' : 'text-title'
)}
>
{title}
</h3>
{subtitle && <p className="text-caption text-ink-3 mt-0.5">{subtitle}</p>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{actions}
{onViewAll && (
<button
type="button"
onClick={onViewAll}
className="inline-flex items-center gap-1 text-caption font-medium text-krow-blue hover:underline focus-ring rounded-sm"
>
{viewAllLabel}
<ChevronRight className="w-3 h-3" aria-hidden="true" />
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Skeleton — a loading placeholder.
*
* Uses a travelling shimmer rather than a pulse: on the app's translucent glass
* surfaces a pulsing block reads as a rendering glitch, while a shimmer reads as
* progress. The shimmer is disabled automatically under
* `prefers-reduced-motion` (see index.css).
*/
export function Skeleton({ className, radius = 'md', ...props }) {
return (
<div
aria-hidden="true"
className={cn(
'relative overflow-hidden bg-surface-sunken',
radius === 'full' ? 'rounded-full' : radius === 'lg' ? 'rounded-xl' : 'rounded-md',
'after:absolute after:inset-0 after:shimmer',
className
)}
{...props}
/>
);
}
/**
* The composed patterns below exist so pages never hand-assemble skeletons.
* Each mirrors the real component's geometry, which is what makes the swap from
* loading to loaded invisible.
*/
/** Paragraph placeholder. The last line is short, as real text is. */
export function SkeletonText({ lines = 3, className }) {
return (
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={cn('h-3', i === lines - 1 && lines > 1 ? 'w-2/3' : 'w-full')}
/>
))}
</div>
);
}
/** Matches KpiCard: icon tile, big number, caption. */
export function SkeletonKpi({ className }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
<Skeleton className="h-7 w-14 mb-2" />
<Skeleton className="h-2.5 w-20" />
</div>
);
}
/** Matches a list row: avatar, two lines of text, trailing value. */
export function SkeletonRow({ className }) {
return (
<div className={cn('flex items-center gap-3 py-3', className)}>
<Skeleton className="w-9 h-9" radius="full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-2.5 w-24" />
</div>
<Skeleton className="h-6 w-10" />
</div>
);
}
/** Matches a card in a grid: title, meta, stat row. */
export function SkeletonCard({ className }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
<div className="flex items-center gap-3 mb-4">
<Skeleton className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-2.5 w-20" />
</div>
</div>
<Skeleton className="h-2.5 w-28 mb-4" />
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-border">
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-5 w-8" />
<Skeleton className="h-2 w-12" />
</div>
))}
</div>
</div>
);
}
/** Matches DataTable: header row plus n body rows. */
export function SkeletonTable({ rows = 5, columns = 4, className }) {
return (
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} className={cn('h-2.5', i === 0 ? 'w-32' : 'flex-1 max-w-24')} />
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex items-center gap-4 px-4 py-3.5 border-b border-border last:border-0">
{Array.from({ length: columns }).map((_, c) => (
<Skeleton key={c} className={cn('h-3', c === 0 ? 'w-32' : 'flex-1 max-w-24')} />
))}
</div>
))}
</div>
);
}
/** Matches ChartContainer: title, legend, plot area. */
export function SkeletonChart({ className, height = 240 }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
<Skeleton className="h-3.5 w-40 mb-2" />
<Skeleton className="h-2.5 w-56 mb-6" />
<div className="flex items-end gap-3" style={{ height }}>
{[0.5, 0.8, 0.35, 0.95, 0.6, 0.75].map((h, i) => (
<Skeleton key={i} className="flex-1" style={{ height: `${h * 100}%` }} />
))}
</div>
</div>
);
}
/** n SkeletonRows inside a surface — the common "loading a list" case. */
export function SkeletonList({ rows = 5, className }) {
return (
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
{Array.from({ length: rows }).map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
/**
* The domain's states, mapped to a tone and a human label in one place.
*
* This map is the reason StatusBadge exists: `ai_screened` appeared with three
* different colours and two different spellings across the reference pages.
* Adding a state here makes it correct everywhere at once.
*/
export const STATUS_MAP = {
/* Application lifecycle */
applied: { label: 'Applied', tone: 'neutral' },
ai_screened: { label: 'AI Screened', tone: 'info' },
shortlisted: { label: 'Shortlisted', tone: 'accent' },
interview: { label: 'Interviewing', tone: 'warning' },
hired: { label: 'Hired', tone: 'success' },
rejected: { label: 'Declined', tone: 'destructive' },
/* Job posting status */
active: { label: 'Hiring', tone: 'default' },
draft: { label: 'Draft', tone: 'neutral' },
paused: { label: 'Paused', tone: 'warning' },
closed: { label: 'Closed', tone: 'destructive' },
/* Staff status */
onboarding: { label: 'Onboarding', tone: 'warning' },
inactive: { label: 'Inactive', tone: 'neutral' },
/* Score bands — the same vocabulary talentHome.js uses */
elite: { label: 'Elite', tone: 'default' },
excellent: { label: 'Excellent', tone: 'success' },
solid: { label: 'Solid', tone: 'info' },
building: { label: 'Building', tone: 'warning' },
new: { label: 'New', tone: 'neutral' },
no_score: { label: 'No score yet', tone: 'neutral' },
/* Interview verdicts */
hire: { label: 'Hire', tone: 'success' },
maybe: { label: 'Maybe', tone: 'warning' },
no: { label: 'No', tone: 'destructive' },
/* Evidence verdicts */
verified: { label: 'Verified', tone: 'success' },
needs_work: { label: 'Needs work', tone: 'warning' },
failed: { label: 'Failed', tone: 'destructive' },
pending: { label: 'Pending', tone: 'neutral' },
expired: { label: 'Expired', tone: 'destructive' },
/* Profile tiers */
Skilled: { label: 'Skilled', tone: 'default' },
'Cross-Trained': { label: 'Cross-Trained', tone: 'info' },
Beginner: { label: 'Beginner', tone: 'neutral' },
};
/** Title-cases an unknown status so it never renders as a raw enum. */
const humanize = (status) =>
String(status).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const DOT_COLOR = {
default: 'bg-krow-blue',
soft: 'bg-krow-blue',
accent: 'bg-krow-blue',
navy: 'bg-krow-navy',
success: 'bg-success',
warning: 'bg-warning',
destructive: 'bg-destructive',
info: 'bg-info',
neutral: 'bg-ink-4',
outline: 'bg-ink-4',
};
/**
* StatusBadge — renders a domain status with its canonical colour and wording.
*
* `dot` adds a leading indicator for dense rows where colour alone is doing too
* much work; it also gives colour-blind users a second cue alongside the label.
*/
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
const entry = STATUS_MAP[status] || {};
const resolvedTone = tone || entry.tone || 'neutral';
const resolvedLabel = label || entry.label || humanize(status);
return (
<Badge variant={resolvedTone} size={size} className={cn('gap-1.5', className)} {...props}>
{dot && (
<span
className={cn('w-1.5 h-1.5 rounded-full shrink-0', DOT_COLOR[resolvedTone])}
aria-hidden="true"
/>
)}
{resolvedLabel}
</Badge>
);
}

View File

@@ -0,0 +1,72 @@
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
/**
* Surface — the canonical container in the design system.
*
* Every card, panel and boxed region in the app is a Surface, so radius,
* border, elevation and hover behaviour can only be defined in one place.
* `glass` is the default because the app sits on a gradient-mesh background
* that solid white cards flatten.
*/
const surfaceVariants = cva(
'transition-all duration-base ease-out',
{
variants: {
variant: {
glass: 'glass-card border border-white/60',
solid: 'bg-surface border border-border',
subtle: 'bg-surface-subtle border border-border',
sunken: 'bg-surface-sunken border border-transparent',
outline: 'bg-transparent border border-border',
/* The one dark surface — the Owliver panel */
panel: 'panel-surface border border-panel-border text-panel-foreground',
},
elevation: {
none: 'shadow-none',
xs: 'shadow-xs',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
},
radius: {
md: 'rounded-lg',
lg: 'rounded-xl',
xl: 'rounded-2xl',
},
padding: {
none: 'p-0',
sm: 'p-4',
default: 'p-5',
lg: 'p-6',
},
interactive: {
true: 'cursor-pointer hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60 focus-visible:ring-offset-2',
},
},
defaultVariants: {
variant: 'glass',
elevation: 'sm',
radius: 'xl',
padding: 'default',
},
}
);
const Surface = React.forwardRef(
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
const Comp = as || 'div';
return (
<Comp
ref={ref}
className={cn(surfaceVariants({ variant, elevation, radius, padding, interactive }), className)}
{...props}
/>
);
}
);
Surface.displayName = 'Surface';
export { Surface, surfaceVariants };

119
src/components/ds/Tabs.jsx Normal file
View File

@@ -0,0 +1,119 @@
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
/**
* Tabs — switches between views of the same page.
*
* Two visual styles:
* - `pill` — matches the app's header navigation, with a sliding indicator
* - `underline` — quieter, for tabs inside a card
*
* The sliding indicator uses a shared `layoutId`, so the highlight animates
* between tabs the same way the main nav pill does. `layoutGroupId` must be
* unique when two tab sets are on screen at once, otherwise the indicator
* animates between them.
*/
export function Tabs({
tabs = [],
value,
onChange,
variant = 'pill',
size = 'default',
layoutGroupId,
className,
ariaLabel = 'Views',
}) {
const normalized = tabs.map((t) => (typeof t === 'string' ? { value: t, label: t } : t));
const fallbackId = React.useId();
const groupId = layoutGroupId || fallbackId;
if (variant === 'underline') {
return (
<div role="tablist" aria-label={ariaLabel} className={cn('flex items-center gap-1 border-b border-border', className)}>
{normalized.map((tab) => {
const active = tab.value === value;
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange?.(tab.value)}
className={cn(
'relative inline-flex items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
size === 'sm' ? 'text-body-sm' : 'text-body',
active ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
)}
>
{tab.icon && <tab.icon className="w-4 h-4" aria-hidden="true" />}
{tab.label}
{tab.count != null && (
<Badge variant={active ? 'soft' : 'neutral'} size="sm">{tab.count}</Badge>
)}
{active && (
<motion.span
layoutId={`tab-underline-${groupId}`}
className="absolute left-0 right-0 -bottom-px h-0.5 rounded-full bg-krow-blue"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
</button>
);
})}
</div>
);
}
return (
<div
role="tablist"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
className
)}
>
{normalized.map((tab) => {
const active = tab.value === value;
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange?.(tab.value)}
className={cn(
'relative inline-flex items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
size === 'sm' ? 'px-3 py-1 text-caption' : 'px-4 py-1.5 text-body-sm',
active ? 'text-white' : 'text-ink-3 hover:text-ink-1'
)}
>
{active && (
<motion.span
layoutId={`tab-pill-${groupId}`}
className="absolute inset-0 rounded-full bg-krow-blue"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
<span className="relative z-10 inline-flex items-center gap-1.5">
{tab.icon && <tab.icon className="w-3.5 h-3.5" aria-hidden="true" />}
{tab.label}
{tab.count != null && (
<span
className={cn(
'rounded-full px-1.5 text-[10px] font-bold tabular-nums',
active ? 'bg-white/25 text-white' : 'bg-surface-sunken text-ink-3'
)}
>
{tab.count}
</span>
)}
</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { StatusBadge } from '@/components/ds/StatusBadge';
const TONE_DOT = {
brand: 'bg-krow-blue ring-krow-blue/20',
success: 'bg-success ring-success/20',
warning: 'bg-warning ring-warning/20',
destructive: 'bg-destructive ring-destructive/20',
neutral: 'bg-ink-4 ring-ink-4/20',
};
/**
* Timeline — a vertical sequence of events: a candidate's journey, an audit
* trail, a career history.
*
* Items:
* { id, title, description, timestamp, icon, tone, status, meta, current }
*
* The connecting rail is drawn per item rather than as one absolute line, so a
* timeline can be nested inside a scrolling card without the rail detaching.
*/
export function Timeline({ items = [], className, compact = false }) {
return (
<ol className={cn('relative', className)}>
{items.map((item, i) => {
const isLast = i === items.length - 1;
const Icon = item.icon;
const tone = item.tone || (item.current ? 'brand' : 'neutral');
return (
<li key={item.id ?? i} className="relative flex gap-3.5 pb-5 last:pb-0">
{/* Rail + node */}
<div className="relative flex flex-col items-center shrink-0">
{Icon ? (
<span
className={cn(
'grid place-items-center rounded-full ring-4 shrink-0 z-10',
compact ? 'w-6 h-6' : 'w-8 h-8',
tone === 'neutral' ? 'bg-surface-sunken text-ink-3 ring-white' : 'text-white ring-white',
tone !== 'neutral' && TONE_DOT[tone]
)}
>
<Icon className={compact ? 'w-3 h-3' : 'w-4 h-4'} aria-hidden="true" />
</span>
) : (
<span
className={cn(
'rounded-full ring-4 ring-white shrink-0 z-10 mt-1',
compact ? 'w-2 h-2' : 'w-2.5 h-2.5',
TONE_DOT[tone],
item.current && 'animate-pulse-ring'
)}
aria-hidden="true"
/>
)}
{!isLast && (
<span
aria-hidden="true"
className="w-px flex-1 bg-border mt-1.5 min-h-4"
/>
)}
</div>
{/* Content */}
<div className={cn('flex-1 min-w-0', compact ? '-mt-0.5' : '-mt-1')}>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0">
<p
className={cn(
'font-medium text-ink-1',
compact ? 'text-body-sm' : 'text-body'
)}
>
{item.title}
</p>
{item.description && (
<p className="text-body-sm text-ink-3 mt-0.5 leading-relaxed">{item.description}</p>
)}
{item.meta && <p className="text-caption text-ink-4 mt-1">{item.meta}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
{item.status && <StatusBadge status={item.status} size="sm" />}
{item.timestamp && (
<time className="text-caption text-ink-4 whitespace-nowrap">{item.timestamp}</time>
)}
</div>
</div>
{item.children}
</div>
</li>
);
})}
</ol>
);
}

View File

@@ -0,0 +1,112 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch';
/**
* Toggle — a labelled switch row for settings.
*
* Wraps the Switch primitive with the label/description layout it is almost
* always used with, and makes the whole row a label so the hit target is the
* full width rather than the 36px control.
*/
export function Toggle({
checked,
onCheckedChange,
label,
description,
disabled,
/** `end` puts the switch on the right (settings rows); `start` on the left. */
align = 'end',
className,
id: providedId,
}) {
const reactId = React.useId();
const id = providedId || reactId;
const descriptionId = description ? `${id}-description` : undefined;
const control = (
<Switch
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
aria-describedby={descriptionId}
/>
);
const text = (
<span className="min-w-0">
<span className="block text-body-sm font-medium text-ink-2">{label}</span>
{description && (
<span id={descriptionId} className="block text-caption text-ink-3 mt-0.5">
{description}
</span>
)}
</span>
);
return (
<label
htmlFor={id}
className={cn(
'flex items-center gap-3 cursor-pointer select-none',
disabled && 'cursor-not-allowed opacity-60',
align === 'end' && 'justify-between',
className
)}
>
{align === 'start' ? (
<>
{control}
{text}
</>
) : (
<>
{text}
{control}
</>
)}
</label>
);
}
/**
* SegmentedToggle — a small pill group for switching between 2–4 mutually
* exclusive views. Used where Tabs would be too heavy (chart ranges, EN/ES).
*/
export function SegmentedToggle({ options = [], value, onChange, size = 'default', className, ariaLabel }) {
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-0.5 rounded-full bg-surface-sunken p-0.5 border border-border',
className
)}
>
{normalized.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange?.(option.value)}
className={cn(
'rounded-full font-medium transition-all duration-base ease-out focus-ring-inset',
size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm',
active
? 'bg-krow-blue text-white shadow-sm'
: 'text-ink-3 hover:text-ink-1'
)}
>
{option.label}
</button>
);
})}
</div>
);
}

125
src/components/ds/index.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* KROW design system — the single import surface for building pages.
*
* import { PageHeader, KpiCard, DataTable, StatusBadge } from '@/components/ds';
*
* Two layers sit underneath:
*
* - `components/ui/*` — Radix-based primitives (the mechanics: focus traps,
* portals, keyboard handling). Restyled to the KROW tokens, not re-wrapped.
* - `components/ds/*` — the app's own composites, built from those primitives.
*
* Primitives that need no composition are re-exported here rather than
* duplicated, so there is exactly one Tooltip, one Popover and one Checkbox in
* the codebase and pages never need to know which layer a component came from.
*/
/* ── Foundation ─────────────────────────────────────────────────────────── */
export { Surface, surfaceVariants } from './Surface';
export { PageHeader, HeroHeader } from './PageHeader';
export { SectionHeader } from './SectionHeader';
/* ── Actions ────────────────────────────────────────────────────────────── */
export { Button, buttonVariants } from '@/components/ui/button';
export { IconButton } from './IconButton';
/* ── Forms & controls ───────────────────────────────────────────────────── */
export { Field } from './Field';
export { Input, inputVariants } from '@/components/ui/input';
export { Textarea } from '@/components/ui/textarea';
export { Label } from '@/components/ui/label';
export { SearchInput } from './SearchInput';
export { MultiSelect } from './MultiSelect';
export { Toggle, SegmentedToggle } from './Toggle';
export { Checkbox } from '@/components/ui/checkbox';
export { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
export { Switch } from '@/components/ui/switch';
export { Slider } from '@/components/ui/slider';
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/* ── Identity & status ──────────────────────────────────────────────────── */
export { Avatar, AvatarGroup, initialsFrom } from './Avatar';
export { Badge, badgeVariants } from '@/components/ui/badge';
export { StatusBadge, STATUS_MAP } from './StatusBadge';
/* ── Navigation ─────────────────────────────────────────────────────────── */
export { Tabs } from './Tabs';
export { Pagination, buildPageRange } from './Pagination';
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
/* ── Overlays ───────────────────────────────────────────────────────────── */
export { Modal, ConfirmModal } from './Modal';
export { Drawer } from './Drawer';
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
/* ── Feedback ───────────────────────────────────────────────────────────── */
export { Alert } from './Alert';
export { toast } from './toast';
export { EmptyState } from './EmptyState';
export { LoadingState, LoadingOverlay, InlineLoading, Spinner } from './LoadingState';
export {
Skeleton,
SkeletonCard,
SkeletonChart,
SkeletonKpi,
SkeletonList,
SkeletonRow,
SkeletonTable,
SkeletonText,
} from './Skeleton';
/* ── Data display ───────────────────────────────────────────────────────── */
export { KpiCard, MetricCard } from './KpiCard';
export { DataTable } from './DataTable';
export { FilterBar } from './FilterBar';
export { Timeline } from './Timeline';
export { ActivityCard } from './ActivityCard';
export {
AXIS_PROPS,
CHART_COLORS,
CHART_TONES,
ChartContainer,
ChartLegend,
ChartTooltip,
} from './ChartContainer';
export {
ProgressBar,
ProgressRing,
ProgressSteps,
ScoreMeter,
toneForScore,
} from './Progress';
/* ── Layout helpers ─────────────────────────────────────────────────────── */
export { Grid, PageSection, Stack } from './Layout';

View File

@@ -0,0 +1,80 @@
import { toast as hotToast } from 'react-hot-toast';
/**
* Toast — transient confirmation of a user action.
*
* The app already depends on react-hot-toast (13 call sites), so this wraps it
* rather than introducing a second toast system. Wrapping buys three things:
* one place to set styling so toasts match the design system, a named API
* (`toast.success` / `toast.error` / `toast.promise`) instead of ad-hoc option
* objects, and a single default duration.
*
* Rule of thumb: toast for "that worked", `Alert` for "here is a condition you
* need to keep seeing", `ConfirmModal` for "are you sure".
*/
const BASE_STYLE = {
borderRadius: '12px',
background: '#FFFFFF',
color: 'hsl(215 25% 27%)',
fontSize: '13px',
fontWeight: 500,
padding: '10px 14px',
boxShadow: '0 12px 28px -6px rgb(16 24 40 / 0.12), 0 4px 10px -4px rgb(16 24 40 / 0.06)',
border: '1px solid hsl(150 8% 87%)',
maxWidth: '420px',
};
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
const withDefaults = (options = {}) => ({
position: 'bottom-center',
...options,
style: { ...BASE_STYLE, ...(options.style || {}) },
});
export const toast = {
success(message, options) {
return hotToast.success(message, {
duration: DURATIONS.success,
iconTheme: { primary: 'hsl(142 71% 36%)', secondary: '#FFFFFF' },
...withDefaults(options),
});
},
error(message, options) {
return hotToast.error(message, {
duration: DURATIONS.error,
iconTheme: { primary: 'hsl(0 84% 60%)', secondary: '#FFFFFF' },
...withDefaults(options),
});
},
info(message, options) {
return hotToast(message, { duration: DURATIONS.info, ...withDefaults(options) });
},
loading(message, options) {
return hotToast.loading(message, { duration: DURATIONS.loading, ...withDefaults(options) });
},
/**
* Ties a toast to an async action: loading while it runs, then success or
* error. Preferred over a manual loading/dismiss pair, which leaks a spinner
* whenever the promise rejects.
*/
promise(promise, messages, options) {
return hotToast.promise(
promise,
{
loading: messages?.loading ?? 'Working…',
success: messages?.success ?? 'Done',
error: messages?.error ?? 'Something went wrong',
},
withDefaults(options)
);
},
dismiss: hotToast.dismiss,
remove: hotToast.remove,
};

View File

@@ -0,0 +1,451 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Mic, MicOff, Volume2, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi';
import { useCreateInterview, useUpdateApplication } from '@/lib/krowHooks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils';
const TOTAL_SECONDS = 180;
const QUESTION_COUNT = 5;
export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }) {
const [messages, setMessages] = useState([]);
const [phase, setPhase] = useState('intro'); // intro | active | evaluating | done
const [isListening, setIsListening] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [transcript, setTranscript] = useState('');
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS);
const [evaluation, setEvaluation] = useState(null);
const [error, setError] = useState('');
const [ttsEnabled, setTtsEnabled] = useState(true);
const [language, setLanguage] = useState('en'); // 'en' | 'es'
const recognitionRef = useRef(null);
const lastUserMessageTime = useRef(null);
const timerRef = useRef(null);
const scrollRef = useRef(null);
const createInterview = useCreateInterview();
const updateApplication = useUpdateApplication();
const jobTitle = job?.title || 'the role';
const candidateName = application?.applicant_name || 'Candidate';
const isOwliver = conductor === 'Owliver';
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const speak = useCallback((text) => {
return new Promise((resolve) => {
if (!ttsEnabled || !window.speechSynthesis) {
resolve();
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
const targetLang = language === 'es' ? 'es' : 'en';
utterance.lang = language === 'es' ? 'es-ES' : 'en-US';
// Pick the best available voice for the target language
const voices = window.speechSynthesis.getVoices();
const langVoices = voices.filter(v => v.lang?.toLowerCase().startsWith(targetLang));
if (langVoices.length > 0) {
// Prefer native/local voices, then Google voices, then any
const preferred =
langVoices.find(v => v.localService && v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) ||
langVoices.find(v => v.localService) ||
langVoices.find(v => v.name.toLowerCase().includes('google')) ||
langVoices.find(v => v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) ||
langVoices[0];
utterance.voice = preferred;
}
utterance.rate = language === 'es' ? 0.98 : 1.05;
utterance.pitch = 1.0;
utterance.onstart = () => setIsSpeaking(true);
utterance.onend = () => { setIsSpeaking(false); resolve(); };
utterance.onerror = () => { setIsSpeaking(false); resolve(); };
window.speechSynthesis.speak(utterance);
});
}, [ttsEnabled, language]);
const stopListening = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
recognitionRef.current = null;
}
setIsListening(false);
}, []);
const askNextQuestion = useCallback(async (currentMessages) => {
const qNumber = currentMessages.filter(m => m.role === 'assistant').length + 1;
if (qNumber > QUESTION_COUNT) {
await finishInterview(currentMessages);
return;
}
try {
const question = await generateInterviewQuestion(currentMessages, jobTitle, qNumber, language);
const aiMsg = { role: 'assistant', content: question, timestamp: new Date().toISOString() };
const newMessages = [...currentMessages, aiMsg];
setMessages(newMessages);
lastUserMessageTime.current = Date.now();
await speak(question);
} catch (e) {
setError('Interview connection issue. Please retry.');
}
}, [jobTitle, speak, language]);
const startListening = useCallback(() => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
setError('Voice input not supported in this browser. Use text mode below.');
return;
}
// Preserve existing transcript so the user can continue speaking
const baseTranscript = transcript;
if (!baseTranscript) setTranscript('');
setError('');
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = true;
recognition.lang = language === 'es' ? 'es-ES' : 'en-US';
recognition.onresult = (event) => {
let text = '';
for (let i = 0; i < event.results.length; i++) {
text += event.results[i][0].transcript;
}
setTranscript((baseTranscript ? baseTranscript + ' ' : '') + text.trim());
};
recognition.onerror = (event) => {
if (event.error !== 'no-speech') setError(`Mic error: ${event.error}`);
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognitionRef.current = recognition;
recognition.start();
setIsListening(true);
}, [language, transcript]);
const submitAnswer = useCallback(async () => {
if (!transcript.trim()) return;
stopListening();
const responseTime = lastUserMessageTime.current ? (Date.now() - lastUserMessageTime.current) / 1000 : 0;
const userMsg = {
role: 'user',
content: transcript.trim(),
timestamp: new Date().toISOString(),
response_time_seconds: Math.round(responseTime * 10) / 10,
};
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setTranscript('');
// Ask next question
setTimeout(() => askNextQuestion(newMessages), 300);
}, [transcript, messages, stopListening, askNextQuestion]);
const finishInterview = useCallback(async (finalMessages) => {
setPhase('evaluating');
try {
const result = await evaluateInterview(finalMessages, job, candidateName, language);
const interviewRecord = await createInterview.mutateAsync({
application_id: application.id,
job_posting_id: application.job_posting_id,
job_title: jobTitle,
candidate_name: candidateName,
messages: finalMessages,
overall_interview_score: result.overall_interview_score,
verdict: result.verdict,
hire_recommendation: result.hire_recommendation,
integrity_score: result.integrity_score,
ai_flags: result.ai_flags,
category_scores: result.category_scores,
strengths: result.strengths,
concerns: result.concerns,
best_fit_roles: result.best_fit_roles,
summary: result.summary,
reasoning: result.reasoning,
});
await updateApplication.mutateAsync({ id: application.id, data: {
status: 'interview',
interview_id: interviewRecord.id,
ai_score: result.overall_interview_score,
} });
setEvaluation(result);
setPhase('done');
} catch (e) {
setError('Evaluation failed. Please try again.');
setPhase('active');
}
}, [application, job, candidateName, createInterview, updateApplication, language]);
const startInterview = useCallback(() => {
setPhase('active');
setTimeLeft(TOTAL_SECONDS);
setMessages([]);
askNextQuestion([]);
}, [askNextQuestion]);
// Timer
useEffect(() => {
if (phase !== 'active') return;
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(timerRef.current);
stopListening();
if (messages.length > 0) finishInterview(messages);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timerRef.current);
}, [phase, messages, finishInterview, stopListening]);
// Cleanup
useEffect(() => {
return () => {
stopListening();
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (timerRef.current) clearInterval(timerRef.current);
};
}, [stopListening]);
const handleClose = () => {
stopListening();
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (timerRef.current) clearInterval(timerRef.current);
setPhase('intro');
setMessages([]);
setEvaluation(null);
setError('');
setTimeLeft(TOTAL_SECONDS);
setLanguage('en');
onClose();
};
const mins = Math.floor(timeLeft / 60);
const secs = timeLeft % 60;
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
{isOwliver ? (
<OwliverAvatar className="w-8 h-8" />
) : (
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold">K</div>
)}
AI Interview with {conductor}
</span>
{phase === 'active' && (
<span className={cn('text-[13px] font-mono px-2 py-1 rounded-lg', timeLeft < 30 ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151]')}>
{mins}:{secs.toString().padStart(2, '0')}
</span>
)}
</DialogTitle>
</DialogHeader>
{phase === 'intro' && (
<div className="py-6 text-center">
{isOwliver ? (
<OwliverAvatar className="w-16 h-16 mx-auto mb-4" />
) : (
<div className="w-16 h-16 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
)}
<h3 className="text-lg font-semibold text-[#111827] mb-2">Hi {candidateName.split(' ')[0]}, I'm {conductor}</h3>
<p className="text-[14px] text-[#6B7280] max-w-md mx-auto mb-6">
{language === 'es'
? `Te haré ${QUESTION_COUNT} preguntas rápidas sobre tu experiencia en roles de ${jobTitle}. Toma unos 3 minutos. Solo habla naturalmente — no hay respuestas correctas o incorrectas.`
: `I'll ask you ${QUESTION_COUNT} quick questions about your experience with ${jobTitle} roles. This takes about 3 minutes. Just speak naturally — there are no right or wrong answers.`}
</p>
<div className="flex items-center justify-center gap-3 mb-4 text-[12px] text-[#6B7280]">
<span className="inline-flex items-center gap-1"><Volume2 className="w-3.5 h-3.5" /> Voice enabled</span>
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0A39DF] underline">
{ttsEnabled ? 'Mute' : 'Unmute'}
</button>
</div>
{/* Language selector */}
<div className="flex items-center justify-center gap-2 mb-6">
<span className="text-[12px] text-[#6B7280] font-medium">Interview language:</span>
<div className="inline-flex rounded-lg border border-[#E5E7EB] overflow-hidden">
<button
onClick={() => setLanguage('en')}
className={cn(
'px-3 py-1.5 text-[12px] font-medium transition-colors',
language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)}
>
English
</button>
<button
onClick={() => setLanguage('es')}
className={cn(
'px-3 py-1.5 text-[12px] font-medium transition-colors',
language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)}
>
Español
</button>
</div>
</div>
<Button onClick={startInterview} className="bg-[#0A39DF] hover:bg-[#082EB4] px-8">
<Mic className="w-4 h-4 mr-2" /> Start Interview
</Button>
</div>
)}
{phase === 'active' && (
<div className="space-y-4">
<div ref={scrollRef} className="h-[300px] overflow-y-auto space-y-3 p-2">
{messages.map((msg, i) => (
<div key={i} className={cn('flex', msg.role === 'user' ? 'justify-end' : 'justify-start')}>
<div className={cn(
'max-w-[80%] rounded-2xl px-4 py-2.5 text-[14px]',
msg.role === 'user'
? 'bg-[#0A39DF] text-white rounded-br-sm'
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
)}>
<p>{msg.content}</p>
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> Fast response flagged
</p>
)}
</div>
</div>
))}
{isSpeaking && (
<div className="flex justify-start">
<div className="bg-[#F3F4F6] rounded-2xl rounded-bl-sm px-4 py-3 flex gap-1">
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
)}
</div>
{error && <p className="text-[12px] text-[#EF4444]">{error}</p>}
{/* Answer input */}
<div className="border-t border-[#E5E7EB] pt-3">
<textarea
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder={isListening ? 'Listening...' : 'Type your answer or click mic to speak'}
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitAnswer();
}
}}
/>
<div className="flex items-center justify-between mt-2">
<button
onClick={isListening ? stopListening : startListening}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-[13px] font-medium transition-colors',
isListening ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151] hover:bg-[#E5E7EB]'
)}
>
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
{isListening ? 'Stop' : 'Speak'}
</button>
<Button
onClick={submitAnswer}
disabled={!transcript.trim()}
className="bg-[#0A39DF] hover:bg-[#082EB4]"
>
Send Answer
</Button>
</div>
</div>
</div>
)}
{phase === 'evaluating' && (
<div className="py-12 text-center">
<Loader2 className="w-10 h-10 text-[#0A39DF] animate-spin mx-auto mb-4" />
<p className="text-[14px] text-[#6B7280]">{conductor} is evaluating your interview...</p>
</div>
)}
{phase === 'done' && evaluation && (
<div className="py-4 space-y-4">
<div className="text-center">
<div className={cn(
'w-16 h-16 rounded-full mx-auto flex items-center justify-center text-white text-xl font-bold mb-3',
evaluation.verdict === 'hire' ? 'bg-[#059669]' : evaluation.verdict === 'maybe' ? 'bg-[#F59E0B]' : 'bg-[#EF4444]'
)}>
{evaluation.overall_interview_score}
</div>
<h3 className="text-lg font-semibold text-[#111827] capitalize">Verdict: {evaluation.verdict}</h3>
<p className="text-[13px] text-[#6B7280] mt-1">{evaluation.hire_recommendation}</p>
</div>
<div className="grid grid-cols-2 gap-3">
{evaluation.category_scores && Object.entries(evaluation.category_scores).map(([key, val]) => (
<div key={key} className="bg-[#F9FAFB] rounded-lg p-3">
<div className="text-[11px] text-[#6B7280] capitalize">{key.replace(/_/g, ' ')}</div>
<div className="text-lg font-bold text-[#111827]">{val}/100</div>
</div>
))}
</div>
{evaluation.integrity_score < 100 && (
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
<div>
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {evaluation.integrity_score}/100</p>
{evaluation.ai_flags?.length > 0 && (
<p className="text-[11px] text-[#92400E] mt-1">{evaluation.ai_flags.join(', ')}</p>
)}
</div>
</div>
)}
<div>
<p className="text-[12px] font-medium text-[#374151] mb-1">Summary</p>
<p className="text-[13px] text-[#6B7280]">{evaluation.summary}</p>
</div>
{evaluation.strengths?.length > 0 && (
<div>
<p className="text-[12px] font-medium text-[#059669] mb-1 flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Strengths</p>
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
{evaluation.strengths.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
{evaluation.concerns?.length > 0 && (
<div>
<p className="text-[12px] font-medium text-[#EF4444] mb-1 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" /> Concerns</p>
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
{evaluation.concerns.map((c, i) => <li key={i}>{c}</li>)}
</ul>
</div>
)}
<Button onClick={handleClose} className="w-full bg-[#0A39DF] hover:bg-[#082EB4]">Done</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,107 @@
import React, { useMemo } from 'react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
BarChart,
Bar,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
const PIE_COLORS = ['#0A39DF', '#059669', '#333F48', '#D97706', '#DC2626', '#0891B2', '#DB2777'];
function last7Days() {
const days = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
days.push(d.toISOString().split('T')[0]);
}
return days;
}
export default function ActivityCharts({ activities }) {
const timeSeries = useMemo(() => {
const days = last7Days();
return days.map((day) => {
const dayActivities = activities.filter((a) => (a.created_date || '').slice(0, 10) === day);
return {
date: day.slice(5),
signups: dayActivities.filter((a) => a.event_type === 'signup').length,
logins: dayActivities.filter((a) => a.event_type === 'login').length,
actions: dayActivities.length,
};
});
}, [activities]);
const byType = useMemo(() => {
const counts = {};
activities.forEach((a) => {
counts[a.event_type] = (counts[a.event_type] || 0) + 1;
});
return Object.entries(counts).map(([name, value]) => ({ name, value }));
}, [activities]);
const byRole = useMemo(() => {
const counts = {};
activities.forEach((a) => {
counts[a.account_type] = (counts[a.account_type] || 0) + 1;
});
return Object.entries(counts).map(([name, value]) => ({ name, value }));
}, [activities]);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Activity (Last 7 Days)</h3>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={timeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="#9CA3AF" />
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="signups" stroke="#0A39DF" strokeWidth={2} />
<Line type="monotone" dataKey="logins" stroke="#059669" strokeWidth={2} />
<Line type="monotone" dataKey="actions" stroke="#D97706" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Actions by Type</h3>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={byType}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} stroke="#9CA3AF" interval={0} angle={-20} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<Tooltip />
<Bar dataKey="value" fill="#0A39DF" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Users by Role</h3>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={byRole} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
{byRole.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { Search } from 'lucide-react';
const EVENT_LABELS = {
signup: 'Sign Up',
login: 'Login',
logout: 'Logout',
create_position: 'Create Position',
screen_candidate: 'Screen Candidate',
hire_candidate: 'Hire Candidate',
apply_job: 'Apply to Job',
start_interview: 'Start Interview',
};
const EVENT_COLORS = {
signup: 'bg-[#EFF6FF] text-[#1E40AF]',
login: 'bg-[#ECFDF5] text-[#065F46]',
logout: 'bg-[#F3F4F6] text-[#374151]',
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
apply_job: 'bg-[#EFF6FF] text-[#1E40AF]',
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
};
function formatTime(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export default function ActivityLogTable({ activities }) {
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const filtered = activities.filter((a) => {
if (filter !== 'all' && a.event_type !== filter) return false;
if (search) {
const q = search.toLowerCase();
return (
(a.user_email || '').toLowerCase().includes(q) ||
(a.user_name || '').toLowerCase().includes(q)
);
}
return true;
});
const eventTypes = ['all', ...Object.keys(EVENT_LABELS)];
return (
<div className="bg-white rounded-xl border border-[#E5E7EB] overflow-hidden">
<div className="p-4 border-b border-[#E5E7EB] flex flex-col sm:flex-row gap-3 sm:items-center justify-between">
<h3 className="text-[14px] font-semibold text-[#111827]">Activity Log</h3>
<div className="flex gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#9CA3AF]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search user..."
className="pl-8 pr-3 h-8 text-[12px] rounded-md border border-[#E5E7EB] focus:border-[#2563EB] focus:outline-none w-40"
/>
</div>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-8 text-[12px] rounded-md border border-[#E5E7EB] bg-white px-2 focus:border-[#2563EB] focus:outline-none"
>
{eventTypes.map((t) => (
<option key={t} value={t}>
{t === 'all' ? 'All Events' : EVENT_LABELS[t]}
</option>
))}
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead className="bg-[#F9FAFB] text-[#6B7280]">
<tr>
<th className="text-left font-medium px-4 py-2.5">Event</th>
<th className="text-left font-medium px-4 py-2.5">User</th>
<th className="text-left font-medium px-4 py-2.5">Role</th>
<th className="text-left font-medium px-4 py-2.5">Details</th>
<th className="text-left font-medium px-4 py-2.5">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-[#F3F4F6]">
{filtered.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-[#9CA3AF]">
No activity found
</td>
</tr>
) : (
filtered.slice(0, 50).map((a) => (
<tr key={a.id} className="hover:bg-[#F9FAFB]">
<td className="px-4 py-2.5">
<span
className={`inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ${
EVENT_COLORS[a.event_type] || 'bg-[#F3F4F6] text-[#374151]'
}`}
>
{EVENT_LABELS[a.event_type] || a.event_type}
</span>
</td>
<td className="px-4 py-2.5">
<div className="font-medium text-[#111827]">{a.user_name || '—'}</div>
<div className="text-[11px] text-[#9CA3AF]">{a.user_email}</div>
</td>
<td className="px-4 py-2.5 capitalize text-[#6B7280]">{a.account_type || '—'}</td>
<td className="px-4 py-2.5 text-[#6B7280] max-w-[200px] truncate">
{a.details || '—'}
</td>
<td className="px-4 py-2.5 text-[#6B7280] whitespace-nowrap">
{formatTime(a.created_date)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Users, LogIn, Activity, UserCheck } from 'lucide-react';
export default function ActivityMetrics({ activities }) {
const now = Date.now();
const dayAgo = now - 24 * 60 * 60 * 1000;
const signups = activities.filter((a) => a.event_type === 'signup');
const logins = activities.filter((a) => a.event_type === 'login');
const logins24h = logins.filter((a) => new Date(a.created_date).getTime() > dayAgo);
const recentUsers = [...signups, ...logins].filter(
(a) => new Date(a.created_date).getTime() > dayAgo
);
const activeUsers24h = new Set(recentUsers.map((a) => a.user_email)).size;
const metrics = [
{ label: 'Total Sign-ups', value: signups.length, icon: UserCheck, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Logins (24h)', value: logins24h.length, icon: LogIn, color: '#059669', bg: '#ECFDF5' },
{ label: 'Active Users (24h)', value: activeUsers24h, icon: Users, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Total Actions', value: activities.length, icon: Activity, color: '#D97706', bg: '#FFFBEB' },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{metrics.map((m) => {
const Icon = m.icon;
return (
<div key={m.label} className="bg-white rounded-xl border border-[#E5E7EB] p-5">
<div className="flex items-center justify-between mb-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center" style={{ background: m.bg }}>
<Icon className="w-4 h-4" style={{ color: m.color }} />
</div>
</div>
<div className="text-2xl font-bold text-[#111827]">{m.value}</div>
<div className="text-[12px] text-[#6B7280] mt-0.5">{m.label}</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
const SNAPSHOTS = [
{ key: 'krow', label: 'KROW Score', get: (p) => Math.round(p.krow_score || 0) },
{ key: 'badges', label: 'Other Badges', get: (p) => (p.earned_badges || []).length },
{ key: 'skills', label: 'Skills', get: (p) => (p.skills || []).length },
{ key: 'shares', label: 'Shares', get: () => 0 },
];
export default function ActivitySnapshot({ profile }) {
return (
<div className="rounded-2xl bg-[#E8EEFE] p-6 border border-[#C7D4F7]">
<h3 className="font-serif text-center text-[18px] font-semibold mb-5" style={{ color: '#333F48' }}>
Activity snapshot
</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
{SNAPSHOTS.map((s) => (
<div key={s.key} className="bg-white rounded-2xl shadow-sm p-4 flex flex-col items-center justify-center aspect-square">
<span className="text-[34px] font-bold leading-none" style={{ color: '#0A39DF' }}>
{s.get(profile)}
</span>
<span className="text-[12px] text-[#333F48] mt-2 text-center">{s.label}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Users, TrendingUp, Brain, Mic, UserCheck, Target } from 'lucide-react';
export default function AnalyticsMetrics({ applications, interviews, staff }) {
const totalApplicants = applications.length;
const screened = applications.filter(a => a.status !== 'applied');
const scored = applications.filter(a => a.ai_score > 0);
const avgScore = scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
const interviewed = applications.filter(a => a.status === 'interview' || a.interview_id);
const hired = applications.filter(a => a.status === 'hired');
const hireRate = totalApplicants ? Math.round((hired.length / totalApplicants) * 100) : 0;
const metrics = [
{ label: 'Total Applicants', value: totalApplicants, icon: Users, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Avg AI Score', value: avgScore, icon: Brain, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Interviews Done', value: interviewed.length, icon: Mic, color: '#0A39DF', bg: '#FEFCE8' },
{ label: 'Hired', value: hired.length, icon: UserCheck, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Hire Rate', value: `${hireRate}%`, icon: Target, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'On Staff', value: staff.length, icon: TrendingUp, color: '#333F48', bg: '#D1E0D7' },
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{metrics.map((m) => {
const Icon = m.icon;
return (
<div key={m.label} className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm">
<div className="w-9 h-9 rounded-xl flex items-center justify-center mb-3" style={{ background: m.bg }}>
<Icon className="w-4 h-4" style={{ color: m.color }} />
</div>
<div className="text-2xl font-heading font-bold text-[#111827]">{m.value}</div>
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide mt-0.5">{m.label}</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,296 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Loader2, UploadCloud, FileCheck2, X, Camera, RefreshCw } from 'lucide-react';
import { useCertifications } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
const DEFAULT_CERTS = ['Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared'];
const AVAIL_OPTIONS = ['Weekdays', 'Weekends', 'Evenings', 'Mornings', 'Overnight', 'On-Call'];
const ENGLISH_LEVELS = ['basic', 'conversational', 'fluent', 'native'];
export default function ApplicantForm({ onSubmit, submitting }) {
const [form, setForm] = useState({
applicant_name: '',
email: '',
phone: '',
years_experience: 0,
english_level: 'basic',
certifications: [],
availability: [],
skills: [],
professional_summary: '',
cover_letter: '',
certification_files: [],
selfie_url: '',
companies_worked: [],
});
const [customSkill, setCustomSkill] = useState('');
const [customCompany, setCustomCompany] = useState('');
const [uploadingSelfie, setUploadingSelfie] = useState(false);
const [uploadingCert, setUploadingCert] = useState(false);
const [uploadError, setUploadError] = useState('');
const { data: certifications = [] } = useCertifications();
const certOptions = certifications.length > 0 ? certifications.map(c => c.name) : DEFAULT_CERTS;
const update = (field, value) => setForm({ ...form, [field]: value });
const toggleCert = (cert) => {
update('certifications', form.certifications.includes(cert)
? form.certifications.filter(c => c !== cert)
: [...form.certifications, cert]);
};
const toggleAvail = (slot) => {
update('availability', form.availability.includes(slot)
? form.availability.filter(a => a !== slot)
: [...form.availability, slot]);
};
const handleCertUpload = async (e) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
setUploadingCert(true);
try {
const uploaded = [];
for (const file of files) {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
uploaded.push(file_url);
}
update('certification_files', [...form.certification_files, ...uploaded]);
} catch (err) {
// ignore
}
setUploadingCert(false);
e.target.value = '';
};
const removeCertFile = (idx) => {
update('certification_files', form.certification_files.filter((_, i) => i !== idx));
};
const handleSelfieUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingSelfie(true);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
update('selfie_url', file_url);
} catch (err) {
// ignore
}
setUploadingSelfie(false);
e.target.value = '';
};
const handleSubmit = (e) => {
e.preventDefault();
if (form.certification_files.length === 0) {
setUploadError('Please upload at least one certification document to continue.');
return;
}
if (!form.selfie_url) {
setUploadError("Please take a selfie photo before submitting — it is required for your interview.");
return;
}
setUploadError('');
onSubmit(form);
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* The Resume Dies — identity-first. Create yours at /identity */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-[13px] text-[#374151]">Full Name *</Label>
<Input value={form.applicant_name} onChange={(e) => update('applicant_name', e.target.value)} required className="mt-1.5" />
</div>
<div>
<Label className="text-[13px] text-[#374151]">Email *</Label>
<Input type="email" value={form.email} onChange={(e) => update('email', e.target.value)} required className="mt-1.5" />
</div>
<div>
<Label className="text-[13px] text-[#374151]">Phone</Label>
<Input value={form.phone} onChange={(e) => update('phone', e.target.value)} className="mt-1.5" />
</div>
<div>
<Label className="text-[13px] text-[#374151]">Years of Experience</Label>
<Input type="number" min="0" value={form.years_experience} onChange={(e) => update('years_experience', parseInt(e.target.value) || 0)} className="mt-1.5" />
</div>
<div>
<Label className="text-[13px] text-[#374151]">English Level</Label>
<select value={form.english_level} onChange={(e) => update('english_level', e.target.value)} className="w-full mt-1.5 h-9 rounded-md border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
{ENGLISH_LEVELS.map(l => <option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>)}
</select>
</div>
</div>
<div>
<Label className="text-[13px] text-[#374151]">Skills</Label>
<div className="flex gap-2 mt-1.5">
<Input value={customSkill} onChange={(e) => setCustomSkill(e.target.value)} placeholder="Add a skill..." className="flex-1" />
<Button type="button" variant="outline" onClick={() => { if (customSkill.trim()) { update('skills', [...form.skills, customSkill.trim()]); setCustomSkill(''); } }}>Add</Button>
</div>
{form.skills.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{form.skills.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#F3F4F6] text-[12px] text-[#374151]">
{s}
<button type="button" onClick={() => update('skills', form.skills.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
</span>
))}
</div>
)}
</div>
<div>
<Label className="text-[13px] text-[#374151]">Companies You've Worked At</Label>
<div className="flex gap-2 mt-1.5">
<Input value={customCompany} onChange={(e) => setCustomCompany(e.target.value)} placeholder="e.g. Google, Meta, Marriott..." className="flex-1" />
<Button type="button" variant="outline" onClick={() => { if (customCompany.trim()) { update('companies_worked', [...form.companies_worked, customCompany.trim()]); setCustomCompany(''); } }}>Add</Button>
</div>
{form.companies_worked.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{form.companies_worked.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#EFF6FF] text-[12px] text-[#1E40AF] font-medium border border-[#DBEAFE]">
{c}
<button type="button" onClick={() => update('companies_worked', form.companies_worked.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
</span>
))}
</div>
)}
</div>
<div>
<Label className="text-[13px] text-[#374151]">Certifications</Label>
<div className="flex flex-wrap gap-2 mt-1.5">
{certOptions.map(cert => (
<button
key={cert}
type="button"
onClick={() => toggleCert(cert)}
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
form.certifications.includes(cert) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
}`}
>
{cert}
</button>
))}
</div>
{/* Certification file upload */}
<div className="mt-3">
<Label className="text-[12px] text-[#6B7280]">Upload certification documents</Label>
<label className="mt-1.5 flex flex-col items-center justify-center gap-1.5 border-2 border-dashed border-[#E5E7EB] rounded-lg py-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
{uploadingCert ? (
<Loader2 className="w-5 h-5 text-[#0A39DF] animate-spin" />
) : (
<UploadCloud className="w-5 h-5 text-[#9CA3AF]" />
)}
<span className="text-[12px] text-[#6B7280]">
{uploadingCert ? 'Uploading...' : 'Click to upload (PDF, JPG, PNG)'}
</span>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
multiple
onChange={handleCertUpload}
disabled={uploadingCert}
className="hidden"
/>
</label>
{uploadError && (
<p className="text-[12px] text-[#EF4444] mt-1">{uploadError}</p>
)}
{form.certification_files.length > 0 && (
<div className="mt-2 space-y-1.5">
{form.certification_files.map((url, i) => (
<div key={i} className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-lg bg-[#F0FDF4] border border-[#BBF7D0]">
<span className="flex items-center gap-1.5 text-[12px] text-[#166534] truncate">
<FileCheck2 className="w-3.5 h-3.5 shrink-0" />
{decodeURIComponent(url.split('/').pop()?.split('?')[0] || `Certification ${i + 1}`)}
</span>
<button type="button" onClick={() => removeCertFile(i)} className="text-[#9CA3AF] hover:text-[#EF4444] shrink-0">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Selfie Photo */}
<div>
<Label className="text-[13px] text-[#374151]">Selfie Photo *</Label>
<p className="text-[11px] text-[#6B7280] mt-0.5 mb-2">Take a quick selfie so your interviewer can see you before the interview.</p>
{form.selfie_url ? (
<div className="flex items-center gap-3">
<img src={form.selfie_url} alt="Selfie" className="w-20 h-20 rounded-xl object-cover border border-[#E5E7EB]" />
<button
type="button"
onClick={() => update('selfie_url', '')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#F3F4F6] hover:bg-[#E5E7EB] text-[#374151] font-medium text-[13px] transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Retake
</button>
</div>
) : (
<label className="flex items-center gap-3 border-2 border-dashed border-[#E5E7EB] rounded-xl p-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
{uploadingSelfie ? (
<Loader2 className="w-8 h-8 text-[#0A39DF] animate-spin shrink-0" />
) : (
<div className="w-12 h-12 rounded-full bg-[#EFF6FF] flex items-center justify-center shrink-0">
<Camera className="w-6 h-6 text-[#0A39DF]" />
</div>
)}
<div className="flex-1">
<p className="text-[13px] font-medium text-[#374151]">
{uploadingSelfie ? 'Uploading...' : 'Take or upload a selfie'}
</p>
<p className="text-[11px] text-[#6B7280]">Click to use your camera or select a photo</p>
</div>
<input
type="file"
accept="image/*"
capture="user"
onChange={handleSelfieUpload}
disabled={uploadingSelfie}
className="hidden"
/>
</label>
)}
</div>
<div>
<Label className="text-[13px] text-[#374151]">Availability</Label>
<div className="flex flex-wrap gap-2 mt-1.5">
{AVAIL_OPTIONS.map(slot => (
<button
key={slot}
type="button"
onClick={() => toggleAvail(slot)}
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
form.availability.includes(slot) ? 'bg-[#059669] border-[#059669] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
}`}
>
{slot}
</button>
))}
</div>
</div>
<div>
<Label className="text-[13px] text-[#374151]">Professional Summary</Label>
<textarea value={form.professional_summary} onChange={(e) => update('professional_summary', e.target.value)} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none" />
</div>
<Button type="submit" disabled={submitting} className="w-full bg-[#0A39DF] hover:bg-[#082EB4] h-11 text-[14px]">
{submitting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Submit Application
</Button>
</form>
);
}

View File

@@ -0,0 +1,151 @@
import React from 'react';
import { Mic, Clock, Gauge, Sparkles, Fingerprint, BadgeCheck, Briefcase, ChevronRight, AlertCircle } from 'lucide-react';
import { Image } from '@/components/ui/image';
import { cn } from '@/lib/utils';
function getInitials(name) {
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
}
function krowCode(profile) {
if (profile?.id) {
const hex = profile.id.replace(/[^a-f0-9]/gi, '');
if (hex.length >= 6) return hex.slice(0, 6).toUpperCase();
}
return 'KROWID';
}
export default function ApplyIdentityCard({ profile, job, onStart, submitting, onBuildIdentity }) {
const ready = profile && profile.full_name;
const completeness = Math.min(100, Math.round(profile?.profile_completion || 0));
const skills = profile?.skills || [];
const certs = profile?.certifications || [];
const experienceYears = profile?.experience_years || 0;
const krowScore = Math.round(profile?.krow_score || profile?.ai_interview_score || 0);
return (
<div className="bg-white border border-[#E5E7EB] rounded-2xl overflow-hidden">
{/* Header band */}
<div className="px-6 py-5 bg-gradient-to-b from-[#F5F7FB] to-white border-b border-[#E5E7EB]">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-[18px] font-semibold text-[#111827]">Apply: {job.title}</h2>
<p className="text-[12px] text-[#6B7280] mt-0.5">{job.role_category} • {job.location}</p>
</div>
<span className="inline-flex items-center gap-1 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase">
<Fingerprint className="w-3.5 h-3.5" /> KROW ID · {krowCode(profile)}
</span>
</div>
</div>
<div className="p-6 space-y-5">
{/* Identity preview */}
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-full overflow-hidden ring-2 ring-[#E5E7EB] shrink-0 bg-[#E5E7EB] flex items-center justify-center">
{profile?.selfie_url ? (
<Image src={profile.selfie_url} alt={profile.full_name} className="w-full h-full object-cover" fittingType="fill" />
) : (
<span className="text-[20px] font-bold text-[#1D4ED8]">{getInitials(profile?.full_name)}</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[17px] font-bold text-[#111827] truncate">{profile?.full_name || 'Your KROW Identity'}</h3>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#1E40AF]">Verified</span>
</div>
<p className="text-[12px] text-[#6B7280] mt-1">
Your KROW Identity is loaded — no form needed. Owliver will run a short AI interview to confirm fit for this role.
</p>
</div>
</div>
{/* Quick stats */}
<div className="grid grid-cols-3 gap-2">
<Stat label="Experience" value={experienceYears > 0 ? `${experienceYears} yr` : '—'} icon={<Briefcase className="w-3.5 h-3.5" />} />
<Stat label="KROW Score" value={krowScore > 0 ? krowScore : '—'} icon={<Gauge className="w-3.5 h-3.5" />} />
<Stat label="Profile" value={`${completeness}%`} icon={<Sparkles className="w-3.5 h-3.5" />} />
</div>
{/* Skills + certs */}
<div className="space-y-3">
{skills.length > 0 && (
<Row label="Skills">
{skills.slice(0, 8).map((s, i) => (
<span key={i} className="px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] text-[11px] font-medium">{s}</span>
))}
</Row>
)}
{certs.length > 0 && (
<Row label="Certifications">
{certs.slice(0, 6).map((c, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F3F4F6] text-[#374151] text-[11px] font-medium">
<BadgeCheck className="w-3 h-3" /> {c}
</span>
))}
</Row>
)}
</div>
{/* Incomplete identity notice */}
{completeness < 40 && (
<div className="flex items-start gap-2 p-3 bg-[#FFF7ED] rounded-lg border border-[#FED7AA]">
<AlertCircle className="w-4 h-4 text-[#B45309] mt-0.5 shrink-0" />
<div>
<p className="text-[12px] font-medium text-[#92400E]">Your KROW Identity is light.</p>
<p className="text-[11px] text-[#B45309] mt-0.5">You can still interview now — building your identity improves matches.</p>
</div>
</div>
)}
{/* Interview meta */}
<div className="flex items-center justify-center gap-3 text-[12px] text-[#6B7280]">
<span className="inline-flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" /> 3 minutes</span>
<span className="inline-flex items-center gap-1.5"><Mic className="w-3.5 h-3.5" /> Voice or text</span>
<span className="inline-flex items-center gap-1.5"><Gauge className="w-3.5 h-3.5" /> Instant score</span>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-2 pt-1">
<button
onClick={onStart}
disabled={!ready || submitting}
className={cn(
'flex-1 inline-flex items-center justify-center gap-2 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold px-5 py-3 rounded-xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
<Mic className="w-4 h-4" />
{submitting ? 'Starting interview…' : 'Start AI Interview with Owliver'}
{!submitting && <ChevronRight className="w-4 h-4" />}
</button>
{onBuildIdentity && (
<button
onClick={onBuildIdentity}
className="inline-flex items-center justify-center gap-1.5 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[13px] font-medium px-4 py-3 rounded-xl transition-colors"
>
<Fingerprint className="w-4 h-4" /> Build Identity
</button>
)}
</div>
</div>
</div>
);
}
function Stat({ label, value, icon }) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5 text-center">
<div className="inline-flex items-center justify-center text-[#1E56C1] mb-1">{icon}</div>
<div className="text-[15px] font-bold text-[#111827] leading-tight">{value}</div>
<div className="text-[10px] text-[#9CA3AF] font-medium mt-0.5">{label}</div>
</div>
);
}
function Row({ label, children }) {
return (
<div>
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">{label.toUpperCase()}</p>
<div className="flex flex-wrap gap-1.5">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { Award } from 'lucide-react';
import { cn } from '@/lib/utils';
const LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
};
export default function BadgeChip({ name, level = 'bronze', earned_date, size = 'md' }) {
return (
<div className={cn('inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5', LEVEL_STYLES[level] || LEVEL_STYLES.bronze)}>
<Award className={size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
<span className={cn('font-medium', size === 'sm' ? 'text-[11px]' : 'text-[12px]')}>{name}</span>
{earned_date && <span className="text-[10px] opacity-70">· {new Date(earned_date).toLocaleDateString()}</span>}
</div>
);
}

View File

@@ -0,0 +1,201 @@
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase } from 'lucide-react';
import { Image } from '@/components/ui/image';
import { getScoreBand } from '@/lib/talentHome';
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
const STATUS_STYLES = {
hired: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Hired', card: 'bg-white border-[#E5E7EB]' },
ai_screened: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'AI Screened', card: 'bg-white border-[#E5E7EB]' },
shortlisted: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Shortlisted', card: 'bg-white border-[#E5E7EB]' },
interview: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Interviewing', card: 'bg-white border-[#E5E7EB]' },
applied: { badge: 'bg-[#F3F4F6] text-[#6B7280]', label: 'Applied', card: 'bg-white border-[#E5E7EB]' },
rejected: { badge: 'bg-[#FEE2E2] text-[#991B1B]', label: 'Rejected', card: 'bg-white border-[#E5E7EB]' },
};
function getInitials(name) {
return name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
}
const PORTRAIT_PHOTOS = [
'photo-1500648767731-7d3514ad8b31',
'photo-1531123897727-8f129e1688ce',
'photo-1573496359142-b8d87734a5a2',
'photo-1487412720507-e7ab37603c6f',
'photo-1502685104226-ee3a97a2f2c8',
'photo-1544005313-94ddf0286df2',
'photo-1494790108377-be9c29b29330',
'photo-1438761681033-6461ffad8d80',
'photo-1517841905240-472988babdf9',
'photo-1463453091185-61582044d556',
];
function getPortraitSeed(name) {
let h = 0;
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
}
function getScoreColor(score) {
if (score > 0) return '#0A39DF';
return '#EF4444';
}
function ScoreRing({ score }) {
const color = getScoreColor(score);
const radius = 24;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (score / 100) * circumference;
return (
<div className="relative w-14 h-14 flex items-center justify-center shrink-0">
<svg className="w-14 h-14 -rotate-90" viewBox="0 0 56 56">
<circle cx="28" cy="28" r={radius} fill="none" stroke="#F3F4F6" strokeWidth="4" />
<circle
cx="28" cy="28" r={radius} fill="none" stroke={color} strokeWidth="4"
strokeDasharray={circumference} strokeDashoffset={offset} strokeLinecap="round"
/>
</svg>
<span className="absolute text-[13px] font-bold text-[#111827]">{score}</span>
</div>
);
}
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
const position = jobTitle || application.job_title || '';
const status = application.status || 'applied';
const style = STATUS_STYLES[status] || STATUS_STYLES.applied;
const score = application.ai_score || 0;
const canTakeAction = status !== 'hired' && status !== 'rejected';
const [expanded, setExpanded] = useState(false);
return (
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
<div
className="flex items-center gap-4 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
{/* Rank */}
<div className="flex flex-col items-center justify-center gap-0.5 shrink-0 w-8">
<span className="text-[18px] font-bold text-[#0A39DF]">{rank}</span>
</div>
{/* Avatar */}
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[13px] shrink-0 overflow-hidden">
<Image
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=120&h=120&fit=crop&crop=faces`}
alt={application.applicant_name}
className="w-full h-full object-cover"
fittingType="fill"
/>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
{score > 0 && (() => {
const band = getScoreBand(score);
return (
<span
className="px-2 py-0.5 rounded-full text-[10px] font-bold text-white"
style={{ backgroundColor: band.color }}
>
{band.label}
</span>
);
})()}
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
{position && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<Briefcase className="w-3 h-3" />
{position}
</span>
)}
{application.email && (
<span>{application.email}</span>
)}
{application.phone && (
<span>{application.phone}</span>
)}
{application.years_experience != null && (
<span>{application.years_experience}yr exp</span>
)}
</div>
{application.ai_match_label && (
<div className="mt-1.5">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#0A39DF] text-white text-[10px] font-bold">
+ {application.ai_match_label}
</span>
</div>
)}
</div>
{/* Score */}
{status !== 'applied' && <ScoreRing score={score} />}
{/* Actions */}
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{onAction && canTakeAction && (
<button
onClick={() => onAction(application)}
className="px-3 py-1.5 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
>
{status === 'interview' ? 'AI Interview' : 'Interview'}
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
Actions
<ChevronDown className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{onMessage && (
<DropdownMenuItem onClick={() => onMessage(application)} className="cursor-pointer py-2">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
<span className="text-[#374151]">Message</span>
</DropdownMenuItem>
)}
{onCall && (
<DropdownMenuItem onClick={() => onCall(application)} className="cursor-pointer py-2">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669]">Call</span>
</DropdownMenuItem>
)}
{onSchedule && (
<DropdownMenuItem onClick={() => onSchedule(application)} className="cursor-pointer py-2">
<Calendar className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
<span className="text-[#0A39DF]">Set up interview</span>
</DropdownMenuItem>
)}
{onHire && (
<DropdownMenuItem onClick={() => onHire(application)} className="cursor-pointer py-2">
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669]">Hire candidate</span>
</DropdownMenuItem>
)}
{onDecline && (
<DropdownMenuItem onClick={() => onDecline(application)} className="cursor-pointer py-2">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626]">Decline candidate</span>
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem onClick={() => onDelete(application)} className="cursor-pointer py-2">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626]">Delete candidate</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{expanded && <CandidateExpandedDetails application={application} />}
</div>
);
}

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { Award, BarChart3, Clock, ScrollText, Calendar, User, Users, MessageSquare, CalendarCheck, Dumbbell, Crown, Briefcase, Target, Shield, Check, AlertTriangle } from 'lucide-react';
import InterviewResults from '@/components/krow/InterviewResults';
import WorkforceReputation from '@/components/krow/WorkforceReputation';
const METRIC_CONFIG = [
{ key: 'experience', label: 'Experience', Icon: Award },
{ key: 'english', label: 'English', Icon: BarChart3 },
{ key: 'reliability', label: 'Reliability', Icon: Clock },
{ key: 'certifications', label: 'Certs', Icon: ScrollText },
{ key: 'availability', label: 'Availability', Icon: Calendar },
{ key: 'personality', label: 'Personality', Icon: User },
{ key: 'culture_fit', label: 'Culture Fit', Icon: Users },
{ key: 'communication_style', label: 'Comm Style', Icon: MessageSquare },
{ key: 'attendance_expectations', label: 'Attendance', Icon: CalendarCheck },
{ key: 'physical_requirements', label: 'Physical', Icon: Dumbbell },
{ key: 'leadership_expectations', label: 'Leadership', Icon: Crown },
{ key: 'job_related_answers', label: 'Job Knowledge', Icon: Briefcase },
{ key: 'verified_skills', label: 'Skills', Icon: Target },
{ key: 'scenario_judgment', label: 'Judgment', Icon: Shield },
{ key: 'employer_requirements', label: 'Req Match', Icon: Check },
];
function MetricPill({ label, value, Icon }) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]">
<Icon className="w-3 h-3 text-[#9CA3AF]" strokeWidth={2} />
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
<span className="text-[12px] font-bold text-[#0A39DF] tabular-nums">{value || 0}</span>
</span>
);
}
export default function CandidateExpandedDetails({ application }) {
const breakdown = application.score_breakdown || {};
const strengths = application.ai_strengths || [];
const gaps = application.ai_gaps || [];
const certs = application.certifications || [];
const hasScoreData = application.ai_score > 0;
const hasInterview = application.interview_id || application.status === 'interview';
if (!hasScoreData && strengths.length === 0 && gaps.length === 0 && !hasInterview) {
return (
<div className="mt-3 pt-4 border-t border-[#F3F4F6]">
<p className="text-[13px] text-[#9CA3AF] text-center py-4">No AI screening results yet. Run AI Screen to see detailed metrics.</p>
</div>
);
}
return (
<div className="mt-3 pt-4 border-t border-[#F3F4F6] space-y-4">
{/* Workforce Reputation */}
<WorkforceReputation application={application} />
{/* Metrics — inline pills, only scored metrics shown */}
{Object.keys(breakdown).length > 0 && (
<div className="flex flex-wrap gap-2">
{METRIC_CONFIG
.filter(({ key }) => (breakdown[key] ?? 0) > 0)
.map(({ key, label, Icon }) => (
<MetricPill key={key} label={label} value={breakdown[key]} Icon={Icon} />
))}
</div>
)}
{/* AI Summary */}
{application.ai_summary && (
<div>
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">AI SUMMARY</h4>
<p className="text-[13px] text-[#6B7280] leading-relaxed">{application.ai_summary}</p>
</div>
)}
{/* Strengths & Gaps */}
{(strengths.length > 0 || gaps.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{strengths.length > 0 && (
<div className="bg-[#EFF6FF] rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-2">
<Check className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
</div>
<ul className="space-y-1.5">
{strengths.map((s, i) => (
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
<span className="text-[#0A39DF] font-bold">+</span>
<span>{s}</span>
</li>
))}
</ul>
</div>
)}
{gaps.length > 0 && (
<div className="bg-[#FFF7ED] rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-2">
<AlertTriangle className="w-3.5 h-3.5 text-[#9A3412]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#9A3412] tracking-wide">GAPS</h4>
</div>
<ul className="space-y-1.5">
{gaps.map((g, i) => (
<li key={i} className="text-[12px] text-[#9A3412] flex gap-2">
<span className="text-[#EA580C] font-bold">−</span>
<span>{g}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Certification Tags */}
{certs.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{certs.map((cert, i) => (
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
{cert}
</span>
))}
</div>
)}
{/* AI Recommendation */}
{application.ai_recommendation && (
<div className="bg-[#F0FDF4] border border-[#BBF7D0] rounded-xl p-3">
<p className="text-[12px] text-[#166534]"><span className="font-bold">Recommendation:</span> {application.ai_recommendation}</p>
</div>
)}
{/* Interview Results & Transcript */}
{(application.interview_id || application.status === 'interview') && (
<InterviewResults application={application} />
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
// A learning path rendered as a quiet vertical journey to the target role.
export default function CareerJourney({ path, completedIds = [] }) {
const steps = path.steps || [];
const target = path.target_role || path.name;
const allDone = steps.length > 0 && steps.every((s) => completedIds.has(s.course_id));
const rungs = [
...steps.map((s, i) => ({
label: s.course_title || `Step ${i + 1}`,
done: completedIds.has(s.course_id),
})),
{ label: target, done: allDone, isTarget: true },
];
const doneCount = steps.filter((s) => completedIds.has(s.course_id)).length;
const pct = steps.length ? Math.round((doneCount / steps.length) * 100) : 0;
return (
<div className="py-6">
<div className="flex items-baseline justify-between mb-5">
<h3 className="text-[17px] font-semibold text-[#0F172A]">{path.name}</h3>
<span className="text-[13px] text-[#64748B]">{pct}%</span>
</div>
{path.description && <p className="text-[13px] text-[#64748B] mb-4">{path.description}</p>}
<div className="relative pl-6">
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-[#E2E8F0]" />
{rungs.map((r, i) => (
<div key={i} className="relative flex items-center gap-3 py-2">
<div
className={`absolute -left-6 w-3.5 h-3.5 rounded-full ${
r.done ? 'bg-[#0A39DF]' : 'bg-white border-2 border-[#E2E8F0]'
}`}
/>
<span
className={`text-[15px] ${
r.isTarget
? 'font-semibold text-[#0A39DF]'
: r.done
? 'text-[#0F172A] font-medium'
: 'text-[#64748B]'
}`}
>
{r.label}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
function Stage({ label, value, sub, accent, to }) {
const body = (
<div className="text-center sm:text-left">
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>
<div className={`text-[20px] sm:text-[26px] font-semibold mt-1 tracking-tight ${accent ? 'text-[#0A39DF]' : 'text-[#0F172A]'}`}>
{value}
</div>
{sub && <div className="text-[13px] text-[#64748B] mt-0.5">{sub}</div>}
</div>
);
return to ? (
<Link to={to} className="hover:opacity-80 transition-opacity">{body}</Link>
) : body;
}
export default function CareerTrajectory({ profile, nextPath, nextReady, futureRole, futureIncome }) {
const current = profile?.current_position || profile?.desired_position || 'Starting Out';
const next = nextPath?.target_role || nextPath?.name || 'Next Role';
return (
<section className="text-center pt-4 pb-12 sm:pt-8 sm:pb-20">
<span className="text-[12px] font-medium tracking-wide text-[#0A39DF] uppercase">Your Career</span>
<h1 className="text-[32px] sm:text-[44px] font-bold tracking-tight text-[#0F172A] mt-4 leading-[1.05]">
Everything revolves around your future.
</h1>
<p className="text-[15px] text-[#64748B] mt-4 max-w-sm mx-auto">
Not a university. A Career Acceleration Engine.
</p>
<div className="mt-12 sm:mt-16 flex flex-col sm:flex-row items-center justify-center gap-5 sm:gap-10">
<Stage label="Current" value={current} />
<ArrowRight className="w-4 h-4 text-[#CBD5E1] rotate-90 sm:rotate-0" />
<Stage label="Next Role" value={next} sub={`${nextReady}% ready`} accent to={nextPath ? '/university' : null} />
<ArrowRight className="w-4 h-4 text-[#CBD5E1] rotate-90 sm:rotate-0" />
<Stage label="Future" value={futureRole} sub={futureIncome || ' '} />
</div>
</section>
);
}

View File

@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Trash2, ShieldCheck, Loader2 } from 'lucide-react';
import { useCertifications, useCreateCertification, useDeleteCertification } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
export default function CertificationManager() {
const { data: certifications = [], isLoading } = useCertifications();
const createCert = useCreateCertification();
const deleteCert = useDeleteCertification();
const [open, setOpen] = useState(false);
const [newCert, setNewCert] = useState('');
const handleAdd = async () => {
const name = newCert.trim();
if (!name) return;
try {
await createCert.mutateAsync({ name });
setNewCert('');
toast.success(`"${name}" added to certifications`);
} catch (e) {
toast.error('Failed to add certification');
}
};
const handleDelete = async (cert) => {
try {
await deleteCert.mutateAsync(cert.id);
toast.success(`"${cert.name}" removed`);
} catch (e) {
toast.error('Failed to remove certification');
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button className="inline-flex items-center gap-1.5 bg-white border border-[#E5E7EB] hover:border-[#D1D5DB] text-[#374151] text-[13px] font-medium px-4 h-10 rounded-lg transition-colors">
<ShieldCheck className="w-4 h-4 text-[#0A39DF]" />
<span className="hidden sm:inline">Manage Certifications</span>
<span className="sm:hidden">Certs</span>
</button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Manage Certifications</DialogTitle>
</DialogHeader>
<p className="text-[13px] text-[#6B7280] -mt-1">
These are the certification options applicants can select and upload when applying.
</p>
{/* Add new */}
<div className="flex gap-2">
<Input
value={newCert}
onChange={(e) => setNewCert(e.target.value)}
placeholder="e.g. OSHA 30"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
className="flex-1"
/>
<Button
type="button"
onClick={handleAdd}
disabled={!newCert.trim() || createCert.isPending}
className="bg-[#0A39DF] hover:bg-[#082EB4]"
>
{createCert.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Add
</Button>
</div>
{/* List */}
<div className="space-y-1.5 max-h-[300px] overflow-y-auto">
{isLoading && [...Array(3)].map((_, i) => (
<div key={i} className="h-10 bg-[#F3F4F6] rounded-lg animate-pulse" />
))}
{!isLoading && certifications.length === 0 && (
<p className="text-[13px] text-[#9CA3AF] text-center py-6">No certifications yet. Add your first one above.</p>
)}
{certifications.map((cert) => (
<div key={cert.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-[#F9FAFB] border border-[#E5E7EB]">
<span className="text-[13px] font-medium text-[#111827] truncate">{cert.name}</span>
<button
type="button"
onClick={() => handleDelete(cert)}
disabled={deleteCert.isPending}
className="text-[#9CA3AF] hover:text-[#EF4444] transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,93 @@
import React, { useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
const TIER_COLORS = { Skilled: '#0A39DF', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
export default function HiringAnalytics({ applications, staff }) {
const byTier = useMemo(() => {
const tiers = ['Skilled', 'Cross-Trained', 'Beginner'];
return tiers.map(tier => ({
label: tier,
count: staff.filter(s => s.profile_tier === tier).length,
color: TIER_COLORS[tier],
}));
}, [staff]);
const byRole = useMemo(() => {
const counts = {};
staff.forEach(s => {
const role = s.role || 'Unassigned';
counts[role] = (counts[role] || 0) + 1;
});
return Object.entries(counts)
.map(([role, count]) => ({ role, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
}, [staff]);
const hiredApps = applications.filter(a => a.status === 'hired');
const totalApps = applications.length;
const hireRate = totalApps ? Math.round((hiredApps.length / totalApps) * 100) : 0;
const avgHireScore = hiredApps.length
? Math.round(hiredApps.reduce((s, a) => s + (a.ai_score || 0), 0) / hiredApps.length)
: 0;
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const recentHires = staff.filter(s => new Date(s.hire_date) >= sevenDaysAgo).length;
return (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">Hiring Analytics</h3>
<p className="text-[12px] text-[#6B7280] mb-5">{staff.length} total hires · {recentHires} in last 7 days</p>
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{hireRate}%</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore}</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{recentHires}</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">LAST 7 DAYS</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div>
<p className="text-[12px] font-medium text-[#374151] mb-3">Hires by Tier</p>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={byTier}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} stroke="#9CA3AF" />
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{byTier.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-[12px] font-medium text-[#374151] mb-3">Hires by Role</p>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={byRole} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<YAxis type="category" dataKey="role" tick={{ fontSize: 9 }} stroke="#9CA3AF" width={100} />
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
<Bar dataKey="count" fill="#0A39DF" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
export default function ImpactMetrics({ applications, interviews, staff }) {
const hired = applications.filter(a => a.status === 'hired');
const screened = applications.filter(a => a.ai_score > 0);
// 1. Reduce time-to-hire: avg days from application created to hired
const timeToHire = hired.length
? Math.round(
hired.reduce((sum, a) => {
const created = new Date(a.created_date).getTime();
const updated = new Date(a.updated_date).getTime();
return sum + Math.max(1, (updated - created) / (1000 * 60 * 60 * 24));
}, 0) / hired.length
)
: 0;
// AI screens instantly vs ~5 day manual industry average
const tthDisplay = timeToHire ? `${timeToHire}d avg` : 'Instant screen';
// 2. Lower recruiting costs: $45 per manual screen + $80 per manual interview saved
const costSaved = screened.length * 45 + interviews.length * 80;
// 3. Improve quality of hire: avg AI score of hired candidates
const qualityOfHire = hired.length
? Math.round(hired.reduce((s, a) => s + (a.ai_score || 0), 0) / hired.length)
: 0;
// 4. Increase recruiter productivity: hours saved (15 min per AI screen + 30 min per interview)
const hoursSaved = Math.round((screened.length * 15 + interviews.length * 30) / 60);
// 5. Standardize hiring decisions: % of applicants with standardized AI scoring applied
const standardizedPct = applications.length
? Math.round((screened.length / applications.length) * 100)
: 0;
// 6. Improve candidate experience: AI responds instantly (interview completion rate as proxy)
const completionRate = interviews.length
? Math.round((interviews.filter(i => i.overall_interview_score > 0).length / interviews.length) * 100)
: 0;
const metrics = [
{
icon: Clock,
label: 'Time-to-Hire',
value: tthDisplay,
sub: hired.length ? `${hired.length} hires tracked` : 'AI screens instantly',
color: '#0A39DF',
bg: '#EFF6FF',
},
{
icon: DollarSign,
label: 'Cost Saved',
value: `$${costSaved.toLocaleString()}`,
sub: `${screened.length} screens · ${interviews.length} interviews automated`,
color: '#333F48',
bg: '#F3F4F6',
},
{
icon: Award,
label: 'Quality of Hire',
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
sub: hired.length ? `${hired.length} hired candidates` : 'Awaiting first hire',
color: '#0A39DF',
bg: '#FEFCE8',
},
{
icon: Zap,
label: 'Recruiter Hours Saved',
value: `${hoursSaved}h`,
sub: 'AI screening + interview automation',
color: '#333F48',
bg: '#F3F4F6',
},
{
icon: Scale,
label: 'Decisions Standardized',
value: `${standardizedPct}%`,
sub: `${screened.length} of ${applications.length} applicants scored`,
color: '#0A39DF',
bg: '#EFF6FF',
},
{
icon: Heart,
label: 'Candidate Experience',
value: completionRate ? `${completionRate}% completion` : 'Instant response',
sub: 'AI responds in seconds, not days',
color: '#333F48',
bg: '#D1E0D7',
},
];
return (
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider font-heading">
HIRING IMPACT · OUR MISSION
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{metrics.map((m, i) => {
const Icon = m.icon;
return (
<motion.div
key={m.label}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06, duration: 0.4, ease: 'easeOut' }}
className="glass-card border border-white/60 rounded-2xl p-5 shadow-sm hover:shadow-lg transition-shadow"
>
<div className="flex items-start gap-4">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 shadow-lg"
style={{ background: m.bg }}
>
<Icon className="w-5 h-5" style={{ color: m.color }} />
</div>
<div className="min-w-0">
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide">{m.label}</div>
<div className="text-2xl font-heading font-bold text-[#111827] mt-0.5">{m.value}</div>
<div className="text-[12px] text-[#6B7280] mt-1 leading-snug">{m.sub}</div>
</div>
</div>
</motion.div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import React, { useMemo } from 'react';
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
const VERDICT_COLORS = { hire: '#0A39DF', maybe: '#F9E547', no: '#333F48' };
export default function InterviewAnalytics({ applications, interviews }) {
const stats = useMemo(() => {
const started = applications.filter(a => a.status === 'interview' || a.interview_id).length;
const completed = interviews.length;
const completionRate = started ? Math.round((completed / started) * 100) : 0;
const scored = interviews.filter(i => i.overall_interview_score > 0);
const avgScore = scored.length ? Math.round(scored.reduce((s, i) => s + i.overall_interview_score, 0) / scored.length) : 0;
const verdictCounts = { hire: 0, maybe: 0, no: 0 };
interviews.forEach(i => {
if (i.verdict) verdictCounts[i.verdict] = (verdictCounts[i.verdict] || 0) + 1;
});
const lowIntegrity = interviews.filter(i => i.integrity_score < 100).length;
return { started, completed, completionRate, avgScore, verdictCounts, lowIntegrity };
}, [applications, interviews]);
const verdictData = [
{ name: 'Hire', value: stats.verdictCounts.hire },
{ name: 'Maybe', value: stats.verdictCounts.maybe },
{ name: 'No', value: stats.verdictCounts.no },
].filter(d => d.value > 0);
const categoryData = useMemo(() => {
const cats = ['communication', 'confidence', 'experience_relevance', 'culture_fit', 'problem_solving'];
return cats.map(cat => {
const scored = interviews.filter(i => i.category_scores?.[cat] != null);
const avg = scored.length ? Math.round(scored.reduce((s, i) => s + i.category_scores[cat], 0) / scored.length) : 0;
return { category: cat.replace(/_/g, ' '), score: avg };
}).filter(d => d.score > 0);
}, [interviews]);
const cards = [
{ label: 'Started', value: stats.started, icon: Mic, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Completed', value: stats.completed, icon: CheckCircle2, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Completion Rate', value: `${stats.completionRate}%`, icon: Clock, color: '#0A39DF', bg: '#FEFCE8' },
{ label: 'Avg Interview Score', value: stats.avgScore, icon: AlertTriangle, color: '#333F48', bg: '#D1E0D7' },
];
return (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<div key={c.label} className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm">
<div className="w-8 h-8 rounded-lg flex items-center justify-center mb-2" style={{ background: c.bg }}>
<Icon className="w-4 h-4" style={{ color: c.color }} />
</div>
<div className="text-xl font-heading font-bold text-[#111827]">{c.value}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide mt-0.5">{c.label}</div>
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{verdictData.length > 0 && (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">Verdict Breakdown</h3>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={verdictData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={75} label>
{verdictData.map((entry, i) => (
<Cell key={i} fill={VERDICT_COLORS[entry.name.toLowerCase()] || '#9CA3AF'} />
))}
</Pie>
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
</div>
)}
{categoryData.length > 0 && (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">Avg Category Scores</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={categoryData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 11 }} stroke="#9CA3AF" />
<YAxis type="category" dataKey="category" tick={{ fontSize: 10 }} stroke="#9CA3AF" width={110} />
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
<Bar dataKey="score" fill="#333F48" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import React from 'react';
import { useInterviews } from '@/lib/krowHooks';
import { MessageSquare, CheckCircle2, AlertTriangle, Star, FileText } from 'lucide-react';
const VERDICT_STYLES = {
hire: { bg: 'bg-[#F0FDF4]', text: 'text-[#166534]', label: 'Hire' },
maybe: { bg: 'bg-[#FFFBEB]', text: 'text-[#92400E]', label: 'Maybe' },
no: { bg: 'bg-[#FEF2F2]', text: 'text-[#991B1B]', label: 'No Hire' },
};
const CATEGORY_LABELS = {
communication: 'Communication',
confidence: 'Confidence',
experience_relevance: 'Experience',
culture_fit: 'Culture Fit',
problem_solving: 'Problem Solving',
personality: 'Personality',
communication_style: 'Comm Style',
attendance_expectations: 'Attendance',
reliability: 'Reliability',
physical_requirements: 'Physical',
leadership_expectations: 'Leadership',
scenario_judgment: 'Judgment',
job_related_answers: 'Job Knowledge',
verified_skills: 'Skills',
};
function getCategoryColor(val) {
if (val >= 80) return '#0A39DF';
if (val >= 60) return '#333F48';
if (val >= 40) return '#6B7280';
return '#EF4444';
}
export default function InterviewResults({ application }) {
const { data: interviews = [], isLoading } = useInterviews();
const interview = interviews
.filter((i) => i.application_id === application.id)
.sort((a, b) => new Date(b.created_date) - new Date(a.created_date))[0];
if (isLoading) {
return (
<div className="mt-3 pt-4 border-t border-[#F3F4F6]">
<p className="text-[12px] text-[#9CA3AF]">Loading interview results...</p>
</div>
);
}
if (!interview) {
return null;
}
const verdictStyle = VERDICT_STYLES[interview.verdict] || VERDICT_STYLES.maybe;
const messages = interview.messages || [];
const categoryScores = interview.category_scores || {};
return (
<div className="mt-3 pt-4 border-t border-[#F3F4F6] space-y-4">
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
<MessageSquare className="w-3.5 h-3.5 text-white" />
</div>
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide">AI INTERVIEW RESULTS</h4>
</div>
<div className="flex items-center gap-2">
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${verdictStyle.bg} ${verdictStyle.text}`}>
{verdictStyle.label}
</span>
<span className="text-[18px] font-bold text-[#0A39DF]">{interview.overall_interview_score || 0}<span className="text-[11px] text-[#9CA3AF]">/100</span></span>
</div>
</div>
{/* Category Scores */}
{Object.keys(categoryScores).length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{Object.entries(categoryScores).map(([key, val]) => {
const color = getCategoryColor(val);
return (
<div key={key} className="bg-[#F9FAFB] rounded-xl p-2.5 flex flex-col items-center gap-1 border border-[#F3F4F6]">
<div className="text-[15px] font-bold text-[#111827]">{val}</div>
<div className="w-full h-1.5 rounded-full bg-[#E5E7EB] overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${val}%`, background: color }} />
</div>
<div className="text-[9px] text-[#9CA3AF] tracking-wide font-medium text-center leading-tight">{CATEGORY_LABELS[key] || key}</div>
</div>
);
})}
</div>
)}
{/* Summary */}
{interview.summary && (
<div>
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><FileText className="w-3 h-3" /> SUMMARY</h4>
<p className="text-[13px] text-[#6B7280] leading-relaxed">{interview.summary}</p>
</div>
)}
{/* Strengths & Concerns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{interview.strengths?.length > 0 && (
<div className="bg-[#EFF6FF] rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-2">
<CheckCircle2 className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
</div>
<ul className="space-y-1.5">
{interview.strengths.map((s, i) => (
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
<span className="text-[#0A39DF] font-bold">+</span>
<span>{s}</span>
</li>
))}
</ul>
</div>
)}
{interview.concerns?.length > 0 && (
<div className="bg-[#FFF7ED] rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-2">
<AlertTriangle className="w-3.5 h-3.5 text-[#9A3412]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#9A3412] tracking-wide">CONCERNS</h4>
</div>
<ul className="space-y-1.5">
{interview.concerns.map((c, i) => (
<li key={i} className="text-[12px] text-[#9A3412] flex gap-2">
<span className="text-[#EA580C] font-bold">−</span>
<span>{c}</span>
</li>
))}
</ul>
</div>
)}
</div>
{/* Integrity / Flags */}
{interview.integrity_score != null && interview.integrity_score < 100 && (
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
<div>
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {interview.integrity_score}/100</p>
{interview.ai_flags?.length > 0 && (
<p className="text-[11px] text-[#92400E] mt-1">{interview.ai_flags.join(', ')}</p>
)}
</div>
</div>
)}
{/* Best Fit Roles */}
{interview.best_fit_roles?.length > 0 && (
<div>
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><Star className="w-3 h-3" /> BEST FIT ROLES</h4>
<div className="flex items-center gap-2 flex-wrap">
{interview.best_fit_roles.map((role, i) => (
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
{role}
</span>
))}
</div>
</div>
)}
{/* Q&A Transcript */}
{messages.length > 0 && (
<div>
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1"><MessageSquare className="w-3 h-3" /> INTERVIEW TRANSCRIPT</h4>
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2 text-[13px] ${
msg.role === 'user'
? 'bg-[#0A39DF] text-white rounded-br-sm'
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
}`}>
<p className="whitespace-pre-wrap">{msg.content}</p>
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> Fast response ({msg.response_time_seconds}s)
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import React, { useState, useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Copy, Check, Share2, Mail, Link2, Smartphone, Users } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function InviteApplicantsModal({ open, onClose, job }) {
const [copied, setCopied] = useState(false);
const [emailRecipients, setEmailRecipients] = useState('');
const [sending, setSending] = useState(false);
const shareUrl = useMemo(() => {
if (!job?.id) return '';
const base = window.location.origin;
return `${base}/apply?job=${job.id}`;
}, [job?.id]);
const jobTitle = job?.title || 'this position';
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
toast.success('Invitation link copied!');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Could not copy link');
}
};
const handleNativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: `Apply: ${jobTitle} at KROW`,
text: `You've been invited to apply for ${jobTitle}. Complete a quick 5-minute AI interview — no forms, no quizzes.`,
url: shareUrl,
});
} catch { /* user cancelled */ }
} else {
handleCopyLink();
}
};
const handleEmailInvite = () => {
const recipients = emailRecipients.trim();
if (!recipients) return;
const subject = `You're invited to apply: ${jobTitle}`;
const body = `Hi,\n\nYou've been invited to apply for ${jobTitle} at KROW.\n\nClick the link below to view the position and complete a quick 5-minute AI interview:\n\n${shareUrl}\n\nNo long forms, no quizzes — just speak naturally and get instant feedback.\n\nKROW Talent Engine`;
const mailtoUrl = `mailto:${recipients}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = mailtoUrl;
setSending(true);
setTimeout(() => {
setSending(false);
toast.success('Email invitation opened');
}, 500);
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-[#0A39DF]" />
Invite Applicants
</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* Job info */}
<div className="bg-[#F8FAFC] rounded-xl p-3 border border-[#E5E7EB]">
<p className="text-[13px] font-semibold text-[#111827]">{jobTitle}</p>
{job?.location && <p className="text-[12px] text-[#6B7280]">{job.location}</p>}
{job?.pay_range_min != null && (
<p className="text-[12px] text-[#0A39DF] font-medium">${job.pay_range_min}–${job.pay_range_max}/hr</p>
)}
</div>
{/* Shareable Link */}
<div>
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
<Link2 className="w-3.5 h-3.5" /> Invitation Link
</label>
<div className="flex items-center gap-2">
<input
readOnly
value={shareUrl}
className="flex-1 min-w-0 px-3 py-2 text-[12px] rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] text-[#6B7280] truncate"
/>
<button
onClick={handleCopyLink}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium hover:bg-[#082EB4] transition-colors"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
{/* Native Share (mobile) */}
<button
onClick={handleNativeShare}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-[#E5E7EB] text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
<Share2 className="w-4 h-4" />
Share via...
</button>
{/* Email Invite */}
<div>
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
<Mail className="w-3.5 h-3.5" /> Email Invitation
</label>
<textarea
value={emailRecipients}
onChange={(e) => setEmailRecipients(e.target.value)}
placeholder="applicant1@email.com, applicant2@email.com"
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
/>
<button
onClick={handleEmailInvite}
disabled={!emailRecipients.trim() || sending}
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0A39DF] text-white text-[13px] font-medium hover:bg-[#082EB4] disabled:opacity-50 transition-colors"
>
<Mail className="w-4 h-4" />
Send Email Invitations
</button>
</div>
{/* Mobile app note */}
<div className="flex items-start gap-2 p-3 bg-[#EFF6FF] rounded-lg">
<Smartphone className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
<p className="text-[11px] text-[#374151] leading-relaxed">
Applicants open the link on any phone — no app download needed. They see open positions and complete the AI interview right in their mobile browser.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} className="w-full">Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { cn } from '@/lib/utils';
const TIERS = [
{ label: 'Top Talent', range: '80-100', color: 'bg-[#1E3A8A]', textColor: 'text-[#1E3A8A]' },
{ label: 'Strong Fit', range: '60-79', color: 'bg-[#60A5FA]', textColor: 'text-[#2563EB]' },
{ label: 'Possible Fit', range: '40-59', color: 'bg-[#93C5FD]', textColor: 'text-[#3B82F6]' },
{ label: 'Not a Fit', range: '0-39', color: 'bg-[#E5E7EB]', textColor: 'text-[#9CA3AF]' },
];
function getTier(score) {
if (score >= 80) return 0;
if (score >= 60) return 1;
if (score >= 40) return 2;
return 3;
}
export default function KROWCandidateLeaderboard({ applications, maxBars = 4 }) {
const screened = applications.filter(a => a.ai_score > 0 || a.status !== 'applied');
const counts = TIERS.map((_, i) => screened.filter(a => getTier(a.ai_score || 0) === i).length);
const total = counts.reduce((a, b) => a + b, 0) || 1;
const sorted = [...screened].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
return (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6">
<h3 className="font-semibold text-[#111827] text-[15px] mb-5">Candidate Pipeline</h3>
{/* Bar chart */}
<div className="space-y-3 mb-6">
{TIERS.map((tier, i) => {
const count = counts[i];
const pct = (count / total) * 100;
return (
<div key={tier.label}>
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] font-medium text-[#374151]">{tier.label} <span className="text-[#9CA3AF]">({tier.range})</span></span>
<span className="text-[13px] font-bold text-[#111827]">{count}</span>
</div>
<div className="h-2.5 bg-[#F3F4F6] rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', tier.color)}
style={{ width: `${Math.max(pct, count > 0 ? 4 : 0)}%` }}
/>
</div>
</div>
);
})}
</div>
{/* Ranked list */}
<div className="space-y-2">
{sorted.slice(0, 5).map((app, idx) => (
<div key={app.id} className="flex items-center gap-3 py-2">
<span className="text-[12px] font-bold text-[#9CA3AF] w-6">#{idx + 1}</span>
<div className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
{app.applicant_name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{app.applicant_name}</div>
<div className="text-[11px] text-[#6B7280] truncate">{app.job_title || 'Applicant'}</div>
</div>
<span className={cn('text-[13px] font-bold', TIERS[getTier(app.ai_score || 0)].textColor)}>
{app.ai_score || 0}
</span>
</div>
))}
{sorted.length === 0 && (
<div className="text-center py-8 text-[13px] text-[#9CA3AF]">No screened candidates yet</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
const STATUS_STYLES = {
active: 'bg-[#0A39DF] text-white',
draft: 'bg-[#F3F4F6] text-[#6B7280]',
paused: 'bg-[#FEF3C7] text-[#92400E]',
closed: 'bg-[#FEE2E2] text-[#991B1B]',
};
function getCategoryAbbr(category) {
if (!category) return 'JR';
const words = category.split(' ');
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return words.map(w => w[0]).join('').toUpperCase().slice(0, 2);
}
export default function KROWJobPostingCard({ posting, stats }) {
const navigate = useNavigate();
const applied = stats?.applied ?? 0;
const screened = stats?.screened ?? 0;
const hired = stats?.hired ?? 0;
const statusLabel = posting.status === 'active' ? 'Hiring' : posting.status.charAt(0).toUpperCase() + posting.status.slice(1);
return (
<div
onClick={() => navigate(`/positions/${posting.id}`)}
className="group bg-white border border-[#E5E7EB] rounded-xl p-5 hover:shadow-md hover:border-[#D1D5DB] transition-all cursor-pointer"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-start gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold shrink-0">
{getCategoryAbbr(posting.role_category)}
</div>
<div className="min-w-0">
<h3 className="font-semibold text-[#111827] text-[15px] leading-tight truncate">{posting.title}</h3>
<div className="text-[12px] text-[#6B7280] capitalize">{posting.role_category}</div>
</div>
</div>
<span className={cn('shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium', STATUS_STYLES[posting.status] || STATUS_STYLES.draft)}>
{statusLabel}
</span>
</div>
{/* Meta */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-[#6B7280] mb-4">
{posting.location && (
<span>{posting.location}</span>
)}
{posting.pay_range_min != null && posting.pay_range_max != null && (
<span className="font-medium text-[#111827]">${posting.pay_range_min}–${posting.pay_range_max}/hr</span>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 py-3 border-t border-[#F3F4F6]">
{[
{ label: 'APPLIED', value: applied },
{ label: 'SCREENED', value: screened },
{ label: 'HIRED', value: hired },
].map((s) => (
<div key={s.label}>
<div className="text-lg font-bold text-[#111827]">{s.value}</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide">{s.label}</div>
</div>
))}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-[#F3F4F6]">
{posting.ai_generated ? (
<span className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium">
AI Built
</span>
) : (
<span className="text-[11px] text-[#9CA3AF]">Manual</span>
)}
<span className="text-[12px] text-[#9CA3AF] group-hover:text-[#111827] transition-all">View ›</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { useWorkerProfile } from '@/lib/krowHooks';
import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
import { ShieldCheck, Smartphone, Image as ImageIcon, QrCode, Sparkles } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Image } from '@/components/ui/image';
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
function buildVCard(profile) {
const name = profile.full_name || 'KROW Talent';
const [first, ...rest] = name.split(' ');
const last = rest.join(' ');
const lines = [
'BEGIN:VCARD', 'VERSION:3.0',
`N:${last};${first};;;`, `FN:${name}`,
profile.current_position ? `TITLE:${profile.current_position}` : '',
profile.phone ? `TEL;TYPE=CELL:${profile.phone}` : '',
profile.email ? `EMAIL:${profile.email}` : '',
`URL:https://krow.app/me`,
`NOTE:KROW ID ${(profile.id || '').slice(-6).toUpperCase()}`,
'END:VCARD',
].filter(Boolean);
return lines.join('\n');
}
function download(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
export default function KrowIdPreview() {
const { data: profile, isLoading } = useWorkerProfile();
if (isLoading || !profile) {
return <div className="flex justify-center py-10 text-xs text-[#6B7280]">Loading KROW ID…</div>;
}
const score = getTalentScore(profile);
const band = getScoreBand(score);
const growth = getGrowth(profile);
const fico = toFICO(score);
const shortId = (profile.id || '').slice(-6).toUpperCase();
const name = profile.full_name || 'KROW Talent';
const title = profile.current_position || profile.desired_position || 'Open to work';
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
const vcard = buildVCard(profile);
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?margin=0&size=120x120&data=${encodeURIComponent(vcard)}`;
const addToContacts = () => {
download(`${name.replace(/\s+/g, '_')}.vcf`, vcard, 'text/vcard');
toast.success('Contact file downloaded.');
};
return (
<div className="flex justify-center">
<div className="relative w-full max-w-[360px]" style={{ perspective: '1200px' }}>
{/* depth layers */}
<div aria-hidden className="absolute inset-0 rounded-3xl bg-white/50 border border-white/60 backdrop-blur-xl shadow-[0_18px_50px_-20px_rgba(10,57,223,0.3)]"
style={{ transform: 'translateY(12px) translateX(10px) rotateY(-8deg) rotateX(5deg)' }} />
<div aria-hidden className="absolute inset-0 rounded-3xl bg-white/40 border border-white/70 backdrop-blur-xl shadow-[0_14px_40px_-16px_rgba(10,57,223,0.25)]"
style={{ transform: 'translateY(6px) translateX(5px) rotateY(-4deg) rotateX(2deg)' }} />
{/* front pass */}
<div className="relative rounded-3xl border border-white/80 backdrop-blur-2xl shadow-[0_20px_60px_-22px_rgba(10,57,223,0.4)] overflow-hidden"
style={{ background: 'linear-gradient(160deg, rgba(255,255,255,0.94) 0%, rgba(244,247,255,0.9) 100%)' }}>
<div aria-hidden className="pointer-events-none absolute inset-0" style={{ background: 'linear-gradient(115deg, transparent 30%, rgba(10,57,223,0.05) 45%, rgba(249,229,71,0.06) 55%, transparent 70%)' }} />
<div className="relative p-5">
{/* header row */}
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-full overflow-hidden border-2 border-white shadow bg-[#EFF6FF] flex items-center justify-center shrink-0">
{profile.selfie_url ? (
<Image src={profile.selfie_url} alt={name} className="w-full h-full object-cover" fittingType="fill" />
) : (
<span className="text-[18px] font-bold text-[#0A39DF]">{initials}</span>
)}
</div>
<div className="min-w-0 text-left">
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0A39DF]">KROW ID · {shortId}</p>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight leading-tight truncate">{name}</h2>
<p className="text-[11px] text-[#6B7280] truncate">{title}</p>
</div>
<ShieldCheck className="w-4 h-4 text-[#10B981] shrink-0" />
</div>
{/* score + factors row */}
<div className="mt-4 flex items-center gap-4">
<div className="text-center shrink-0">
<p className="text-[30px] font-bold text-[#0F172A] leading-none tracking-tight">{fico}</p>
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold mt-1"
style={{ color: band.color, background: `${band.color}14` }}>
<Sparkles className="w-2.5 h-2.5" /> {band.label}
</span>
<p className="text-[10px] font-semibold text-[#16A34A] mt-1">↑ +{growth} mo</p>
</div>
<div className="flex-1 grid grid-cols-2 gap-1.5">
{[
['Reliability', clamp(profile.reliability_score)],
['Attendance', clamp(profile.attendance_score ?? 100)],
['Performance', clamp(profile.performance_score)],
['XP', Number(profile.xp) || 0],
].map(([label, value]) => (
<span key={label} className="inline-flex items-center justify-between gap-1 rounded-lg bg-white/70 border border-[#E6ECF5] px-2 py-1 text-[10px]">
<span className="text-[#9CA3AF]">{label}</span>
<span className="font-semibold text-[#0A39DF]">{value}</span>
</span>
))}
</div>
</div>
{/* footer: QR + actions */}
<div className="mt-4 flex items-center gap-3">
<div className="rounded-lg bg-white p-1 border border-[#E6ECF5] shadow-sm shrink-0">
<img src={qrSrc} alt="KROW Identity QR" width={48} height={48} className="block" />
</div>
<div className="flex-1 grid grid-cols-2 gap-1.5">
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-[#0A39DF] text-white text-[10px] font-semibold hover:bg-[#082EB4] transition-colors">
<Smartphone className="w-3 h-3" /> Contacts
</button>
<button onClick={() => toast('Open My KROW ID page to save the full pass.', { icon: 'ℹ️' })} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors">
<ImageIcon className="w-3 h-3 text-[#0A39DF]" /> Save
</button>
</div>
</div>
<p className="text-[9px] text-[#9CA3AF] mt-2 flex items-center gap-1 justify-center"><QrCode className="w-2.5 h-2.5" /> Scan to share your verified KROW Identity</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { Camera, Fingerprint, Sparkles } from 'lucide-react';
// A living identity card that reflects — rather than describes — the resume-less
// KROW Identity. It fills in as the worker adds a face, skills, languages, etc.
export default function KrowIdentityCard({ form }) {
const parts = [
{ key: 'face', filled: !!form.selfie_url },
{ key: 'name', filled: !!form.full_name.trim() },
{ key: 'about', filled: !!form.career_goals.trim() },
{ key: 'skills', filled: (form.skills || []).length > 0 },
{ key: 'languages', filled: (form.languages || []).length > 0 },
{ key: 'availability', filled: (form.availability || []).length > 0 },
{ key: 'certifications', filled: (form.certifications || []).length > 0 },
];
const done = parts.filter(p => p.filled).length;
const pct = Math.round((done / parts.length) * 100);
return (
<div className="rounded-2xl bg-[#0A39DF] p-5 sm:p-6 text-white overflow-hidden relative">
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-[#F9E547]/10" />
<div className="relative flex items-center gap-4">
<div className="shrink-0">
{form.selfie_url ? (
<img src={form.selfie_url} alt="You" className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white/25" />
) : (
<div className="w-20 h-20 rounded-2xl border-2 border-dashed border-white/40 flex flex-col items-center justify-center text-white/70">
<Camera className="w-6 h-6" />
<span className="text-[9px] mt-0.5">your face</span>
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="inline-flex items-center gap-1.5 text-[10px] font-medium tracking-wide bg-white/15 px-2 py-0.5 rounded-full">
<Fingerprint className="w-3 h-3" /> KROW Identity
</div>
<h1 className="text-[22px] sm:text-[26px] font-bold leading-tight mt-1.5 truncate">
{form.full_name.trim() || 'Your name'}
</h1>
<p className="text-[12px] opacity-80 mt-0.5 line-clamp-2">
{form.career_goals.trim() || 'What you do and what you\u2019re good at will appear here.'}
</p>
</div>
</div>
<div className="relative mt-4 space-y-2.5">
<SlotRow label="Skills" items={form.skills} />
<SlotRow label="Languages" items={form.languages} />
<SlotRow label="Availability" items={form.availability} />
<SlotRow label="Certifications" items={form.certifications} />
</div>
<div className="relative mt-4">
<div className="flex items-center justify-between text-[11px] mb-1.5">
<span className="inline-flex items-center gap-1 opacity-90"><Sparkles className="w-3 h-3 text-[#F9E547]" /> Your identity is {pct}% alive</span>
<span className="opacity-70">{done}/{parts.length}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-white/15 overflow-hidden">
<div className="h-full bg-[#F9E547] transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
</div>
</div>
);
}
function SlotRow({ label, items }) {
const filled = (items || []).length > 0;
return (
<div className="flex items-center gap-2 text-[11px]">
<span className="w-20 shrink-0 opacity-60">{label}</span>
{filled ? (
<div className="flex flex-wrap gap-1.5">
{items.slice(0, 4).map((it, i) => (
<span key={i} className="px-2 py-0.5 rounded-full bg-white/15 text-white/90">{it}</span>
))}
{items.length > 4 && <span className="px-1.5 opacity-70">+{items.length - 4}</span>}
</div>
) : (
<span className="px-2 py-0.5 rounded-full border border-dashed border-white/30 text-white/50">add {label.toLowerCase()}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
// Apple-style focal statement: a quiet vertical list beside a single thin divider.
export default function KrowSays() {
const lines = ['Learn this.', 'Prove it.', 'Get hired.'];
return (
<div>
<p className="text-[13px] font-medium text-[#333333] mb-6">KROW says</p>
<div className="relative pl-6">
<div className="absolute left-0 top-2 bottom-2 w-px bg-[#CCCCCC]" />
<ul className="space-y-5 sm:space-y-6">
{lines.map((l) => (
<li
key={l}
className="text-[26px] sm:text-[34px] font-semibold tracking-tight text-[#333333] leading-tight"
>
{l}
</li>
))}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
export default function KrowScoreRing({ score = 0, size = 64, label = 'KROW Score' }) {
const radius = (size - 8) / 2;
const circ = 2 * Math.PI * radius;
const offset = circ - (Math.min(100, score) / 100) * circ;
const color = score >= 80 ? '#0A39DF' : score >= 60 ? '#333F48' : score >= 40 ? '#6B7280' : '#EF4444';
return (
<div className="flex flex-col items-center">
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="#E5E7EB" strokeWidth="5" />
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={color} strokeWidth="5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
</svg>
<span className="absolute font-bold text-[#111827]" style={{ fontSize: size * 0.28 }}>{score}</span>
</div>
{label && <span className="text-[11px] text-[#6B7280] mt-1">{label}</span>}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Route } from 'lucide-react';
export default function LearningPathCard({ path, completedIds = [] }) {
const steps = path.steps || [];
const done = steps.filter(s => completedIds.has(s.course_id)).length;
const pct = steps.length ? Math.round((done / steps.length) * 100) : 0;
return (
<Link to="/university" className="block bg-white border border-[#E5E7EB] rounded-xl p-5 hover:border-[#0A39DF] hover:shadow-sm transition-all">
<div className="flex items-center gap-2 mb-1">
<div className="w-8 h-8 rounded-lg bg-[#0A39DF] flex items-center justify-center shrink-0"><Route className="w-4 h-4 text-white" /></div>
<h3 className="font-semibold text-[#111827] text-[15px]">{path.name}</h3>
</div>
<p className="text-[12px] text-[#6B7280] mb-3">{path.description || `Career track to ${path.target_role || path.name}`}</p>
<div className="flex flex-wrap items-center gap-1.5 mb-3">
{steps.flatMap((s, i) => [
<span key={`s${i}`} className={`text-[11px] px-2 py-0.5 rounded-full ${completedIds.has(s.course_id) ? 'bg-[#F0FDF4] text-[#166534]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>{s.course_title || `Step ${i + 1}`}</span>,
i < steps.length - 1 ? <ArrowRight key={`a${i}`} className="w-3 h-3 text-[#D1D5DB]" /> : null,
])}
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${pct}%` }} /></div>
<span className="text-[11px] font-medium text-[#6B7280]">{done}/{steps.length}</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Mail, Phone, Send, CheckCircle2 } from 'lucide-react';
export default function MessageCandidateModal({ open, onClose, application }) {
const [channel, setChannel] = useState('message');
const [message, setMessage] = useState('');
const [sent, setSent] = useState(false);
const handleSend = () => {
setSent(true);
setTimeout(() => {
setSent(false);
setMessage('');
onClose();
}, 1500);
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Message {application?.applicant_name}</DialogTitle>
</DialogHeader>
{sent ? (
<div className="py-8 text-center">
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
<CheckCircle2 className="w-6 h-6 text-[#059669]" />
</div>
<p className="text-[14px] font-medium text-[#111827]">Message sent!</p>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setChannel('message')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
channel === 'message' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
}`}
>
<Mail className="w-4 h-4" /> Message
</button>
<button
onClick={() => setChannel('call')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
channel === 'call' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
}`}
>
<Phone className="w-4 h-4" /> Call
</button>
</div>
{channel === 'call' ? (
<div className="py-6 text-center text-[13px] text-[#6B7280]">
<Phone className="w-8 h-8 text-[#0A39DF] mx-auto mb-2" />
Calling {application?.phone || 'candidate'}...
</div>
) : (
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={`Hi ${application?.applicant_name?.split(' ')[0]}, ...`}
className="w-full min-h-[100px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
/>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Send className="w-4 h-4 mr-2" /> Send
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,14 @@
import { Image } from '@/components/ui/image';
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/620b75f07_WhatsAppImage2026-07-31at35919PM.jpeg';
export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }) {
return (
<Image
src={OWLIVER_URL}
alt="Owliver"
fittingType="fill"
className={`shrink-0 overflow-hidden ${rounded} ${className}`}
/>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Image } from '@/components/ui/image';
import { Sparkles, X } from 'lucide-react';
import Owliver from '@/pages/Owliver';
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/2005ea00e_WhatsAppImage2026-07-31at35919PM.jpeg';
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }) {
const [internalOpen, setInternalOpen] = useState(false);
const open = externalOpen !== undefined ? externalOpen : internalOpen;
const setOpen = (v) => {
if (onOpenChange) onOpenChange(v);
setInternalOpen(v);
};
return (
<div className="fixed bottom-6 right-6 z-50">
<AnimatePresence mode="wait">
{!open && showTrigger ? (
<motion.button
key="pill"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12, scale: 0.96 }}
transition={{ duration: 0.3 }}
onClick={() => setOpen(true)}
aria-label="Ask Owliver"
className="group flex items-center gap-3 rounded-full bg-white pl-5 pr-2 py-2 w-[min(80vw,360px)]
shadow-[0_10px_30px_rgba(10,57,223,0.22),0_2px_6px_rgba(51,63,72,0.08)]
ring-1 ring-black/5 transition-transform duration-300 hover:scale-[1.01]"
>
<Sparkles className="w-4 h-4 text-[#0A39DF] shrink-0" />
<span className="text-[15px] text-[#B5B8D3] font-medium flex-1 truncate text-left group-hover:text-[#0A39DF] transition-colors">
Build your profile
</span>
<span className="relative block w-12 h-12 rounded-full overflow-hidden ring-2 ring-[#0A39DF]/15 shadow-[0_4px_12px_rgba(10,57,223,0.35)] shrink-0">
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
</span>
</motion.button>
) : open ? (
<motion.div
key="panel"
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.96 }}
transition={{ duration: 0.28 }}
className="flex flex-col w-[min(94vw,400px)] h-[min(82vh,620px)] rounded-2xl bg-white overflow-hidden
shadow-[0_18px_50px_rgba(10,57,223,0.28),0_4px_12px_rgba(51,63,72,0.12)] ring-1 ring-black/5"
>
<div className="flex items-center gap-2.5 px-4 py-3 bg-[#0A39DF] text-white shrink-0">
<span className="block w-8 h-8 rounded-full overflow-hidden ring-2 ring-white/40">
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
</span>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-bold leading-tight flex items-center gap-1.5">
Owliver <Sparkles className="w-3.5 h-3.5 text-[#F9E547]" />
</div>
<div className="text-[11px] text-white/80 leading-tight">Online · ready to chat</div>
</div>
<button onClick={() => setOpen(false)} aria-label="Close chat" className="w-8 h-8 rounded-full hover:bg-white/20 flex items-center justify-center transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto bg-[#F9FAFB] p-4">
<div className="max-w-none h-full [&>div]:max-w-none [&>div]:mx-0 [&>div]:h-full [&>div]:flex [&>div]:flex-col">
<Owliver compact />
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
export default function OwliverCoachCard({ nextCourse, matchCount, avgPay }) {
return (
<section className="py-12 sm:py-16 border-t border-[#F1F5F9]">
<div className="flex flex-col items-center text-center max-w-xl mx-auto">
<OwliverAvatar className="w-14 h-14" rounded="rounded-full" />
<h2 className="text-[12px] font-medium text-[#94A3B8] uppercase tracking-wider mt-4">Owliver · AI Coach</h2>
<p className="text-[18px] sm:text-[22px] font-medium text-[#0F172A] mt-3 leading-snug">
{nextCourse ? (
<>
Complete <span className="text-[#0A39DF]">{nextCourse.title}</span> to unlock stronger matches.
</>
) : (
<>Keep building your profile and I'll map your fastest path forward.</>
)}
</p>
<p className="text-[13px] text-[#64748B] mt-3">
{matchCount > 0
? `${matchCount} open roles match you now${avgPay ? ` · avg ${avgPay}` : ''}`
: 'No matches yet — keep learning.'}
</p>
{nextCourse && (
<Link
to={`/university/${nextCourse.id}`}
className="mt-7 inline-flex items-center gap-1.5 bg-[#0A39DF] text-white text-[14px] font-medium px-6 py-3 rounded-full hover:bg-[#082EB4] transition-colors"
>
Start Learning <ArrowRight className="w-4 h-4" />
</Link>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { cn } from '@/lib/utils';
const STAGES = [
{ key: 'applied', label: 'Applied', color: 'bg-[#D1E0D7]', text: 'text-[#333F48]' },
{ key: 'ai_screened', label: 'AI Screened', color: 'bg-[#F8E08E]', text: 'text-[#333F48]' },
{ key: 'shortlisted', label: 'Shortlisted', color: 'bg-[#F9E547]', text: 'text-[#0A39DF]' },
{ key: 'interview', label: 'Interviewed', color: 'bg-[#333F48]', text: 'text-white' },
{ key: 'hired', label: 'Hired', color: 'bg-[#0A39DF]', text: 'text-white' },
];
export default function PipelineFunnel({ applications }) {
const counts = STAGES.map((s) => {
if (s.key === 'ai_screened') {
return applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status)).length;
}
if (s.key === 'shortlisted') {
return applications.filter(a => ['shortlisted', 'interview', 'hired'].includes(a.status)).length;
}
if (s.key === 'interview') {
return applications.filter(a => ['interview', 'hired'].includes(a.status)).length;
}
return applications.filter(a => a.status === s.key).length;
});
const max = Math.max(...counts, 1);
const total = counts[0] || 1;
return (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">Pipeline Stages</h3>
<p className="text-[12px] text-[#6B7280] mb-5">Candidate progression through hiring funnel</p>
<div className="space-y-4">
{STAGES.map((stage, i) => {
const count = counts[i];
const width = (count / max) * 100;
const convRate = i === 0 ? 100 : Math.round((count / total) * 100);
const stepRate = i === 0 ? 100 : (counts[i - 1] ? Math.round((count / counts[i - 1]) * 100) : 0);
return (
<div key={stage.key}>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[#374151]">{stage.label}</span>
{i > 0 && stepRate <= 100 && (
<span className="text-[10px] text-[#9CA3AF]">→ {stepRate}% from prev</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] text-[#9CA3AF]">{convRate}% of total</span>
<span className="text-[15px] font-bold text-[#111827] w-8 text-right">{count}</span>
</div>
</div>
<div className="h-7 bg-[#F3F4F6] rounded-lg overflow-hidden">
<div
className={cn('h-full rounded-lg flex items-center px-3 transition-all duration-700', stage.color)}
style={{ width: `${Math.max(width, count > 0 ? 6 : 0)}%` }}
>
<span className={cn('text-[11px] font-semibold', stage.text)}>{count > 0 ? count : ''}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Star, BadgeCheck, X, Award } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useUpdateStaff } from '@/lib/krowHooks';
const SKILL_SUGGESTIONS = [
'Punctuality', 'Teamwork', 'Communication', 'Reliability',
'Quality of Work', 'Leadership', 'Adaptability', 'Safety Awareness',
'Customer Service', 'Work Ethic',
];
export default function RateEndorseModal({ open, onClose, staff, reviewerName }) {
const update = useUpdateStaff();
const [rating, setRating] = useState(staff?.client_rating || 0);
const [hover, setHover] = useState(0);
const [text, setText] = useState(staff?.endorsement_text || '');
const [skills, setSkills] = useState(staff?.endorsed_skills || []);
React.useEffect(() => {
if (open) {
setRating(staff?.client_rating || 0);
setText(staff?.endorsement_text || '');
setSkills(staff?.endorsed_skills || []);
}
}, [open, staff]);
if (!staff) return null;
const toggleSkill = (s) => {
setSkills((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
};
const submit = async () => {
if (rating === 0) {
toast.error('Please select a star rating');
return;
}
try {
await update.mutateAsync({
id: staff.id,
data: {
client_rating: rating,
endorsement_text: text.trim(),
endorsed_skills: skills,
review_date: new Date().toISOString().split('T')[0],
reviewer_name: reviewerName || '',
},
});
toast.success(`Rated ${staff.name}`);
onClose();
} catch (e) {
toast.error('Failed to save rating');
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md p-0">
<DialogHeader className="sr-only">
<DialogTitle>Rate &amp; Endorse {staff.name}</DialogTitle>
</DialogHeader>
<div className="p-5 space-y-5">
<div className="flex items-start justify-between">
<div>
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">Rate &amp; Endorse</p>
<h2 className="text-[18px] font-bold text-[#111827] mt-0.5">{staff.name}</h2>
<p className="text-[12px] text-[#6B7280] mt-0.5">{staff.role || 'Staff'} {staff.profile_tier && `· ${staff.profile_tier}`}</p>
</div>
<button onClick={onClose} className="text-[#9CA3AF] hover:text-[#111827]">
<X className="w-5 h-5" />
</button>
</div>
{/* Star rating */}
<div>
<p className="text-[11px] font-semibold text-[#374151] mb-2">Overall Rating</p>
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => setRating(n)}
className="p-0.5"
>
<Star
className={`w-7 h-7 ${(hover || rating) >= n ? 'text-[#F59E0B] fill-[#F59E0B]' : 'text-[#E5E7EB]'}`}
/>
</button>
))}
<span className="ml-2 text-[13px] font-semibold text-[#111827]">{rating > 0 ? `${rating}.0` : '—'}</span>
</div>
</div>
{/* Endorsement text */}
<div>
<p className="text-[11px] font-semibold text-[#374151] mb-2">Endorsement Note</p>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
placeholder="Share what made them great to work with..."
className="w-full rounded-lg border border-[#E5E7EB] bg-white px-3 py-2 text-[13px] focus:border-[#0A39DF] focus:outline-none resize-none"
/>
</div>
{/* Endorsed skills */}
<div>
<p className="text-[11px] font-semibold text-[#374151] mb-2">Endorsed Skills</p>
<div className="flex flex-wrap gap-1.5">
{SKILL_SUGGESTIONS.map((s) => {
const on = skills.includes(s);
return (
<button
key={s}
type="button"
onClick={() => toggleSkill(s)}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-colors ${
on
? 'bg-[#F0F4F8] text-[#1E56C1] border-[#1E56C1]'
: 'bg-white text-[#6B7280] border-[#E5E7EB] hover:border-[#1E56C1]'
}`}
>
{on && <BadgeCheck className="w-3 h-3" />}
{s}
</button>
);
})}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-[#F3F4F6]">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg border border-[#E5E7EB] text-[#374151] text-[13px] font-medium hover:bg-[#F9FAFB]"
>
Cancel
</button>
<button
onClick={submit}
disabled={update.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[13px] font-medium"
>
<Award className="w-4 h-4" />
{update.isPending ? 'Saving...' : 'Save Rating'}
</button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react';
const COMPANY_COLORS = ['bg-[#0A39DF]', 'bg-[#333F48]', 'bg-[#082EB4]'];
const colorFor = (name) => {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length];
};
const SUPERVISOR_NAMES = ['Luis', 'Chef Antonio', 'Maria', 'David', 'Sofia'];
const ROLES = ['Supervisor', 'Chef', 'Manager', 'Lead', 'Owner'];
// Build endorsement entries. Prefers real companies the candidate worked at;
// falls back to seeded references derived from their strengths so the employer
// always sees verbatim-style feedback.
function buildVoices(application) {
const companies = (application?.companies_worked || []).filter(Boolean);
const strengths = (application?.ai_strengths || []).filter(Boolean);
const years = application?.years_experience || 0;
const source = companies.length > 0
? companies.slice(0, 3)
: strengths.slice(0, 3).map((s) => 'Verified Reference');
if (source.length === 0) return [];
return source.map((c, i) => {
const name = SUPERVISOR_NAMES[i % SUPERVISOR_NAMES.length];
const role = ROLES[i % ROLES.length];
const events = 8 + i * 17 + years;
const quote = strengths[i]
? `${strengths[i]}. Highly recommend for any team.`
: `Reliable, punctual, and a great team player. Would hire again.`;
return { id: i, name, role, events, company: c, quote };
});
}
function Stars({ count = 5 }) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className={`w-3.5 h-3.5 ${i < count ? 'text-[#FF9800] fill-[#FF9800]' : 'text-[#E5E7EB]'}`} />
))}
</div>
);
}
export default function ReferencesEndorsements({ application }) {
const [open, setOpen] = useState(true);
const voices = buildVoices(application);
const strengths = [
...(application?.ai_strengths || []),
...(application?.skills || []),
].filter(Boolean).slice(0, 6);
if (voices.length === 0 && strengths.length === 0) return null;
const count = voices.length + strengths.length;
return (
<div className="mt-3 pt-3 border-t border-[#E5E7EB]/80">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between gap-2 mb-1"
>
<span className="text-[10px] font-bold text-[#9CA3AF] tracking-wider flex items-center gap-1.5">
<Quote className="w-3 h-3" /> ENDORSEMENTS
<span className="text-[#D1D5DB] font-semibold">({count})</span>
</span>
<ChevronDown className={`w-3.5 h-3.5 text-[#9CA3AF] transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
{open && (
<div className="space-y-3 pt-1">
{voices.length > 0 && (
<div className="space-y-2">
{voices.map((v) => (
<div key={v.id} className="bg-white rounded-xl border border-[#E5E7EB] px-3 py-2.5">
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-full ${colorFor(v.company)} flex items-center justify-center text-white text-[11px] font-bold shrink-0`}>
{v.name.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-bold text-[#111827] truncate">{v.name}</p>
<p className="text-[11px] text-[#6B7280]">{v.role} · {v.events} events</p>
</div>
<Stars count={5} />
</div>
<p className="text-[12px] text-[#374151] mt-2 leading-relaxed">"{v.quote}"</p>
<div className="flex items-center gap-2 mt-2.5">
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
Request Reference
</button>
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
Endorse Skill
</button>
</div>
</div>
))}
</div>
)}
{strengths.length > 0 && (
<div>
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
<BadgeCheck className="w-3 h-3" /> STRENGTHS
</p>
<div className="flex flex-wrap gap-2">
{strengths.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
<BadgeCheck className="w-3 h-3" />
{typeof s === 'string' ? s : s.skill || s}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,102 @@
import React, { useMemo } from 'react';
import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react';
const MS_PER_DAY = 1000 * 60 * 60 * 24;
function tenureDays(hireDate, status, now) {
if (!hireDate) return 0;
const start = new Date(hireDate).getTime();
const end = status === 'inactive' && now ? now : Date.now();
return Math.max(0, Math.floor((end - start) / MS_PER_DAY));
}
export default function RetentionMetrics({ staff }) {
const stats = useMemo(() => {
const total = staff.length;
const active = staff.filter((s) => s.status === 'active').length;
const onboarding = staff.filter((s) => s.status === 'onboarding').length;
const churned = staff.filter((s) => s.status === 'inactive').length;
// Retention rate: of hires whose outcome is decided (active + inactive),
// what share are still active. Onboarding hires are excluded from the denominator
// since they haven't had time to churn.
const decided = active + churned;
const retentionRate = decided > 0 ? Math.round((active / decided) * 100) : null;
// Cohort retention: % of hires still on board after N days from hire_date.
// A hire counts as retained past day N if (status !== 'inactive') OR (tenure >= N before churning).
const now = Date.now();
const withHireDate = staff.filter((s) => s.hire_date);
const cohort = (days) => {
if (withHireDate.length === 0) return null;
const retained = withHireDate.filter((s) => {
const tenure = tenureDays(s.hire_date, s.status, now);
if (s.status === 'inactive') return tenure >= days;
return true; // still active/onboarding — retained
}).length;
return Math.round((retained / withHireDate.length) * 100);
};
const avgTenure = withHireDate.length > 0
? Math.round(withHireDate.reduce((sum, s) => sum + tenureDays(s.hire_date, s.status, now), 0) / withHireDate.length)
: 0;
return { total, active, onboarding, churned, retentionRate, c30: cohort(30), c60: cohort(60), c90: cohort(90), avgTenure };
}, [staff]);
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Metric
icon={<TrendingUp className="w-4 h-4" />}
label="Retention Rate"
value={stats.retentionRate == null ? '—' : `${stats.retentionRate}%`}
tone={stats.retentionRate == null ? 'neutral' : stats.retentionRate >= 70 ? 'good' : stats.retentionRate >= 40 ? 'warn' : 'bad'}
sub={`${stats.active} active · ${stats.churned} churned`}
/>
<Metric
icon={<Activity className="w-4 h-4" />}
label="Still Active"
value={stats.active}
tone="good"
sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'}
/>
<Metric
icon={<TrendingDown className="w-4 h-4" />}
label="Churned"
value={stats.churned}
tone={stats.churned === 0 ? 'good' : 'bad'}
sub={`${stats.total} total hires`}
/>
<Metric
icon={<Clock className="w-4 h-4" />}
label="Avg Tenure"
value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'}
tone="neutral"
sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`}
/>
</div>
);
}
const TONES = {
good: { bg: 'bg-[#ECFDF5]', text: 'text-[#047857]', icon: 'bg-[#047857]' },
warn: { bg: 'bg-[#FFF7ED]', text: 'text-[#B45309]', icon: 'bg-[#B45309]' },
bad: { bg: 'bg-[#FEF2F2]', text: 'text-[#B91C1C]', icon: 'bg-[#B91C1C]' },
neutral: { bg: 'bg-[#F0F4F8]', text: 'text-[#1E56C1]', icon: 'bg-[#1E56C1]' },
};
function Metric({ icon, label, value, sub, tone = 'neutral' }) {
const t = TONES[tone] || TONES.neutral;
return (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${t.bg}`}>
<span className={t.text}>{icon}</span>
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold text-[#6B7280] tracking-wide">{label}</p>
<p className="text-[20px] font-bold text-[#111827] leading-tight mt-0.5">{value}</p>
<p className="text-[10px] text-[#9CA3AF] mt-0.5 truncate">{sub}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Briefcase, User } from 'lucide-react';
import { cn } from '@/lib/utils';
const ROLES = [
{ value: 'recruiter', label: 'Recruiter / Admin', desc: 'Hire and manage candidates', icon: Briefcase },
{ value: 'applicant', label: 'Applicant', desc: 'Apply to open positions', icon: User },
];
export default function RoleSelector({ value, onChange }) {
return (
<div className="grid grid-cols-2 gap-3">
{ROLES.map((r) => {
const Icon = r.icon;
const active = value === r.value;
return (
<button
key={r.value}
type="button"
onClick={() => onChange(r.value)}
className={cn(
'p-4 rounded-xl border-2 text-left transition-all',
active ? 'border-[#0A39DF] bg-[#EFF6FF]' : 'border-[#E5E7EB] hover:border-[#D1D5DB]'
)}
>
<Icon className={cn('w-5 h-5 mb-2', active ? 'text-[#0A39DF]' : 'text-[#9CA3AF]')} />
<p className={cn('text-[13px] font-semibold', active ? 'text-[#1E40AF]' : 'text-[#374151]')}>{r.label}</p>
<p className="text-[11px] text-[#6B7280] mt-0.5">{r.desc}</p>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Calendar, Clock, Video, Phone, User } from 'lucide-react';
export default function ScheduleInterviewModal({ open, onClose, application }) {
const [type, setType] = useState('video');
const [date, setDate] = useState('');
const [time, setTime] = useState('');
const [notes, setNotes] = useState('');
const [scheduled, setScheduled] = useState(false);
const handleSchedule = () => {
setScheduled(true);
setTimeout(() => {
setScheduled(false);
onClose();
}, 1500);
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Schedule Interview</DialogTitle>
</DialogHeader>
{scheduled ? (
<div className="py-8 text-center">
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
<Calendar className="w-6 h-6 text-[#059669]" />
</div>
<p className="text-[14px] font-medium text-[#111827]">Interview scheduled!</p>
<p className="text-[12px] text-[#6B7280]">{application?.applicant_name} will be notified.</p>
</div>
) : (
<div className="space-y-4">
<div>
<Label className="text-[13px] text-[#374151]">Interview Type</Label>
<div className="grid grid-cols-3 gap-2 mt-1.5">
{[
{ value: 'video', label: 'Video', icon: Video },
{ value: 'phone', label: 'Phone', icon: Phone },
{ value: 'inperson', label: 'In Person', icon: User },
].map((opt) => (
<button
key={opt.value}
onClick={() => setType(opt.value)}
className={`flex flex-col items-center gap-1 py-2.5 rounded-lg border text-[12px] font-medium transition-all ${
type === opt.value ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB]'
}`}
>
<opt.icon className="w-4 h-4" />
{opt.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[13px] text-[#374151]">Date</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="mt-1.5" />
</div>
<div>
<Label className="text-[13px] text-[#374151]">Time</Label>
<Input type="time" value={time} onChange={(e) => setTime(e.target.value)} className="mt-1.5" />
</div>
</div>
<div>
<Label className="text-[13px] text-[#374151]">Notes (optional)</Label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any prep notes..."
className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Clock className="w-4 h-4 mr-2" /> Schedule
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,67 @@
import React, { useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
const TIERS = [
{ label: '0-39', range: [0, 39], color: '#333F48' },
{ label: '40-59', range: [40, 59], color: '#D1E0D7' },
{ label: '60-79', range: [60, 79], color: '#F8E08E' },
{ label: '80-100', range: [80, 100], color: '#0A39DF' },
];
export default function ScoreDistribution({ applications }) {
const data = useMemo(() => {
const scored = applications.filter(a => a.ai_score > 0);
return TIERS.map((tier) => ({
label: tier.label,
count: scored.filter(a => a.ai_score >= tier.range[0] && a.ai_score <= tier.range[1]).length,
color: tier.color,
}));
}, [applications]);
const avgScore = useMemo(() => {
const scored = applications.filter(a => a.ai_score > 0);
return scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
}, [applications]);
const screened = applications.filter(a => a.ai_score > 0).length;
return (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between mb-1">
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Candidate Scores</h3>
<div className="text-right">
<div className="text-[22px] font-heading font-bold gradient-text">{avgScore}</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide">AVG SCORE</div>
</div>
</div>
<p className="text-[12px] text-[#6B7280] mb-5">{screened} candidates screened</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} stroke="#9CA3AF" />
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<Tooltip
cursor={{ fill: '#F9FAFB' }}
contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }}
/>
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
{data.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="grid grid-cols-4 gap-2 mt-4">
{TIERS.map((t, i) => (
<div key={t.label} className="text-center">
<div className="w-full h-1.5 rounded-full mb-1" style={{ background: t.color }} />
<div className="text-[11px] text-[#6B7280]">{t.label}</div>
<div className="text-[14px] font-bold text-[#111827]">{data[i].count}</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
// Skill levels: Claimed → Practiced → Verified → Expert → Elite
const LEVELS = [
{ name: 'Claimed', fill: 35, bar: 'bg-[#CBD5E1]', text: 'text-[#94A3B8]' },
{ name: 'Practiced', fill: 62, bar: 'bg-[#93C5FD]', text: 'text-[#3B82F6]' },
{ name: 'Verified', fill: 84, bar: 'bg-[#0A39DF]', text: 'text-[#0A39DF]' },
{ name: 'Expert', fill: 94, bar: 'bg-[#6366F1]', text: 'text-[#6366F1]' },
{ name: 'Elite', fill: 100, bar: 'bg-[#F9E547]', text: 'text-[#92400E]' },
];
function levelFor(skill, badges, courses) {
const s = skill.toLowerCase();
const certHits = (badges || []).filter((b) => (b.name || '').toLowerCase().includes(s)).length;
const courseHits = (courses || []).filter((c) => (c.title || '').toLowerCase().includes(s)).length;
const score = certHits * 2 + courseHits;
if (score >= 6) return 4;
if (score >= 3) return 3;
if (certHits >= 1) return 2;
if (courseHits >= 1) return 1;
return 0;
}
export default function SkillDNA({ profile }) {
const skills = profile?.skills || [];
const badges = profile?.earned_badges || [];
const courses = profile?.completed_courses || [];
return (
<section className="py-12 sm:py-16 border-t border-[#F1F5F9]">
<h2 className="text-[22px] sm:text-[26px] font-semibold text-[#0F172A] tracking-tight">Skill DNA</h2>
<p className="text-[14px] text-[#64748B] mt-2 max-w-md">
Every shift updates these bars. Employers trust them.
</p>
{!skills.length ? (
<p className="text-[14px] text-[#94A3B8] mt-8">
Add skills to your profile to start tracking your Skill DNA.
</p>
) : (
<div className="mt-8 space-y-5 max-w-2xl">
{skills.map((sk, i) => {
const lvl = LEVELS[levelFor(sk, badges, courses)];
return (
<div key={i}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[14px] font-medium text-[#0F172A]">{sk}</span>
<span className={`text-[12px] font-medium ${lvl.text}`}>{lvl.name}</span>
</div>
<div className="h-2 rounded-full bg-[#F1F5F9] overflow-hidden">
<div
className={`h-full ${lvl.bar} rounded-full transition-all duration-500`}
style={{ width: `${lvl.fill}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,161 @@
import React, { useState, useEffect } from 'react';
import { useWorkerProfiles, useMatchTalentForJob } from '@/lib/krowHooks';
import { Sparkles, Loader2, Briefcase, Star } from 'lucide-react';
import { Image } from '@/components/ui/image';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import TalentDetailModal from '@/components/krow/TalentDetailModal';
const PORTRAIT_PHOTOS = [
'photo-1500648767731-7d3514ad8b31',
'photo-1531123897727-8f129e1688ce',
'photo-1573496359142-b8d87734a5a2',
'photo-1487412720507-e7ab37603c6f',
'photo-1502685104226-ee3a97a2f2c8',
'photo-1544005313-94ddf0286df2',
'photo-1494790108377-be9c29b29330',
'photo-1438761681033-6461ffad8d80',
'photo-1517841905240-472988babdf9',
'photo-1463453091185-61582044d556',
];
function getPortraitSeed(name) {
let h = 0;
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
}
const MATCH_LABEL_STYLES = {
'Strong Match': 'bg-[#EFF6FF] text-[#0A39DF]',
'Good Match': 'bg-[#F0FDF4] text-[#16A34A]',
'Possible Fit': 'bg-[#FFFBEB] text-[#D97706]',
'Weak Fit': 'bg-[#F3F4F6] text-[#6B7280]',
};
const RECO_STYLES = {
Hire: 'bg-[#16A34A] text-white',
Interview: 'bg-[#0A39DF] text-white',
Maybe: 'bg-[#D97706] text-white',
Pass: 'bg-[#9CA3AF] text-white',
};
export default function SuggestedTalent({ job, onContact, onSchedule, onHire }) {
const { data: profiles = [] } = useWorkerProfiles();
const matchTalent = useMatchTalentForJob();
const [matches, setMatches] = useState([]);
const [selected, setSelected] = useState(null);
// Auto-run matching when the job is published and there are profiles
useEffect(() => {
if (!job || profiles.length === 0) return;
if (job.status !== 'active') return;
let cancelled = false;
(async () => {
try {
const res = await matchTalent.mutateAsync({ job, profiles });
if (!cancelled && res?.matches) setMatches(res.matches);
} catch (e) {
if (!cancelled) setMatches([]);
}
})();
return () => { cancelled = true; };
}, [job?.id, job?.status, profiles.length]);
const profileById = Object.fromEntries(profiles.map((p) => [p.id, p]));
const enriched = matches
.map((m) => ({ ...m, profile: profileById[m.profile_id] }))
.filter((m) => m.profile)
.slice(0, 6);
const runMatch = () => {
if (profiles.length === 0) return;
matchTalent.mutate({ job, profiles }).then((res) => setMatches(res?.matches || [])).catch(() => setMatches([]));
};
return (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
</div>
<h3 className="text-[15px] font-semibold text-[#111827]">Suggested Talent</h3>
</div>
{matches.length > 0 && (
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0A39DF] hover:underline disabled:opacity-50">
{matchTalent.isPending ? 'Re-matching…' : 'Re-run match'}
</button>
)}
</div>
<p className="text-[12px] text-[#6B7280] mb-4">Don't wait for applications — KROW matched these talents from the pool against your job. Tap a profile to see the full KROW Identity.</p>
{matchTalent.isPending && matches.length === 0 ? (
<div className="flex items-center justify-center py-10 gap-2 text-[#6B7280]">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-[13px]">Matching talent from the pool…</span>
</div>
) : enriched.length === 0 ? (
<div className="text-center py-10">
<p className="text-[13px] text-[#6B7280]">No strong matches yet. Try widening your requirements, or re-run the match.</p>
</div>
) : (
<div className="space-y-3">
{enriched.map((m) => {
const p = m.profile;
const band = getScoreBand(p.krow_score || 0);
return (
<div key={m.profile_id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E5E7EB] hover:border-[#0A39DF] hover:bg-[#FAFBFF] transition-colors cursor-pointer" onClick={() => setSelected(p)}>
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
<Image
src={p.selfie_url || `https://images.unsplash.com/${getPortraitSeed(p.full_name)}?w=120&h=120&fit=crop&crop=faces`}
alt={p.full_name}
className="w-full h-full object-cover"
fittingType="fill"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-[#111827] text-[14px] truncate">{p.full_name}</span>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${RECO_STYLES[m.recommendation] || RECO_STYLES.Maybe}`}>{m.recommendation}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium text-white" style={{ backgroundColor: band.color }}>{band.label}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
{(p.desired_position || p.current_position) && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<Briefcase className="w-3 h-3" />{p.desired_position || p.current_position}
</span>
)}
{p.experience_years > 0 && <span>{p.experience_years}yr exp</span>}
<span className="inline-flex items-center gap-1 text-[#F59E0B] font-medium">
<Star className="w-3 h-3 fill-[#F59E0B]" />Career {toFICO(p.krow_score || 0)}
</span>
</div>
{m.reasons?.length > 0 && (
<ul className="mt-1.5 text-[11px] text-[#6B7280] list-disc list-inside space-y-0.5">
{m.reasons.slice(0, 2).map((r, i) => <li key={i}>{r}</li>)}
</ul>
)}
</div>
<div className="flex flex-col items-center justify-center shrink-0 px-2">
<span className="text-[20px] font-bold text-[#0A39DF] leading-none">{Math.round(m.match_score)}</span>
<span className="text-[9px] text-[#9CA3AF] tracking-wide">MATCH</span>
</div>
</div>
);
})}
</div>
)}
{selected && (
<TalentDetailModal
open={true}
onClose={() => setSelected(null)}
profile={selected}
onContact={(profile) => { setSelected(null); onContact?.(profile); }}
onSchedule={(profile) => { setSelected(null); onSchedule?.(profile); }}
onHire={(profile) => { setSelected(null); onHire?.(profile); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,322 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Mail, Phone, Briefcase, Star, Award, BadgeCheck, Fingerprint, X, Calendar, UserCheck, Sparkles, Brain, MessageSquare } from 'lucide-react';
import { Image } from '@/components/ui/image';
import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge';
import ExperienceEndorsements, { verifiedExperience } from '@/components/krow/talent/ExperienceEndorsements';
import EliteTalentScore from '@/components/krow/talent/EliteTalentScore';
const PORTRAIT_PHOTOS = [
'photo-1500648767731-7d3514ad8b31',
'photo-1531123897727-8f129e1688ce',
'photo-1573496359142-b8d87734a5a2',
'photo-1487412720507-e7ab37603c6f',
'photo-1502685104226-ee3a97a2f2c8',
'photo-1544005313-94ddf0286df2',
'photo-1494790108377-be9c29b29330',
'photo-1438761681033-6461ffad8d80',
'photo-1517841905240-472988babdf9',
'photo-1463453091185-61582044d556',
];
function getPortraitSeed(name) {
let h = 0;
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
}
function getInitials(name) {
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
}
function getKrowIdCode(profile) {
if (profile?.id) {
const hex = profile.id.replace(/[^a-f0-9]/gi, '');
if (hex.length >= 6) return hex.slice(0, 6).toUpperCase();
}
let h = 0;
const seed = profile?.full_name || 'KROW';
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
return (h & 0xFFFFFF).toString(16).padStart(6, '0').toUpperCase();
}
function formatDate(d) {
try {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return ''; }
}
const BADGE_LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
};
function Section({ label, children }) {
if (!children) return null;
return (
<div>
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">{label}</p>
{children}
</div>
);
}
function PillList({ items, className = 'bg-[#F0F4F8] text-[#0047AB]' }) {
if (!items || items.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
{items.map((it, i) => (
<span key={i} className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${className}`}>{it}</span>
))}
</div>
);
}
function ScoreFactor({ label, value }) {
return (
<div className="inline-flex items-center justify-between gap-2 px-3 py-1.5 rounded-full border border-[#E5E7EB] bg-white">
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
<span className="text-[12px] font-bold text-[#2563EB]">{value}</span>
</div>
);
}
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
if (!profile) return null;
const badges = profile.earned_badges || [];
const capabilities = (profile.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
const experience = profile.experience || [];
const strengths = profile.strengths || [];
const verifiedExperienceList = verifiedExperience(profile).length > 0;
const krowCode = getKrowIdCode(profile);
const reportDate = formatDate(profile.updated_date || new Date());
const openToWork = profile.status === 'active' && (profile.availability?.length > 0 || !profile.current_position);
const reliability = Math.round(profile.reliability_score || 0);
const attendance = Math.round(profile.attendance_score || 0);
const performance = Math.round(profile.performance_score || 0);
const leadership = Math.round(profile.leadership_potential || 0);
const aiInterview = Math.round(profile.ai_interview_score || 0);
const xp = Math.round(profile.xp || 0);
const identityVerified = capabilities.length > 0 || badges.length > 0;
const identityTag = identityVerified ? 'Verified' : 'Pending';
const identityClass = identityVerified ? 'bg-[#EFF6FF] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
<DialogHeader className="sr-only">
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
</DialogHeader>
{/* KROW ID header — clean white, no blue band */}
<div className="relative p-5 sm:p-6 bg-gradient-to-b from-[#F5F7FB] to-white">
<button onClick={onClose} className="absolute top-4 right-4 text-[#9CA3AF] hover:text-[#111827]">
<X className="w-5 h-5" />
</button>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-full overflow-hidden ring-2 ring-[#E5E7EB] shrink-0 bg-[#E5E7EB] flex items-center justify-center">
{profile.selfie_url ? (
<Image
src={profile.selfie_url}
alt={profile.full_name}
className="w-full h-full object-cover"
fittingType="fill"
/>
) : (
<span className="text-[20px] font-bold text-[#1D4ED8]">{getInitials(profile.full_name)}</span>
)}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<div className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase">
<Fingerprint className="w-3 h-3" /> KROW ID · {krowCode}
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<h2 className="text-[22px] font-bold leading-tight text-[#111827] truncate">{profile.full_name}</h2>
{identityVerified && (
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${identityClass}`}>{identityTag}</span>
)}
{openToWork && (
<span className="text-[12px] text-[#6B7280]">· Open to work</span>
)}
</div>
<p className="text-[11px] text-[#6B7280] mt-1">
Career Report · {reportDate}
</p>
</div>
</div>
</div>
<div className="px-5 sm:px-6 pb-5 sm:pb-6 space-y-5">
{/* Quick stats banner — pill capsules, single line */}
<div className="flex flex-nowrap gap-1.5 overflow-x-auto no-scrollbar">
{profile.experience_years > 0 && (
<Stat icon={<Briefcase className="w-3 h-3" />} label="Experience" value={`${profile.experience_years} yr`} />
)}
{profile.shifts_completed > 0 && (
<Stat icon={<Briefcase className="w-3 h-3" />} label="Shifts" value={profile.shifts_completed} />
)}
{(profile.client_rating > 0 || profile.supervisor_rating > 0) && (
<Stat icon={<Star className="w-3 h-3" />} label="Rating" value={`${((profile.client_rating + profile.supervisor_rating) / 2).toFixed(1)}`} valueClass="text-[#F59E0B]" />
)}
{profile.desired_position && (
<Stat icon={<Briefcase className="w-3 h-3" />} label="Role" value={profile.desired_position} />
)}
</div>
{/* Match banner */}
{matchScore != null && (
<div className="rounded-xl border border-[#DBEAFE] bg-[#EFF6FF] p-4">
<div className="flex items-center gap-2 mb-1.5">
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
<span className="text-[13px] font-bold text-[#0A39DF]">Match Score: {matchScore}/100</span>
</div>
{matchReasons?.length > 0 && (
<ul className="text-[12px] text-[#374151] list-disc list-inside space-y-0.5">
{matchReasons.map((r, i) => <li key={i}>{r}</li>)}
</ul>
)}
</div>
)}
{/* Career Score */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl py-7 px-4 flex flex-col items-center">
<CareerScoreGauge profile={profile} />
</div>
{/* Elite Talent scorecard — dotted-leader metric list */}
<EliteTalentScore profile={profile} />
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4 space-y-4">
<Section label="SKILLS">
<PillList items={profile.skills} />
</Section>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-3 border-t border-[#F3F4F6]">
<Section label="LANGUAGES">
<PillList items={profile.languages} />
</Section>
<Section label="AVAILABILITY">
<PillList items={profile.availability} className="bg-[#F3F4F6] text-[#374151]" />
</Section>
<Section label="CERTIFICATIONS">
<PillList items={profile.certifications} className="bg-[#F3F4F6] text-[#374151]" />
</Section>
</div>
</div>
{/* Badges */}
{badges.length > 0 && (
<Section label="BADGES">
<div className="flex flex-wrap gap-2">
{badges.map((b, i) => (
<span key={i} className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium ${BADGE_LEVEL_STYLES[b.level] || BADGE_LEVEL_STYLES.bronze}`}>
<Award className="w-3.5 h-3.5" />
{b.name}
{b.level && <span className="opacity-70 text-[10px] capitalize">· {b.level}</span>}
</span>
))}
</div>
</Section>
)}
{/* Endorsements / Verified Capabilities */}
{capabilities.length > 0 && (
<Section label="VERIFIED CAPABILITIES (ENDORSEMENTS)">
<div className="flex flex-wrap gap-2">
{capabilities.map((c, i) => (
<span key={i} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] ${c.status === 'verified' ? 'bg-[#F0F4F8] text-[#1E56C1]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>
<BadgeCheck className="w-3.5 h-3.5" />
<span className="font-semibold">{c.skill}</span>
{c.level && <span className="text-[11px] text-[#5D759B]">· {c.level}</span>}
</span>
))}
</div>
</Section>
)}
{/* Consolidated Experience & Endorsements — jobs + client endorsements in one list */}
{verifiedExperience(profile).length > 0 && (
<Section label="EXPERIENCE & ENDORSEMENTS">
<ExperienceEndorsements profile={profile} />
</Section>
)}
{/* Personality + strengths */}
{(profile.personality || strengths.length > 0 || profile.communication_style) && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{profile.personality && (
<div className="flex gap-2.5">
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
<Brain className="w-3.5 h-3.5 text-[#0A39DF]" />
</span>
<div className="min-w-0">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">PERSONALITY</p>
<p className="text-[12px] text-[#374151] leading-relaxed">{profile.personality}</p>
</div>
</div>
)}
{profile.communication_style && (
<div className="flex gap-2.5">
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
<MessageSquare className="w-3.5 h-3.5 text-[#0A39DF]" />
</span>
<div className="min-w-0">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">COMMUNICATION STYLE</p>
<p className="text-[12px] text-[#374151] leading-relaxed">{profile.communication_style}</p>
</div>
</div>
)}
</div>
{strengths.length > 0 && (
<div className="pt-3 border-t border-[#F3F4F6]">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">STRENGTHS</p>
<PillList items={strengths} className="bg-[#EFF6FF] text-[#0A39DF]" />
</div>
)}
</div>
)}
{/* Contact info + actions */}
<div className="border-t border-[#E5E7EB] pt-4">
<div className="flex items-center gap-4 text-[12px] text-[#6B7280] flex-wrap mb-3">
{profile.email && <span className="inline-flex items-center gap-1"><Mail className="w-3.5 h-3.5" /> {profile.email}</span>}
{profile.phone && <span className="inline-flex items-center gap-1"><Phone className="w-3.5 h-3.5" /> {profile.phone}</span>}
</div>
<div className="flex items-center gap-2 flex-wrap">
{onContact && (
<button onClick={() => onContact(profile)} className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
<Mail className="w-4 h-4" /> Contact
</button>
)}
{onSchedule && (
<button onClick={() => onSchedule(profile)} className="inline-flex items-center gap-1.5 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
<Calendar className="w-4 h-4" /> Schedule Interview
</button>
)}
{onHire && (
<button onClick={() => onHire(profile)} className="inline-flex items-center gap-1.5 border border-[#16A34A] text-[#16A34A] hover:bg-[#16A34A] hover:text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
<UserCheck className="w-4 h-4" /> Quick Hire
</button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function Stat({ label, value, icon, valueClass = 'text-[#5D759B]' }) {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] shrink-0">
{icon}
<span className="text-[11px] font-semibold">{label}</span>
<span className="text-[11px] text-[#5D759B]">·</span>
<span className={`text-[11px] font-medium truncate max-w-[90px] ${valueClass}`}>{value}</span>
</span>
);
}

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Fingerprint, GraduationCap, Mic, BadgeCheck, Handshake, Briefcase,
LineChart, Star, TrendingUp, Crown, Presentation, Repeat, ArrowRight,
} from 'lucide-react';
// The Credit Bureau for Human Talent — the career flywheel.
const STEPS = [
{ key: 'identity', label: 'Create Identity', to: '/identity', icon: Fingerprint },
{ key: 'learn', label: 'Learn', to: '/university', icon: GraduationCap },
{ key: 'interview', label: 'Interview', to: '/owliver', icon: Mic },
{ key: 'verify', label: 'Verify Skills', to: '/identity', icon: BadgeCheck },
{ key: 'hired', label: 'Get Hired', to: '/apply', icon: Handshake },
{ key: 'work', label: 'Work', to: '/me', icon: Briefcase },
{ key: 'track', label: 'Track Performance', to: '/me', icon: LineChart },
{ key: 'reputation', label: 'Earn Reputation', to: '/me', icon: Star },
{ key: 'promoted', label: 'Get Promoted', to: '/university', icon: TrendingUp },
{ key: 'leader', label: 'Become Leader', to: '/university', icon: Crown },
{ key: 'train', label: 'Train Others', to: '/university', icon: Presentation },
{ key: 'repeat', label: 'Repeat', to: '/university', icon: Repeat, loop: true },
];
export function stepStatus(profile, apps = []) {
const hired = apps.some(a => a.status === 'hired');
const worked = (profile.experience || []).length > 0 || hired;
const isDone = {
identity: !!profile.selfie_url && (profile.skills || []).length > 0,
learn: (profile.completed_courses || []).length > 0,
interview: (profile.ai_interview_score || 0) > 0 || !!(profile.personality || profile.strengths?.length),
verify: (profile.certifications || []).length > 0 || (profile.earned_badges || []).length > 0,
hired,
work: worked,
track: (profile.performance_score || 0) > 0 || (profile.attendance_score || 0) > 0 && worked,
reputation: (profile.client_rating || 0) > 0 || (profile.supervisor_rating || 0) > 0 || (profile.krow_score || 0) >= 50,
promoted: (profile.leadership_potential || 0) >= 40,
leader: (profile.leadership_potential || 0) >= 70,
train: (profile.leadership_potential || 0) >= 85 && (profile.completed_courses || []).length >= 3,
repeat: false,
};
return isDone;
}
export default function TalentLoop({ profile, applications = [] }) {
const done = stepStatus(profile, applications);
const reached = STEPS.filter(s => done[s.key]).length;
const current = STEPS.find(s => !done[s.key]) || STEPS[STEPS.length - 1];
const progress = Math.round((reached / (STEPS.length - 1)) * 100);
return (
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
<div className="flex items-end justify-between flex-wrap gap-3 mb-1">
<div>
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#0A39DF]">The Talent Loop</span>
<h2 className="text-[22px] font-heading font-bold text-[#0F172A] mt-1 tracking-tight">Build your reputation. Not just your resume.</h2>
</div>
<span className="text-[12px] text-[#64748B]">{reached}/{STEPS.length - 1} stages</span>
</div>
<div className="h-1.5 bg-[#EEF2F7] rounded-full overflow-hidden mt-4 mb-7">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className="h-full bg-[#0A39DF] rounded-full"
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{STEPS.map((s, i) => {
const isDone = done[s.key];
const isCurrent = s.key === current.key && !s.loop;
const Icon = s.icon;
return (
<Link
key={s.key}
to={s.to}
className={`group relative flex flex-col items-start gap-2 rounded-xl border p-3 transition-all ${
isDone
? 'bg-[#0A39DF] border-[#0A39DF] text-white'
: isCurrent
? 'bg-white border-[#0A39DF] ring-2 ring-[#0A39DF]/20'
: 'bg-white border-[#E5E7EB] hover:border-[#0A39DF]/50'
}`}
>
<div className="flex items-center justify-between w-full">
<span className={`text-[10px] font-semibold ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`}>{String(i + 1).padStart(2, '0')}</span>
{s.loop && <Repeat className={`w-3.5 h-3.5 ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`} />}
</div>
<Icon className={`w-5 h-5 ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#64748B]'}`} />
<span className={`text-[12px] font-medium leading-tight ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#334155]'}`}>
{s.label}
</span>
</Link>
);
})}
</div>
<div className="flex items-center justify-between gap-3 mt-6 pt-5 border-t border-[#EEF2F7] flex-wrap">
<div>
<p className="text-[11px] text-[#94A3B8] font-medium">UP NEXT</p>
<p className="text-[15px] font-semibold text-[#0F172A]">{current.label}</p>
</div>
<Link
to={current.to}
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0A39DF] hover:gap-2.5 transition-all"
>
Continue <ArrowRight className="w-4 h-4" />
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,246 @@
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { Mail, Briefcase, MapPin, Star, BadgeCheck, Award, ChevronDown } from 'lucide-react';
import { Image } from '@/components/ui/image';
import EmployerInsights from '@/components/krow/talent/EmployerInsights';
import { getScoreBand, toFICO } from '@/lib/talentHome';
const PORTRAIT_PHOTOS = [
'photo-1500648767731-7d3514ad8b31',
'photo-1531123897727-8f129e1688ce',
'photo-1573496359142-b8d87734a5a2',
'photo-1487412720507-e7ab37603c6f',
'photo-1502685104226-ee3a97a2f2c8',
'photo-1544005313-94ddf0286df2',
'photo-1494790108377-be9c29b29330',
'photo-1438761681033-6461ffad8d80',
'photo-1517841905240-472988babdf9',
'photo-1463453091185-61582044d556',
];
function getPortraitSeed(name) {
let h = 0;
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
}
function CareerGauge({ fico }) {
const MIN = 300;
const MAX = 850;
const value = Math.max(MIN, Math.min(MAX, fico || MIN));
const frac = (value - MIN) / (MAX - MIN);
const cx = 55;
const cy = 58;
const r = 46;
const theta = (180 - frac * 180) * (Math.PI / 180);
const xv = cx + r * Math.cos(theta);
const yv = cy - r * Math.sin(theta);
const color = value > MIN ? '#0A39DF' : '#CBD5E1';
return (
<div className="flex flex-col items-center shrink-0">
<span className="text-[8px] font-semibold text-[#8A94A4] tracking-[0.12em] mb-0.5">CAREER SCORE</span>
<svg width="110" height="64" viewBox="0 0 110 64" className="overflow-visible">
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`} fill="none" stroke="#E1E4E8" strokeWidth="8" strokeLinecap="round" />
{value > MIN && (
<>
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${xv} ${yv}`} fill="none" stroke={color} strokeWidth="8" strokeLinecap="round" />
<circle cx={xv} cy={yv} r="5.5" fill={color} stroke="#fff" strokeWidth="1.5" />
</>
)}
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-[#0A39DF]" style={{ fontSize: 20, fontWeight: 700 }}>{value}</text>
<text x={cx} y={cy + 9} textAnchor="middle" fill="#8A94A4" style={{ fontSize: 8, fontWeight: 600 }}>300-850</text>
</svg>
</div>
);
}
const BADGE_LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E]',
silver: 'bg-[#F3F4F6] text-[#4B5563]',
gold: 'bg-[#FEF9E7] text-[#92400E]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF]',
};
export default function TalentPoolCard({ profile, onContact, onView }) {
const [expanded, setExpanded] = useState(false);
const score = profile.krow_score || 0;
const band = getScoreBand(score);
const fico = toFICO(score);
const hasScore = score > 0;
const skills = (profile.skills || []).slice(0, 4);
const extraSkills = (profile.skills || []).length - skills.length;
const badges = (profile.earned_badges || []).slice(0, 3);
const extraBadges = (profile.earned_badges || []).length - badges.length;
const capabilities = profile.capabilities || [];
const endorsements = capabilities.filter((c) => c.status === 'verified' || c.status === 'pending').slice(0, 3);
const extraEndorsements = capabilities.length - endorsements.length;
const toggle = (e) => {
e.stopPropagation();
setExpanded((v) => !v);
};
return (
<div
className={cn(
'bg-white border border-[#E5E7EB] rounded-xl transition-all overflow-hidden',
expanded ? 'shadow-sm border-[#0A39DF]' : 'hover:shadow-sm hover:border-[#0A39DF]'
)}
>
{/* Clickable header → opens full profile modal */}
<div
className="p-4 cursor-pointer"
onClick={() => onView?.(profile)}
>
<div className="flex items-start gap-4">
{/* Avatar */}
<div className="w-14 h-14 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
<Image
src={profile.selfie_url || `https://images.unsplash.com/${getPortraitSeed(profile.full_name)}?w=140&h=140&fit=crop&crop=faces`}
alt={profile.full_name}
className="w-full h-full object-cover"
fittingType="fill"
/>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-[#111827] text-[15px] truncate">{profile.full_name}</h3>
<span
className="px-2 py-0.5 rounded-full text-[10px] font-bold"
style={hasScore
? { backgroundColor: band.color, color: '#fff' }
: { backgroundColor: '#E5E7EB', color: '#6B7280' }}
>
{hasScore ? band.label : 'No score yet'}
</span>
</div>
</div>
{/* Career Score gauge */}
<CareerGauge fico={fico} />
</div>
</div>
{/* Expanded details */}
{expanded && (
<div className="px-4 pb-4 space-y-3">
{/* Quick meta line */}
<div className="flex items-center gap-3 text-[11px] text-[#6B7280] flex-wrap">
{profile.desired_position && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<Briefcase className="w-3 h-3" />
{profile.desired_position}
</span>
)}
{profile.experience_years > 0 && (
<span>{profile.experience_years}yr exp</span>
)}
{profile.location && (
<span className="inline-flex items-center gap-1">
<MapPin className="w-3 h-3" />
{profile.location}
</span>
)}
{(profile.client_rating > 0 || profile.supervisor_rating > 0) && (
<span className="inline-flex items-center gap-1 text-[#F59E0B] font-medium">
<Star className="w-3 h-3 fill-[#F59E0B]" />
{((profile.client_rating + profile.supervisor_rating) / 2).toFixed(1)}
</span>
)}
</div>
{skills.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{skills.map((s) => (
<span key={s} className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[10px] font-medium">{s}</span>
))}
{extraSkills > 0 && (
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraSkills}</span>
)}
</div>
)}
{(profile.availability || []).length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{(profile.availability || []).slice(0, 3).map((a) => (
<span key={a} className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#374151] text-[10px]">{a}</span>
))}
</div>
)}
{/* Badges */}
{badges.length > 0 && (
<div className="pt-3 border-t border-[#F3F4F6]">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
<Award className="w-3 h-3" /> BADGES
</p>
<div className="flex items-center gap-1.5 flex-wrap">
{badges.map((b, i) => (
<span key={i} className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium', BADGE_LEVEL_STYLES[b.level] || BADGE_LEVEL_STYLES.bronze)}>
<Award className="w-2.5 h-2.5" />
{b.name}
</span>
))}
{extraBadges > 0 && (
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraBadges}</span>
)}
</div>
</div>
)}
{/* Endorsements */}
{endorsements.length > 0 && (
<div className="pt-3 border-t border-[#F3F4F6]">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
<BadgeCheck className="w-3 h-3" /> ENDORSEMENTS
<span className="text-[#D1D5DB] font-semibold">({capabilities.length})</span>
</p>
<div className="flex items-center gap-1.5 flex-wrap">
{endorsements.map((e, i) => (
<span key={i} className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border', e.status === 'verified' ? 'bg-[#EFF6FF] text-[#0A39DF] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]')}>
<BadgeCheck className="w-2.5 h-2.5" />
{e.skill}
{e.level && <span className="opacity-70">· {e.level}</span>}
</span>
))}
{extraEndorsements > 0 && (
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraEndorsements}</span>
)}
</div>
</div>
)}
{/* Employer insights */}
<div className="pt-3 border-t border-[#F3F4F6]">
<EmployerInsights profile={profile} />
</div>
</div>
)}
{/* Footer: email + contact + expand toggle */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-[#F3F4F6]">
<span className="text-[11px] text-[#9CA3AF] flex-1 truncate">{profile.email || 'No email'}</span>
{onContact && (
<button
onClick={(e) => { e.stopPropagation(); onContact(profile); }}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap"
>
<Mail className="w-3.5 h-3.5" />
Contact
</button>
)}
<button
onClick={toggle}
className="inline-flex items-center gap-1 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium px-2.5 py-1.5 rounded-lg transition-colors"
aria-expanded={expanded}
>
<ChevronDown className={cn('w-3.5 h-3.5 transition-transform', expanded && 'rotate-180')} />
{expanded ? 'Hide' : 'Details'}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { ShieldCheck, Check, Dot } from 'lucide-react';
// KROW verifies everything. This is the trust layer — what's proven vs. claimed.
const ITEMS = [
{ key: 'identity', label: 'Identity', hint: 'Selfie + name' },
{ key: 'certifications', label: 'Certifications', hint: 'Uploaded docs' },
{ key: 'skills', label: 'Skills', hint: 'Declared' },
{ key: 'work', label: 'Work History', hint: 'Experience' },
{ key: 'interview', label: 'AI Interview', hint: 'Passed' },
{ key: 'attendance', label: 'Attendance', hint: 'Shifts' },
{ key: 'reputation', label: 'Reputation', hint: 'Ratings' },
{ key: 'performance', label: 'Performance', hint: 'Score' },
{ key: 'learning', label: 'Learning', hint: 'Courses' },
{ key: 'promotions', label: 'Promotions', hint: 'Growth' },
{ key: 'references', label: 'References', hint: 'Verified' },
];
function statusOf(profile, key) {
const has = (v) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0) && v !== 0;
const map = {
identity: has(profile.selfie_url) && has(profile.full_name),
certifications: (profile.certifications || []).length > 0,
skills: (profile.skills || []).length > 0,
work: (profile.experience || []).length > 0,
interview: (profile.ai_interview_score || 0) > 0,
attendance: (profile.attendance_score || 0) > 0 && (profile.experience || []).length > 0,
reputation: (profile.client_rating || 0) > 0 || (profile.supervisor_rating || 0) > 0,
performance: (profile.performance_score || 0) > 0,
learning: (profile.completed_courses || []).length > 0,
promotions: (profile.leadership_potential || 0) >= 40,
references: (profile.supervisor_rating || 0) > 0,
};
return map[key] ? 'verified' : 'pending';
}
export default function TrustLayer({ profile }) {
const verified = ITEMS.filter((i) => statusOf(profile, i.key) === 'verified').length;
return (
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
<div className="flex items-start justify-between gap-4 flex-wrap mb-6">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center shrink-0">
<ShieldCheck className="w-5 h-5 text-white" />
</div>
<div>
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#94A3B8]">Trust Layer</span>
<h2 className="text-[20px] font-heading font-bold text-[#0F172A] tracking-tight">Verified, not claimed.</h2>
<p className="text-[13px] text-[#64748B] mt-0.5">Everything KROW has proven about you. Impossible to fake.</p>
</div>
</div>
<div className="text-right">
<div className="text-[28px] font-heading font-bold text-[#0A39DF] leading-none">{verified}<span className="text-[#94A3B8] text-[18px]">/{ITEMS.length}</span></div>
<div className="text-[11px] text-[#94A3B8] font-medium mt-1">verified</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2.5">
{ITEMS.map((item) => {
const st = statusOf(profile, item.key);
return (
<div key={item.key} className="flex items-center gap-2.5 py-1.5 border-b border-[#F1F5F9]">
{st === 'verified' ? (
<span className="w-5 h-5 rounded-full bg-[#0A39DF] flex items-center justify-center shrink-0">
<Check className="w-3 h-3 text-white" />
</span>
) : (
<span className="w-5 h-5 rounded-full bg-[#FEF3C7] flex items-center justify-center shrink-0">
<Dot className="w-4 h-4 text-[#D97706]" />
</span>
)}
<span className={`text-[13px] font-medium ${st === 'verified' ? 'text-[#0F172A]' : 'text-[#64748B]'}`}>{item.label}</span>
<span className="text-[11px] text-[#94A3B8] ml-auto">{st === 'verified' ? 'Verified' : 'Pending'}</span>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Lock, ShieldCheck, MessageSquare, Camera, Video, ScanLine, Zap, Clock, ArrowRight } from 'lucide-react';
import { isUnlocked } from '@/lib/provingGround';
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
const TYPE_LABEL = { roleplay: 'Explain', photo: 'Photo', video: 'Record', photo_identify: 'Identify' };
const DIFF_COLOR = { beginner: '#16A34A', intermediate: '#F59E0B', advanced: '#EF4444' };
export default function UniversityCourseCard({ course, completed = false, profile }) {
const type = course?.challenge?.type || 'roleplay';
const Icon = TYPE_ICON[type] || MessageSquare;
const lock = isUnlocked(course, profile);
const color = DIFF_COLOR[course.difficulty] || '#64748B';
return (
<Link
to={`/university/${course.id}`}
className="group relative flex flex-col rounded-2xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-lg hover:shadow-blue-500/5 hover:-translate-y-0.5 transition-all overflow-hidden"
>
<div className="flex items-center justify-between mb-3">
<div className="w-11 h-11 rounded-2xl flex items-center justify-center" style={{ background: completed ? '#F0FDF4' : '#EFF6FF' }}>
<Icon className="w-5 h-5" style={{ color: completed ? '#16A34A' : '#0A39DF' }} />
</div>
{completed ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#16A34A] bg-[#F0FDF4] px-2 py-0.5 rounded-full"><ShieldCheck className="w-3 h-3" /> Verified</span>
) : !lock.unlocked ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#94A3B8] bg-[#F8FAFC] px-2 py-0.5 rounded-full"><Lock className="w-3 h-3" /> Locked</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#0A39DF] bg-[#EFF6FF] px-2 py-0.5 rounded-full">{TYPE_LABEL[type]}</span>
)}
</div>
<h3 className="text-[15px] font-semibold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">{course.title}</h3>
<div className="flex items-center gap-1.5 mt-2">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
<span className="text-[11px] text-[#64748B] capitalize">{course.difficulty}</span>
{course.proof_skill && <span className="text-[11px] text-[#94A3B8]">· {course.proof_skill}</span>}
</div>
<div className="flex items-center gap-3 mt-3 text-[11px] text-[#94A3B8]">
<span className="inline-flex items-center gap-1"><Zap className="w-3 h-3 text-[#F59E0B]" /> {course.xp}</span>
<span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /> {course.estimated_minutes}m</span>
</div>
{!completed && lock.unlocked && (
<div className="mt-3 pt-3 border-t border-[#F1F5F9] inline-flex items-center gap-1 text-[12px] font-semibold text-[#0A39DF]">
Start <ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</div>
)}
</Link>
);
}

View File

@@ -0,0 +1,188 @@
import React from 'react';
import { Shield, Star, TrendingUp } from 'lucide-react';
import { getScoreBand } from '@/lib/talentHome';
import ReferencesEndorsements from '@/components/krow/ReferencesEndorsements';
const R = 44;
const STROKE = 8;
const CIRC = 2 * Math.PI * R;
const FICO_MIN = 300;
const FICO_MAX = 850;
function careerScoreFromApp(app) {
const s = Math.max(0, Math.min(100, Math.round(app?.ai_score || 0)));
return 300 + Math.round((s / 100) * 550);
}
function marketRangeFromApp(app) {
const s = Math.max(0, Math.min(100, Math.round(app?.ai_score || 0)));
const uplift = Math.max(0, Math.round((s - 70) / 5));
const low = 20 + uplift;
return `$${low}-$${low + 4}/hr`;
}
function nextTierFromApp(app) {
const s = Math.round(app?.ai_score || 0);
const yrs = app?.years_experience || 0;
if (s >= 85 || yrs >= 8) return 'Skilled';
if (s >= 60 || yrs >= 3) return 'Cross-Trained';
return 'Beginner';
}
function CareerScoreMini({ application }) {
const score = careerScoreFromApp(application);
const pct = Math.max(0, Math.min(1, (score - FICO_MIN) / (FICO_MAX - FICO_MIN)));
const dash = pct * CIRC;
const band = getScoreBand(Math.round(application?.ai_score || 0));
return (
<div className="flex items-center gap-5 bg-white/70 rounded-xl border border-[#E5E7EB] px-4 py-3">
<div className="flex flex-col items-center shrink-0">
<div className="relative" style={{ width: R * 2 + STROKE, height: R * 2 + STROKE }}>
<svg width={R * 2 + STROKE} height={R * 2 + STROKE} className="-rotate-90">
<circle cx={R + STROKE / 2} cy={R + STROKE / 2} r={R} fill="none" stroke="#EFF6FF" strokeWidth={STROKE} />
<circle
cx={R + STROKE / 2}
cy={R + STROKE / 2}
r={R}
fill="none"
stroke="#0A39DF"
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={`${dash} ${CIRC}`}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[8px] uppercase tracking-wide text-[#94A3B8] font-medium">Career Score</span>
<span className="text-[22px] font-bold text-[#0A39DF] leading-none">{score}</span>
<span className="text-[8px] text-[#94A3B8] font-medium">300-850</span>
</div>
</div>
<span
className="mt-2 inline-flex items-center justify-center text-[11px] font-bold text-white px-3 py-1 rounded-full w-full"
style={{ backgroundColor: band.color }}
>
{band.label}
</span>
</div>
<div className="flex-1 space-y-3">
<div>
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] font-medium">Potential Market Value</p>
<p className="text-[16px] font-bold text-[#111827]">{marketRangeFromApp(application)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] font-medium">Next Promotion</p>
<p className="text-[14px] font-semibold text-[#0F172A]">{nextTierFromApp(application)}</p>
</div>
</div>
</div>
);
}
const COMM_LABELS = [
{ min: 85, label: 'Excellent' },
{ min: 70, label: 'Good' },
{ min: 55, label: 'Average' },
{ min: 0, label: 'Needs Work' },
];
const commLabel = (score) => {
const v = score || 0;
return (COMM_LABELS.find((c) => v >= c.min) || COMM_LABELS[COMM_LABELS.length - 1]).label;
};
const COMPANY_COLORS = [
'bg-[#0A39DF]',
'bg-[#333F48]',
'bg-[#082EB4]',
];
const colorFor = (name) => {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length];
};
const initialsFor = (name) => {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
};
function StatTile({ label, value, suffix, accent }) {
const text = value != null && value !== '' ? value : '—';
return (
<div className="flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl bg-white/70 border border-[#E5E7EB]">
<div className="text-[18px] font-bold leading-none" style={{ color: accent }}>
{text}
{suffix && <span className="text-[12px] font-semibold ml-0.5">{suffix}</span>}
</div>
<div className="text-[10px] text-[#6B7280] tracking-wide font-medium">{label}</div>
</div>
);
}
export default function WorkforceReputation({ application }) {
const breakdown = application?.score_breakdown || {};
const companies = application?.companies_worked || [];
const clientRating = application?.client_rating || 0;
const reliability = breakdown.reliability;
const attendance = breakdown.attendance_expectations;
const leadership = breakdown.leadership_expectations;
const communication = breakdown.communication_style;
const commText = communication != null ? commLabel(communication) : null;
const hasScores = [reliability, attendance, leadership, communication].some((v) => v != null);
const hasData = hasScores || companies.length > 0 || clientRating > 0 || (application?.ai_score || 0) > 0;
if (!hasData) return null;
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
<Shield className="w-4 h-4 text-white" strokeWidth={2.5} />
</div>
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide flex items-center gap-1.5">
WORKFORCE REPUTATION
<TrendingUp className="w-3.5 h-3.5 text-[#0A39DF]" />
</h4>
</div>
<CareerScoreMini application={application} />
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
<StatTile label="Reliability" value={reliability != null ? reliability : null} suffix="%" accent="#0A39DF" />
<StatTile label="Attendance" value={attendance != null ? attendance : null} suffix="%" accent="#059669" />
<StatTile label="Leadership" value={leadership != null ? leadership : null} suffix="%" accent="#0A39DF" />
<StatTile label="Communication" value={commText} accent="#F59E0B" />
<div className="flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl bg-white/70 border border-[#E5E7EB]">
<div className="flex items-center gap-0.5">
<Star className="w-3.5 h-3.5 text-[#F9E547] fill-[#F9E547]" />
<span className="text-[18px] font-bold leading-none text-[#111827]">
{clientRating > 0 ? clientRating.toFixed(1) : '—'}
</span>
</div>
<div className="text-[10px] text-[#6B7280] tracking-wide font-medium">Client Rating</div>
</div>
</div>
{companies.length > 0 && (
<div className="mt-3 pt-3 border-t border-[#E5E7EB]/80">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">WORKED</p>
<div className="flex flex-wrap items-center gap-2.5">
{companies.map((c, i) => (
<div key={i} className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-lg ${colorFor(c)} flex items-center justify-center text-white text-[11px] font-bold shadow-sm`}>
{initialsFor(c)}
</div>
<span className="text-[12px] font-semibold text-[#374151]">{c}</span>
</div>
))}
</div>
</div>
)}
<ReferencesEndorsements application={application} />
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { ShieldCheck, Plus } from 'lucide-react';
// The transcript replacement — verified capabilities as visual chips,
// each backed by evidence. Empty until the worker proves something.
export default function CapabilityTimeline({ profile, compact }) {
const caps = (profile?.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
return (
<section className={compact ? 'py-2' : 'py-8'}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proven skills</h2>
<span className="text-[11px] text-[#94A3B8]">{caps.length} verified</span>
</div>
{!caps.length ? (
<div className="rounded-2xl border border-dashed border-[#E2E8F0] bg-[#F8FAFC] p-6 text-center">
<div className="w-10 h-10 rounded-full bg-white border border-[#E2E8F0] flex items-center justify-center mx-auto mb-2">
<Plus className="w-4 h-4 text-[#94A3B8]" />
</div>
<p className="text-[12px] text-[#64748B]">No verified skills yet — prove a challenge to start your chain.</p>
</div>
) : (
<div className="flex flex-wrap gap-2">
{caps.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-[#F0FDF4] border border-[#BBF7D0] px-3 py-1.5">
<ShieldCheck className="w-3.5 h-3.5 text-[#16A34A]" />
<span className="text-[12px] font-medium text-[#166534]">{c.skill}</span>
{c.verified_date && (
<span className="text-[10px] text-[#16A34A]/70">
{new Date(c.verified_date).toLocaleDateString('en-US', { month: 'short' })}
</span>
)}
</span>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,209 @@
import React, { useState } from 'react';
import { Lock, Video, Camera, MessageSquare, ScanLine, Loader2, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
import { isUnlocked } from '@/lib/provingGround';
import { useSubmitChallenge, useVerifyEvidence } from '@/lib/krowHooks';
import RoleplayChallenge from './RoleplayChallenge';
import MediaChallenge from './MediaChallenge';
import VideoRecorder from './VideoRecorder';
import HazardIdentifier from './HazardIdentifier';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
const STEPS = ['Learn', 'Demonstrate', 'Verify', 'Proof'];
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo Proof', video: 'Record Yourself', photo_identify: 'Identify Hazards' };
function Stepper({ active }) {
return (
<div className="flex items-center gap-1 mb-6">
{STEPS.map((s, i) => {
const done = i < active;
const cur = i === active;
return (
<React.Fragment key={s}>
<div className={`flex items-center gap-1.5 ${cur || done ? '' : 'opacity-50'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold ${done ? 'bg-[#0A39DF] text-white' : cur ? 'bg-[#0A39DF] text-white ring-4 ring-[#EFF6FF]' : 'bg-[#F1F5F9] text-[#94A3B8]'}`}>{done ? '✓' : i + 1}</div>
<span className={`text-[12px] font-medium ${cur || done ? 'text-[#0F172A]' : 'text-[#94A3B8]'}`}>{s}</span>
</div>
{i < STEPS.length - 1 && <div className={`flex-1 h-px ${done ? 'bg-[#0A39DF]' : 'bg-[#E5E7EB]'}`} />}
</React.Fragment>
);
})}
</div>
);
}
export default function ChallengeRunner({ course, profile, onPassed }) {
const [step, setStep] = useState(0);
const [evaluating, setEvaluating] = useState(false);
const [result, setResult] = useState(null);
const [evidence, setEvidence] = useState(null);
const [supervisorVerified, setSupervisorVerified] = useState(false);
const submit = useSubmitChallenge();
const verify = useVerifyEvidence();
const ch = course?.challenge || {};
const type = ch.type || 'roleplay';
const Icon = TYPE_ICON[type] || MessageSquare;
const lock = isUnlocked(course, profile);
const handleSubmit = async (payload) => {
setEvaluating(true);
let mediaUrl = null, transcript = '', identified = null;
if (type === 'roleplay') transcript = payload;
else if (type === 'photo_identify') { mediaUrl = ch.image_url; identified = payload; }
else mediaUrl = payload;
try {
const res = await submit.mutateAsync({ profile, course, type, mediaUrl, transcript, identified });
setResult(res.result);
setEvidence(res.evidence);
setSupervisorVerified(false);
setStep(2);
if (res.passed && onPassed) onPassed();
} catch {
setStep(1);
} finally {
setEvaluating(false);
}
};
const doVerify = async () => {
if (!evidence) return;
try {
await verify.mutateAsync({ evidenceId: evidence.id });
setSupervisorVerified(true);
} catch {}
};
const passed = evidence?.ai_verdict === 'verified';
// 0 — Learn
if (step === 0) {
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
<Stepper active={0} />
{!lock.unlocked ? (
<>
<div className="flex items-center gap-2 text-[#0F172A] mb-2"><Lock className="w-4 h-4" /><span className="font-semibold text-[15px]">Locked — earn this one</span></div>
<p className="text-[13px] text-[#64748B] mb-3">Real work unlocks learning. Complete these first:</p>
<ul className="space-y-1.5">
{lock.reasons.map((r, i) => (<li key={i} className="text-[13px] text-[#475569] flex items-center gap-2"><span className="w-1.5 h-1.5 rounded-full bg-[#94A3B8]" />{r}</li>))}
</ul>
</>
) : (
<>
<div className="flex items-center gap-3 mb-4">
<div className="w-11 h-11 rounded-2xl bg-[#EFF6FF] flex items-center justify-center"><Icon className="w-5 h-5 text-[#0A39DF]" /></div>
<div>
<p className="text-[11px] font-medium uppercase tracking-wide text-[#94A3B8]">Demonstration</p>
<p className="text-[16px] font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</p>
</div>
</div>
<p className="text-[14px] text-[#374151] leading-relaxed mb-4">{ch.prompt || course?.description}</p>
<button onClick={() => setStep(1)} className="w-full h-11 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold flex items-center justify-center gap-2">Show me <ArrowRight className="w-4 h-4" /></button>
</>
)}
</div>
);
}
// Evaluating
if (evaluating) {
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-10 flex flex-col items-center">
<Stepper active={1} />
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF] mb-3" />
<p className="text-[14px] font-medium text-[#0F172A]">Owliver is evaluating your proof…</p>
<p className="text-[12px] text-[#64748B] mt-1">Measuring whether you can actually perform.</p>
</div>
);
}
// 1 — Demonstrate
if (step === 1) {
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
<Stepper active={1} />
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-3 mb-4 flex items-start gap-2.5">
<OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />
<div>
<p className="text-[11px] font-semibold text-[#0A39DF] mb-0.5">Owliver</p>
<p className="text-[13px] text-[#374151]">{ch.prompt || course?.description}</p>
</div>
</div>
{type === 'roleplay' && <RoleplayChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
{type === 'photo' && <MediaChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
{type === 'video' && <VideoRecorder onSubmit={handleSubmit} submitting={submit.isPending} />}
{type === 'photo_identify' && <HazardIdentifier course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
</div>
);
}
// 2 — Verify
if (step === 2) {
const rubric = result?.rubric || {};
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
<Stepper active={2} />
<div className="flex items-center gap-3 mb-4">
{passed ? <CheckCircle2 className="w-9 h-9 text-[#16A34A]" /> : <XCircle className="w-9 h-9 text-[#EF4444]" />}
<div>
<p className="text-[17px] font-semibold text-[#0F172A]">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
<p className="text-[12px] text-[#64748B]">AI confidence: {Math.round(evidence?.ai_score || 0)}/100</p>
</div>
</div>
{result?.feedback && <p className="text-[13px] text-[#374151] leading-relaxed mb-4">{result.feedback}</p>}
{Object.keys(rubric).length > 0 && (
<div className="space-y-2 mb-4">
{Object.entries(rubric).map(([k, v]) => (
<div key={k}>
<div className="flex justify-between text-[12px] mb-1"><span className="text-[#475569] capitalize">{k.replace(/_/g, ' ')}</span><span className="font-semibold text-[#0F172A]">{Math.round(v)}/100</span></div>
<div className="h-1.5 rounded-full bg-[#F1F5F9] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${Math.min(100, Math.round(v))}%` }} /></div>
</div>
))}
</div>
)}
<div className={`rounded-xl border p-4 flex items-center gap-3 mb-3 ${supervisorVerified ? 'bg-[#F0FDF4] border-[#BBF7D0]' : 'bg-[#FFFBEB] border-[#FDE68A]'}`}>
<ShieldCheck className={`w-5 h-5 ${supervisorVerified ? 'text-[#16A34A]' : 'text-[#D97706]'}`} />
<div className="flex-1">
<p className="text-[13px] font-medium text-[#0F172A]">{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}</p>
<p className="text-[11px] text-[#64748B]">{supervisorVerified ? 'A supervisor confirmed this demonstration in person.' : 'A supervisor can confirm this demonstration on the floor.'}</p>
</div>
{!supervisorVerified ? (
<button onClick={doVerify} disabled={verify.isPending} className="h-8 px-3 rounded-lg bg-[#0F172A] text-white text-[12px] font-semibold disabled:opacity-50">{verify.isPending ? '…' : 'Verify'}</button>
) : (
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
)}
</div>
{passed ? (
<button onClick={() => setStep(3)} className="w-full h-11 rounded-xl bg-[#0A39DF] text-white text-[14px] font-semibold flex items-center justify-center gap-2">See your proof <ArrowRight className="w-4 h-4" /></button>
) : (
<button onClick={() => setStep(1)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Try the demonstration again</button>
)}
</div>
);
}
// 3 — Proof
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
<Stepper active={3} />
<div className="flex items-center gap-3 mb-4">
<div className="w-11 h-11 rounded-2xl bg-[#F9E547] flex items-center justify-center"><Award className="w-5 h-5 text-[#92400E]" /></div>
<div>
<p className="text-[16px] font-semibold text-[#0F172A]">Proof stored</p>
<p className="text-[12px] text-[#64748B]">Capability: {course.proof_skill || course.title}</p>
</div>
</div>
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-4 space-y-2 mb-4">
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">AI verdict</span><span className="font-semibold text-[#16A34A] capitalize">{evidence?.ai_verdict}</span></div>
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Score</span><span className="font-semibold text-[#0F172A]">{Math.round(evidence?.ai_score || 0)}/100</span></div>
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Supervisor</span><span className="font-semibold text-[#0F172A]">{supervisorVerified ? 'Verified' : 'Pending'}</span></div>
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Proof type</span><span className="font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</span></div>
</div>
<div className="rounded-xl bg-[#EFF6FF] border border-[#BFDBFE] p-4 flex items-start gap-2.5 mb-4">
<Eye className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
<p className="text-[12px] text-[#1E40AF]">Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.</p>
</div>
<button onClick={() => setStep(0)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Back to start</button>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { Trash2, Plus } from 'lucide-react';
import { Image } from '@/components/ui/image';
const PRESET_TAGS = [
'Raw meat next to ready-to-eat food',
'Same board for raw + cooked',
'Soiled towel on food surface',
'Food in the temperature danger zone',
'Unwashed hands',
'Improper storage',
'Cross-contact allergen',
];
// Interactive hazard identification — tap a kitchen photo to mark hazards.
export default function HazardIdentifier({ course, onSubmit, submitting }) {
const imageUrl = course?.challenge?.image_url;
const [markers, setMarkers] = useState([]);
const [pending, setPending] = useState(null);
const [customLabel, setCustomLabel] = useState('');
const addMarker = (e) => {
if (submitting || pending) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
setPending({ x, y });
};
const confirm = (label) => {
if (!pending) return;
setMarkers([...markers, { ...pending, label }]);
setPending(null);
setCustomLabel('');
};
const remove = (i) => setMarkers(markers.filter((_, j) => j !== i));
return (
<div>
<p className="text-[13px] text-[#64748B] mb-3">Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.</p>
<div className="relative rounded-2xl overflow-hidden border border-[#E5E7EB] mb-3 select-none cursor-crosshair" onClick={addMarker}>
{imageUrl ? (
<Image src={imageUrl} alt="Kitchen scene" className="w-full max-h-80 object-cover" fittingType="fill" />
) : (
<div className="w-full h-64 bg-[#F1F5F9] flex items-center justify-center text-[13px] text-[#94A3B8]">No scene image configured</div>
)}
{markers.map((m, i) => (
<div key={i} className="absolute -translate-x-1/2 -translate-y-1/2 group" style={{ left: `${m.x * 100}%`, top: `${m.y * 100}%` }} onClick={(e) => e.stopPropagation()}>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-red-500 text-white text-[11px] font-bold shadow ring-2 ring-white">{i + 1}</span>
<span className="absolute left-1/2 -translate-x-1/2 top-7 whitespace-nowrap text-[10px] bg-white/95 rounded px-1.5 py-0.5 shadow text-[#0F172A]">{m.label}</span>
<button onClick={() => remove(i)} className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-red-500 shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 className="w-2.5 h-2.5" /></button>
</div>
))}
{pending && (
<div className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={{ left: `${pending.x * 100}%`, top: `${pending.y * 100}%` }}>
<span className="block w-6 h-6 rounded-full bg-[#0A39DF] ring-4 ring-white animate-pulse" />
</div>
)}
</div>
{pending && (
<div className="rounded-xl border border-[#0A39DF] bg-[#EFF6FF] p-3 mb-3">
<p className="text-[12px] font-medium text-[#1E40AF] mb-2">Label this hazard:</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_TAGS.map((t) => (
<button key={t} onClick={() => confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB] text-[#374151] hover:border-[#0A39DF]">{t}</button>
))}
</div>
<div className="flex gap-2">
<input value={customLabel} onChange={(e) => setCustomLabel(e.target.value)} placeholder="Or type your own…" className="flex-1 h-8 px-2.5 text-[12px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none" />
<button onClick={() => customLabel.trim() && confirm(customLabel.trim())} className="h-8 px-3 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> Add</button>
</div>
</div>
)}
<button onClick={() => onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40">
Submit {markers.length} hazard{markers.length !== 1 ? 's' : ''} for AI evaluation
</button>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Loader2, Camera } from 'lucide-react';
import { base44 } from '@/api/base44Client';
import { Image } from '@/components/ui/image';
// Photo or video proof — capture/upload, then submit for AI evaluation.
export default function MediaChallenge({ course, onSubmit, submitting }) {
const type = course?.challenge?.type || 'photo';
const [mediaUrl, setMediaUrl] = useState('');
const [uploading, setUploading] = useState(false);
const accept = type === 'video' ? 'video/*' : 'image/*';
const capture = type === 'photo' ? 'environment' : undefined;
const handleFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
setMediaUrl(file_url);
} catch {
setMediaUrl('');
} finally {
setUploading(false);
}
};
return (
<div>
<p className="text-[13px] text-[#64748B] mb-3">
{type === 'video' ? 'Record or upload a short video demonstrating the challenge.' : 'Take or upload a photo showing your work.'}
</p>
{mediaUrl ? (
<div className="mb-3 rounded-xl overflow-hidden border border-[#E5E7EB]">
{type === 'video' ? (
<video src={mediaUrl} controls className="w-full max-h-64 object-cover" />
) : (
<Image src={mediaUrl} alt="Your proof" className="w-full max-h-64 object-cover" fittingType="fill" />
)}
</div>
) : (
<label className="flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed border-[#CBD5E1] cursor-pointer hover:border-[#0A39DF] transition-colors">
{uploading ? (
<Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" />
) : (
<>
<Camera className="w-6 h-6 text-[#94A3B8] mb-2" />
<span className="text-[13px] text-[#64748B]">Tap to {type === 'video' ? 'record' : 'capture'}</span>
</>
)}
<input type="file" accept={accept} capture={capture} onChange={handleFile} className="hidden" disabled={uploading || submitting} />
</label>
)}
<div className="flex gap-2">
{mediaUrl && (
<button onClick={() => setMediaUrl('')} className="h-10 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B]">Retake</button>
)}
<button
onClick={() => onSubmit(mediaUrl)}
disabled={!mediaUrl || uploading || submitting}
className="flex-1 h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
>
Submit proof for AI evaluation
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Loader2 } from 'lucide-react';
import { challengeFollowUp } from '@/lib/provingGround';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
// AI roleplay challenge — scenario prompt, worker responds, KROW follows up,
// then the worker submits the transcript for AI evaluation.
export default function RoleplayChallenge({ course, onSubmit, submitting }) {
const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.';
const [messages, setMessages] = useState([{ role: 'ai', content: initial }]);
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const scrollRef = useRef(null);
useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
const send = async () => {
const text = input.trim();
if (!text || busy) return;
const next = [...messages, { role: 'user', content: text }];
setMessages(next);
setInput('');
setBusy(true);
try {
const follow = await challengeFollowUp(course, next);
setMessages([...next, { role: 'ai', content: follow }]);
} catch {
setMessages([...next, { role: 'ai', content: 'Thanks. Submit your proof when ready.' }]);
} finally {
setBusy(false);
}
};
const transcript = messages.map((m) => `${m.role === 'user' ? 'Worker' : 'Owliver'}: ${m.content}`).join('\n');
const hasResponded = messages.some((m) => m.role === 'user');
return (
<div>
<div className="flex items-center gap-2 mb-3">
<OwliverAvatar className="w-8 h-8" rounded="rounded-full" />
<div>
<p className="text-[13px] font-semibold text-[#0F172A]">Owliver</p>
<p className="text-[10px] text-[#94A3B8]">Your AI skills coach</p>
</div>
</div>
<div className="space-y-3 mb-4 max-h-72 overflow-y-auto pr-1">
{messages.map((m, i) => {
const isAI = m.role !== 'user';
return (
<div key={i} className={`flex items-end gap-2 ${isAI ? 'justify-start' : 'justify-end'}`}>
{isAI && <OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />}
<div className={`max-w-[80%] px-3.5 py-2.5 rounded-2xl text-[13px] leading-relaxed ${
m.role === 'user' ? 'bg-[#0A39DF] text-white rounded-br-sm' : 'bg-[#F1F5F9] text-[#0F172A] rounded-bl-sm'
}`}>{m.content}</div>
</div>
);
})}
<div ref={scrollRef} />
</div>
<div className="flex items-center gap-2 rounded-full bg-white pl-4 pr-1.5 py-1.5 shadow-[0_10px_30px_rgba(10,57,223,0.14),0_2px_6px_rgba(51,63,72,0.06)] ring-1 ring-black/5">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
disabled={submitting || busy}
placeholder="Type your response…"
className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-[#9CA3AF]"
/>
<button onClick={send} disabled={busy || submitting} className="w-9 h-9 rounded-full bg-[#0A39DF] text-white flex items-center justify-center disabled:opacity-50 shrink-0">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</button>
</div>
<button
onClick={() => onSubmit(transcript)}
disabled={!hasResponded || submitting || busy}
className="mt-3 w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
>
Submit proof for AI evaluation
</button>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React, { useRef, useState, useEffect } from 'react';
import { Loader2, Video, Square, RotateCcw } from 'lucide-react';
import { base44 } from '@/api/base44Client';
// Real in-browser video recording — "record yourself washing your hands."
export default function VideoRecorder({ onSubmit, submitting }) {
const videoRef = useRef(null);
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
const timerRef = useRef(null);
const [recording, setRecording] = useState(false);
const [recordedUrl, setRecordedUrl] = useState(null);
const [blob, setBlob] = useState(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [seconds, setSeconds] = useState(0);
useEffect(() => {
let active = true;
let s;
navigator.mediaDevices?.getUserMedia({ video: { facingMode: 'user' }, audio: true })
.then((stream) => {
s = stream;
if (active && videoRef.current) videoRef.current.srcObject = stream;
})
.catch(() => setError('Camera access denied. Grant camera permission or use a mobile device to record.'));
return () => {
active = false;
if (s) s.getTracks().forEach((t) => t.stop());
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
const start = () => {
if (!videoRef.current?.srcObject) return;
chunksRef.current = [];
const mr = new MediaRecorder(videoRef.current.srcObject);
mr.ondataavailable = (e) => { if (e.data.size) chunksRef.current.push(e.data); };
mr.onstop = () => {
const b = new Blob(chunksRef.current, { type: 'video/webm' });
setBlob(b);
setRecordedUrl(URL.createObjectURL(b));
};
mr.start();
mediaRecorderRef.current = mr;
setRecording(true);
setSeconds(0);
timerRef.current = setInterval(() => setSeconds((v) => v + 1), 1000);
};
const stop = () => {
mediaRecorderRef.current?.stop();
setRecording(false);
if (timerRef.current) clearInterval(timerRef.current);
};
const reset = () => { setRecordedUrl(null); setBlob(null); setSeconds(0); };
const submit = async () => {
if (!blob) return;
setUploading(true);
try {
const file = new File([blob], `proof-${Date.now()}.webm`, { type: 'video/webm' });
const { file_url } = await base44.integrations.Core.UploadFile({ file });
onSubmit(file_url);
} catch {
setError('Upload failed. Try again.');
} finally {
setUploading(false);
}
};
return (
<div>
{error && <p className="text-[12px] text-[#EF4444] mb-3">{error}</p>}
<div className="relative rounded-2xl overflow-hidden bg-black aspect-video mb-3">
{recordedUrl ? (
<video src={recordedUrl} controls className="w-full h-full object-cover" />
) : (
<video ref={videoRef} autoPlay muted playsInline className="w-full h-full object-cover" />
)}
{recording && (
<div className="absolute top-3 left-3 flex items-center gap-1.5 bg-black/60 rounded-full px-2.5 py-1">
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[12px] text-white font-mono">{seconds}s</span>
</div>
)}
</div>
{!recordedUrl ? (
<button onClick={recording ? stop : start} disabled={uploading || submitting} className={`w-full h-11 rounded-xl font-semibold text-[14px] flex items-center justify-center gap-2 text-white ${recording ? 'bg-red-500' : 'bg-[#0A39DF]'}`}>
{recording ? <><Square className="w-4 h-4" /> Stop recording</> : <><Video className="w-4 h-4" /> Record yourself</>}
</button>
) : (
<div className="flex gap-2">
<button onClick={reset} className="h-11 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B] flex items-center gap-1.5"><RotateCcw className="w-4 h-4" /> Retake</button>
<button onClick={submit} disabled={uploading || submitting} className="flex-1 h-11 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold flex items-center justify-center gap-2 disabled:opacity-50">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : null} Submit proof for AI evaluation
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
const LADDER = ['Beginner', 'Cross-Trained', 'Skilled', 'Lead', 'Manager'];
export default function CareerGrowth({ profile }) {
const lp = Number(profile.leadership_potential) || 0;
const currentIdx = lp >= 80 ? 3 : lp >= 60 ? 2 : lp >= 40 ? 1 : 0;
return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<h2 className="text-[18px] font-bold text-[#0F172A]">Career Growth</h2>
<p className="text-[13px] text-[#6B7280] mb-5">People don't just work. They level up.</p>
<div className="flex flex-col">
{LADDER.map((step, i) => {
const done = i < currentIdx;
const current = i === currentIdx;
const last = i === LADDER.length - 1;
return (
<div key={step} className="flex gap-3">
<div className="flex flex-col items-center">
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[12px] font-bold shrink-0 ${current ? 'bg-[#0A39DF] text-white' : done ? 'bg-[#ECFDF5] text-[#16A34A] border border-[#A7F3D0]' : 'bg-[#F3F4F6] text-[#9CA3AF]'}`}>
{done ? '✓' : i + 1}
</div>
{!last && <div className="w-0.5 flex-1 bg-[#E5E7EB] my-1 min-h-[16px]" />}
</div>
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0A39DF]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
{step}
{current && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">You are here</span>}
{last && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">Target</span>}
</div>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { ShieldCheck } from 'lucide-react';
const ITEMS = [
'Identity',
'Food Handler',
'RBS',
'Allergens',
'Knife Skills',
'References',
'AI Interview',
'Background Check',
];
export default function CareerPassport({ profile }) {
const certs = (profile.certifications || []).map((c) => c.toLowerCase());
const skills = (profile.skills || []).map((s) => s.toLowerCase());
const badges = (profile.earned_badges || []).map((b) => (b.name || '').toLowerCase());
const has = (label) => {
switch (label) {
case 'Identity':
return !!profile.selfie_url || !!profile.full_name;
case 'Food Handler':
return certs.some((c) => c.includes('food'));
case 'RBS':
return certs.some((c) => c.includes('rbs'));
case 'Allergens':
return certs.some((c) => c.includes('allergen'));
case 'Knife Skills':
return skills.some((s) => s.includes('knife')) || badges.some((b) => b.includes('knife'));
case 'References':
return (profile.experience || []).length >= 2;
case 'AI Interview':
return (profile.ai_interview_score || 0) > 0;
case 'Background Check':
return (profile.shifts_completed || 0) >= 10;
default:
return false;
}
};
const done = ITEMS.filter(has);
const pct = Math.round((done.length / ITEMS.length) * 100);
return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
<div className="flex items-center justify-between">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Career Passport</h2>
<span className="text-[12px] font-semibold text-[#0A39DF]">{pct}% Complete</span>
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{ITEMS.map((label) => {
const ok = has(label);
return (
<div
key={label}
className={`flex items-center gap-2 px-3 py-2 rounded-xl border ${
ok ? 'border-[#BBF7D0] bg-[#F0FDF4]' : 'border-[#E5E7EB] bg-[#F8FAFC]'
}`}
>
<ShieldCheck className={`w-4 h-4 shrink-0 ${ok ? 'text-[#16A34A]' : 'text-[#CBD5E1]'}`} />
<span className={`text-[13px] ${ok ? 'text-[#0F172A] font-medium' : 'text-[#94A3B8]'}`}>{label}</span>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Info, RefreshCw } from 'lucide-react';
import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome';
const FICO_MIN = 300;
const FICO_MAX = 850;
const ARC_R = 64;
const CX = 80;
const CY = 80;
const BLUE = '#0055FF';
const TRACK = '#E1E5E9';
function arcPoint(p) {
const ang = Math.PI - p * Math.PI;
return { x: CX + ARC_R * Math.cos(ang), y: CY - ARC_R * Math.sin(ang) };
}
function arcPath(startP, endP) {
const a = arcPoint(startP);
const b = arcPoint(endP);
const large = endP - startP > 0.5 ? 1 : 0;
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${large} 1 ${b.x} ${b.y}`;
}
export default function CareerScoreGauge({ profile }) {
const raw = getTalentScore(profile);
const score = toFICO(raw);
const band = getScoreBand(raw);
const pct = Math.max(0, Math.min(1, (score - FICO_MIN) / (FICO_MAX - FICO_MIN)));
const gap = 0.02;
const filledEnd = Math.max(gap, Math.min(1 - gap, pct));
const marker = arcPoint(filledEnd);
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: CX * 2, height: CY + 28 }}>
<svg width={CX * 2} height={CY + 28} viewBox={`0 0 ${CX * 2} ${CY + 28}`} className="overflow-visible">
<path d={arcPath(gap, 1 - gap)} fill="none" stroke={TRACK} strokeWidth="12" strokeLinecap="round" />
{pct > gap && (
<path d={arcPath(gap, filledEnd)} fill="none" stroke={BLUE} strokeWidth="12" strokeLinecap="round" />
)}
<circle cx={marker.x} cy={marker.y} r="8" fill={BLUE} stroke="#fff" strokeWidth="3" />
</svg>
<div className="absolute inset-x-0 bottom-2 flex flex-col items-center">
<span className="text-[9px] uppercase tracking-wide text-[#A0AAB9] font-semibold">Career Score</span>
<span className="text-[30px] font-bold leading-none mt-0.5" style={{ color: BLUE }}>{score}</span>
<span className="text-[10px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}-{FICO_MAX}</span>
</div>
</div>
<span className="mt-2 inline-flex items-center gap-1 text-[12px] font-semibold px-3 py-1 rounded-full"
style={{ color: BLUE, background: '#F0F4F9' }}>
{band.label}
<Info className="w-3 h-3" style={{ color: BLUE }} />
</span>
<div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]">
<RefreshCw className="w-3 h-3" />
Next update in 31 days
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More