403 lines
18 KiB
JavaScript
403 lines
18 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import { motion } from 'framer-motion';
|
|
import { Plus, Search, Menu, ChevronDown, LogOut, User as UserIcon, Users } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Image } from '@/components/ui/image';
|
|
import { base44 } from '@/api/base44Client';
|
|
import { KROW_LOGO_URL } from '@/assets/brand';
|
|
import { logActivity } from '@/lib/userTracking';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
|
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
|
|
import OwliverChatBubble from '@/components/krow/OwliverChatBubble';
|
|
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
|
|
import KrowIdPreview from '@/components/krow/KrowIdPreview';
|
|
|
|
const ROLE_NAV = {
|
|
employer: [
|
|
{ label: 'Overview', path: '/' },
|
|
{ label: 'Positions', path: '/positions' },
|
|
{ label: 'Candidates', path: '/candidates' },
|
|
{ label: 'Hired History', path: '/hired' },
|
|
{ label: 'Talent Pool', path: '/talent-pool' },
|
|
{ label: 'Analytics', path: '/analytics' },
|
|
],
|
|
admin: [
|
|
{ label: 'Control Center', path: '/admin' },
|
|
{ label: 'Positions', path: '/positions' },
|
|
{ label: 'Candidates', path: '/candidates' },
|
|
{ label: 'Hired History', path: '/hired' },
|
|
{ label: 'Talent Pool', path: '/talent-pool' },
|
|
{ label: 'KROW Forge', path: '/university' },
|
|
{ label: 'Analytics', path: '/analytics' },
|
|
{ label: 'Activity', path: '/tracking' },
|
|
],
|
|
talent: [
|
|
{ label: 'My Portal', path: '/employee' },
|
|
{ label: 'KROW Forge', path: '/university' },
|
|
{ label: 'Opportunities', path: '/apply' },
|
|
],
|
|
};
|
|
|
|
const ROLE_HOME = { employer: '/', admin: '/admin', talent: '/employee' };
|
|
const ROLE_LABEL = { employer: 'Employer', admin: 'Admin', talent: 'Talent' };
|
|
|
|
const STATUS_OPTIONS = ['All Statuses', 'Active', 'Draft', 'Paused', 'Closed'];
|
|
|
|
export default function Layout() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('All Statuses');
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [user, setUser] = useState(null);
|
|
const [activeRole, setActiveRole] = useState(() => localStorage.getItem('activeRole') || 'employer');
|
|
// Owliver's own open state. Owliver is a separate product with its own UI and
|
|
// voice workflow; it is not routed through the Krow AI panel.
|
|
const [owliverOpen, setOwliverOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
base44.auth.me().then(setUser).catch(() => {});
|
|
}, []);
|
|
|
|
const handleLogout = () => {
|
|
base44.auth.logout('/');
|
|
};
|
|
|
|
useEffect(() => {
|
|
const pendingRole = localStorage.getItem('pending_account_type');
|
|
if (pendingRole) {
|
|
localStorage.removeItem('pending_account_type');
|
|
(async () => {
|
|
try {
|
|
await base44.auth.updateMe({ account_type: pendingRole });
|
|
await logActivity('signup', { account_type: pendingRole });
|
|
} catch {}
|
|
})();
|
|
}
|
|
}, []);
|
|
|
|
// Adopt saved/default role from the user's account type on first load.
|
|
useEffect(() => {
|
|
if (user && !localStorage.getItem('activeRole')) {
|
|
const r = ['employer', 'admin', 'talent'].includes(user.account_type) ? user.account_type : 'employer';
|
|
setActiveRole(r);
|
|
localStorage.setItem('activeRole', r);
|
|
}
|
|
}, [user]);
|
|
|
|
const handleRoleChange = (role) => {
|
|
setActiveRole(role);
|
|
localStorage.setItem('activeRole', role);
|
|
navigate(ROLE_HOME[role]);
|
|
};
|
|
|
|
// Anything in the talent portal can open Owliver through this event — the
|
|
// portal hero's own CTA uses it.
|
|
useEffect(() => {
|
|
const openOwliver = () => setOwliverOpen(true);
|
|
window.addEventListener('krow:open-owliver', openOwliver);
|
|
return () => window.removeEventListener('krow:open-owliver', openOwliver);
|
|
}, []);
|
|
|
|
const navItems = ROLE_NAV[activeRole];
|
|
const isTalent = activeRole === 'talent';
|
|
|
|
const isActive = (path) =>
|
|
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
|
|
|
|
const handleSearch = (e) => {
|
|
e.preventDefault();
|
|
navigate(`/positions${searchQuery ? `?search=${encodeURIComponent(searchQuery)}` : ''}`);
|
|
};
|
|
|
|
const handleStatusChange = (value) => {
|
|
setStatusFilter(value);
|
|
const param = value === 'All Statuses' ? '' : `?status=${value.toLowerCase()}`;
|
|
navigate(`/positions${param}`);
|
|
};
|
|
|
|
return (
|
|
<AssistantPanelProvider role={activeRole} pathname={location.pathname}>
|
|
<div className="min-h-screen gradient-mesh bg-[#F8FAFC]">
|
|
{/* Header */}
|
|
<header className="sticky top-0 z-40 glass border-b border-white/40">
|
|
<div className="mx-auto max-w-container-wide px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
|
|
{/* Brand */}
|
|
<button onClick={() => navigate(ROLE_HOME[activeRole])} className="flex items-center gap-2 text-left shrink-0 group">
|
|
<Image src={KROW_LOGO_URL} alt="KROW" fittingType="fit" className="h-7 w-[86px] rounded-lg shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform" />
|
|
<span className="hidden sm:block text-[10px] text-[#9CA3AF] font-medium tracking-wide leading-tight self-end mb-1">FORGE</span>
|
|
</button>
|
|
|
|
{/* Nav pill (desktop) */}
|
|
<nav className="hidden md:flex items-center gap-1 bg-white/60 backdrop-blur-xl rounded-full p-1 border border-white/50 shadow-sm">
|
|
{navItems.map((item) => (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
className={cn(
|
|
'relative px-4 py-1.5 rounded-full text-[13px] font-medium transition-all',
|
|
isActive(item.path)
|
|
? 'text-white'
|
|
: 'text-[#6B7280] hover:text-[#111827]'
|
|
)}
|
|
>
|
|
{isActive(item.path) && (
|
|
<motion.div
|
|
layoutId="navPill"
|
|
className="absolute inset-0 rounded-full bg-[#0A39DF]"
|
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
|
/>
|
|
)}
|
|
<span className="relative z-10">{item.label}</span>
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Role switcher */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="hidden md:inline-flex items-center gap-1.5 h-9 px-3 rounded-full border border-white/60 bg-white/70 backdrop-blur-sm text-[12px] font-semibold text-[#0A39DF] hover:bg-white transition-colors">
|
|
<Users className="w-3.5 h-3.5" />
|
|
{ROLE_LABEL[activeRole]}
|
|
<ChevronDown className="w-3 h-3" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-40">
|
|
{Object.keys(ROLE_LABEL).map((r) => (
|
|
<DropdownMenuItem
|
|
key={r}
|
|
onClick={() => handleRoleChange(r)}
|
|
className={cn('cursor-pointer', r === activeRole && 'text-[#0A39DF] font-semibold')}
|
|
>
|
|
{ROLE_LABEL[r]}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* User menu */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="hidden md:inline-flex items-center justify-center w-9 h-9 rounded-full bg-[#0A39DF] text-white text-[13px] font-semibold shadow-lg shadow-blue-500/20 hover:scale-105 transition-transform">
|
|
{user?.full_name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || <UserIcon className="w-4 h-4" />}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<DropdownMenuLabel className="font-normal">
|
|
<div className="flex flex-col space-y-1">
|
|
<p className="text-sm font-medium leading-none">{user?.full_name || 'User'}</p>
|
|
<p className="text-xs leading-none text-muted-foreground">{user?.email}</p>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => (isTalent ? window.dispatchEvent(new CustomEvent('krow:open-owliver')) : navigate('/identity'))}
|
|
className="cursor-pointer"
|
|
>
|
|
<UserIcon className="w-4 h-4 mr-2" />
|
|
{isTalent ? 'Build Profile with Owliver' : 'Create KROW Identity'}
|
|
</DropdownMenuItem>
|
|
{isTalent && location.pathname !== '/me' ? (
|
|
<HoverCard openDelay={250} closeDelay={200}>
|
|
<HoverCardTrigger asChild>
|
|
<DropdownMenuItem onClick={() => navigate('/me')} className="cursor-pointer">
|
|
<UserIcon className="w-4 h-4 mr-2" />
|
|
My KROW ID
|
|
</DropdownMenuItem>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent side="left" align="start" sideOffset={8} className="w-[380px] p-3 bg-white border border-[#E5E7EB] rounded-2xl shadow-xl">
|
|
<KrowIdPreview />
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
) : (
|
|
<DropdownMenuItem onClick={() => navigate('/me')} className="cursor-pointer">
|
|
<UserIcon className="w-4 h-4 mr-2" />
|
|
My KROW ID
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem onClick={() => navigate('/profile')} className="cursor-pointer">
|
|
<UserIcon className="w-4 h-4 mr-2" />
|
|
Profile
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer text-red-600 focus:text-red-600">
|
|
<LogOut className="w-4 h-4 mr-2" />
|
|
Log out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{/* Mobile menu button */}
|
|
<button
|
|
onClick={() => setMenuOpen(true)}
|
|
className="md:hidden inline-flex items-center justify-center w-9 h-9 rounded-xl border border-white/50 glass text-[#374151] hover:bg-white/80 transition-colors"
|
|
>
|
|
<Menu className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Control bar (Employer / Admin only) */}
|
|
{!isTalent && (
|
|
<div className="sticky top-16 z-30 glass border-b border-white/40">
|
|
{/* Below `sm` this wraps to two rows: search gets a full-width row of
|
|
its own, the filter + action share the next. On one row the search
|
|
field was being crushed to ~90px by the status select's intrinsic
|
|
width, clipping its own placeholder. From `sm` up it is the
|
|
original single 56px-tall row. */}
|
|
<div className="mx-auto max-w-container-wide px-4 sm:px-6 py-2.5 sm:py-0 sm:h-14 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:gap-3">
|
|
<form onSubmit={handleSearch} className="relative w-full sm:w-auto sm:flex-1 sm:max-w-md">
|
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9CA3AF]" />
|
|
<input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search positions..."
|
|
type="text"
|
|
className="w-full pl-9 pr-4 h-9 text-[13px] rounded-full border border-white/60 bg-white/70 backdrop-blur-sm focus:bg-white focus:border-[#0A39DF] focus:outline-none transition-all"
|
|
/>
|
|
</form>
|
|
|
|
<div className="flex items-center gap-2 w-full sm:w-auto ml-auto">
|
|
<div className="relative flex-1 sm:flex-none min-w-0">
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => handleStatusChange(e.target.value)}
|
|
className="appearance-none w-full sm:w-auto h-9 pl-3 pr-8 text-[13px] font-medium rounded-full border border-white/60 bg-white/70 backdrop-blur-sm focus:border-[#0A39DF] focus:outline-none cursor-pointer"
|
|
>
|
|
{STATUS_OPTIONS.map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#374151] pointer-events-none" />
|
|
</div>
|
|
<button
|
|
onClick={() => navigate('/positions/new')}
|
|
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-semibold px-4 py-2 rounded-full transition-all shrink-0 hover:scale-[1.02] shadow-sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{/* The second row leaves room for the label on mobile too */}
|
|
<span>New Position</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile menu Sheet */}
|
|
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
|
<SheetContent side="right" className="w-72 p-0 glass">
|
|
<SheetHeader className="px-5 pt-5 pb-3 border-b border-white/40">
|
|
<SheetTitle className="text-left font-heading">Menu</SheetTitle>
|
|
</SheetHeader>
|
|
<nav className="flex flex-col gap-1 p-3">
|
|
{/* Role switcher (mobile) */}
|
|
<div className="flex gap-1.5 px-1 pb-2">
|
|
{Object.keys(ROLE_LABEL).map((r) => (
|
|
<button
|
|
key={r}
|
|
onClick={() => { handleRoleChange(r); setMenuOpen(false); }}
|
|
className={cn(
|
|
'flex-1 py-2 rounded-xl text-[12px] font-semibold transition-all',
|
|
r === activeRole ? 'bg-[#0A39DF] text-white' : 'bg-white/60 text-[#374151]'
|
|
)}
|
|
>
|
|
{ROLE_LABEL[r]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{navItems.map((item) => (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
onClick={() => setMenuOpen(false)}
|
|
className={cn(
|
|
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
|
|
isActive(item.path)
|
|
? 'bg-[#EFF6FF] text-[#0A39DF]'
|
|
: 'text-[#374151] hover:bg-white/60'
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
<button
|
|
onClick={() => { setMenuOpen(false); isTalent ? window.dispatchEvent(new CustomEvent('krow:open-owliver')) : navigate('/identity'); }}
|
|
className={cn(
|
|
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all text-left w-full',
|
|
isActive('/identity') ? 'bg-[#EFF6FF] text-[#0A39DF]' : 'text-[#374151] hover:bg-white/60'
|
|
)}
|
|
>
|
|
{isTalent ? 'Build Profile with Owliver' : 'Create KROW Identity'}
|
|
</button>
|
|
<Link
|
|
to="/me"
|
|
onClick={() => setMenuOpen(false)}
|
|
className={cn(
|
|
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
|
|
isActive('/me') ? 'bg-[#EFF6FF] text-[#0A39DF]' : 'text-[#374151] hover:bg-white/60'
|
|
)}
|
|
>
|
|
My KROW ID
|
|
</Link>
|
|
<Link
|
|
to="/profile"
|
|
onClick={() => setMenuOpen(false)}
|
|
className={cn(
|
|
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
|
|
isActive('/profile') ? 'bg-[#EFF6FF] text-[#0A39DF]' : 'text-[#374151] hover:bg-white/60'
|
|
)}
|
|
>
|
|
Profile
|
|
</Link>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[14px] font-medium text-red-600 hover:bg-red-50/80 transition-colors w-full"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
Log out
|
|
</button>
|
|
{!isTalent && (
|
|
<button
|
|
onClick={() => { setMenuOpen(false); navigate('/positions/new'); }}
|
|
className="mt-2 inline-flex items-center justify-center gap-1.5 bg-[#0A39DF] text-white text-[14px] font-semibold px-4 py-2.5 rounded-xl transition-all"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
New Position
|
|
</button>
|
|
)}
|
|
</nav>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* Page shell.
|
|
The dashboard is the primary experience: `main` keeps the same measure
|
|
it has always had and stays centred, so with the assistant closed this
|
|
layout is identical to the original. Opening the assistant animates a
|
|
track open beside it; `main` is a flex child, so it reflows smoothly
|
|
rather than jumping, and closing restores the original layout exactly. */}
|
|
<div className="mx-auto flex max-w-container-wide px-4 sm:px-6 py-6 sm:py-8">
|
|
<main className="min-w-0 w-full max-w-7xl flex-1 mx-auto">
|
|
<Outlet />
|
|
</main>
|
|
|
|
{/* Krow AI — the dashboard assistant. Employer and Admin only; see
|
|
ai-assistant/placement.js. Sticky below the sticky chrome so it stays
|
|
put while the dashboard scrolls. */}
|
|
<AssistantPanel
|
|
stickyClassName="top-[8.5rem]"
|
|
panelHeightClassName="h-[calc(100vh_-_10rem)]"
|
|
/>
|
|
</div>
|
|
|
|
{/* Owliver — the independent AI product, with its own branding, voice
|
|
experience and workflow. Talent only. Deliberately untouched by the
|
|
Krow AI work: different product, different UI, no shared components. */}
|
|
{isTalent && <OwliverChatBubble open={owliverOpen} onOpenChange={setOwliverOpen} />}
|
|
</div>
|
|
</AssistantPanelProvider>
|
|
);
|
|
}
|