Fix organization setup flow: redirect to onboarding for incomplete setup

This commit is contained in:
Ra
2025-08-18 10:33:45 -07:00
commit 557b113196
60 changed files with 16246 additions and 0 deletions

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
node_modules/
dist/
.env
.env.*.local
*.log
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/figma/
/figma-code/
/server-minimal.js
/employeeQuestions.ts
/README.md
/metadata.json
**/Demo.tsx
/.firebaserc
/CLOUD_FUNCTIONS_README.md
/Employee-Questions.md
/.env.example
/firebase.json
/EMPLOYEE_FORMS_README.md
/firebase-tools.json
/firestore.indexes.json
/firestore.rules
/.env.production.example
/.github/copilot-instructions.md*
/.vscode/settings.json
/.vscode/extensions.json
!/.vscode/tasks.json
/database.rules.json
/functions/auditly*.json

16
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "start-dev",
"type": "shell",
"command": "bun run dev",
"args": [],
"isBackground": true,
"problemMatcher": [
"$tsc"
],
"group": "build"
}
]
}

238
App.tsx Normal file
View File

@@ -0,0 +1,238 @@
import React from 'react';
import { HashRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { UserOrganizationsProvider, useUserOrganizations } from './contexts/UserOrganizationsContext';
import { OrgProvider, useOrg } from './contexts/OrgContext';
import { Layout } from './components/UiKit';
import CompanyWiki from './pages/CompanyWiki';
import EmployeeData from './pages/EmployeeData';
import Chat from './pages/Chat';
import HelpAndSettings from './pages/HelpAndSettings';
import Login from './pages/Login';
import ModernLogin from './pages/ModernLogin';
import OrgSelection from './pages/OrgSelection';
import Onboarding from './pages/Onboarding';
import EmployeeQuestionnaire from './pages/EmployeeQuestionnaire';
import EmployeeQuestionnaireSteps from './pages/EmployeeQuestionnaireSteps';
import QuestionTypesDemo from './pages/QuestionTypesDemo';
import FormsDashboard from './pages/FormsDashboard';
import DebugEmployee from './pages/DebugEmployee';
import QuestionnaireComplete from './pages/QuestionnaireComplete';
import SubscriptionSetup from './pages/SubscriptionSetup';
import { isFirebaseConfigured } from './services/firebase';
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, loading } = useAuth();
if (loading) return <div className="p-8">Loading...</div>;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
};
const RequireOrgSelection: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { selectedOrgId, loading } = useUserOrganizations();
if (loading) return <div className="p-8">Loading your organizations...</div>;
if (!selectedOrgId) return <Navigate to="/org-selection" replace />;
return <>{children}</>;
};
const RequireOnboarding: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { org } = useOrg();
const { user } = useAuth();
const { organizations } = useUserOrganizations();
if (!org) return <div className="p-8">Loading organization...</div>;
if (!org.onboardingCompleted) {
// Only org owners should be redirected to onboarding
const userOrgRelation = organizations.find(o => o.orgId === org.orgId);
const isOrgOwner = userOrgRelation?.role === 'owner';
if (isOrgOwner) {
return <Navigate to="/onboarding" replace />;
} else {
// Non-owners should see a waiting message
return (
<div className="p-8 text-center">
<h2 className="text-xl font-semibold mb-4">Organization Setup In Progress</h2>
<p className="text-gray-600">
Your organization is currently being set up by the administrator.
Please check back later or contact your administrator for more information.
</p>
</div>
);
}
}
return <>{children}</>;
};
// Wrapper component that provides selected org to OrgProvider
const OrgProviderWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { selectedOrgId } = useUserOrganizations();
if (!selectedOrgId) {
return <div className="p-8">No organization selected</div>;
}
return (
<OrgProvider selectedOrgId={selectedOrgId}>
{children}
</OrgProvider>
);
};
// Redirect invite URLs directly to employee questionnaire (no auth needed)
const InviteRedirect: React.FC = () => {
const { inviteCode } = useParams<{ inviteCode: string }>();
return <Navigate to={`/employee-form/${inviteCode}`} replace />;
};
function App() {
return (
<ThemeProvider>
<AuthProvider>
<UserOrganizationsProvider>
<HashRouter>
<Routes>
<Route path="/login" element={<ModernLogin />} />
<Route path="/login/:inviteCode" element={<ModernLogin />} />
<Route path="/invite/:inviteCode" element={<InviteRedirect />} />
<Route path="/legacy-login" element={<Login />} />
{/* Employee questionnaire - no auth needed, uses invite code */}
<Route path="/employee-form/:inviteCode" element={<EmployeeQuestionnaire />} />
{/* Organization Selection - after auth, before entering app */}
<Route
path="/org-selection"
element={
<RequireAuth>
<OrgSelection />
</RequireAuth>
}
/>
{/* Subscription Setup - after organization creation */}
<Route
path="/subscription-setup"
element={
<RequireAuth>
<SubscriptionSetup />
</RequireAuth>
}
/>
{/* Routes that require both auth and org selection */}
<Route
path="/employee-questionnaire"
element={
<RequireAuth>
<RequireOrgSelection>
<OrgProviderWrapper>
<EmployeeQuestionnaire />
</OrgProviderWrapper>
</RequireOrgSelection>
</RequireAuth>
}
/>
<Route
path="/employee-questionnaire-steps"
element={
<RequireAuth>
<RequireOrgSelection>
<OrgProviderWrapper>
<EmployeeQuestionnaireSteps />
</OrgProviderWrapper>
</RequireOrgSelection>
</RequireAuth>
}
/>
<Route
path="/onboarding"
element={
<RequireAuth>
<RequireOrgSelection>
<OrgProviderWrapper>
<Onboarding />
</OrgProviderWrapper>
</RequireOrgSelection>
</RequireAuth>
}
/>
<Route path="/questionnaire-complete" element={<QuestionnaireComplete />} />
{/* Main app routes - require auth, org selection, and completed onboarding */}
<Route
element={
<RequireAuth>
<RequireOrgSelection>
<OrgProviderWrapper>
<RequireOnboarding>
<Layout />
</RequireOnboarding>
</OrgProviderWrapper>
</RequireOrgSelection>
</RequireAuth>
}
>
<Route path="/" element={<Navigate to="/reports" replace />} />
<Route path="/company-wiki" element={<CompanyWiki />} />
<Route path="/submissions" element={<EmployeeData mode="submissions" />} />
<Route path="/reports" element={<EmployeeData mode="reports" />} />
<Route path="/chat" element={<Chat />} />
<Route path="/help" element={<HelpAndSettings />} />
<Route path="/settings" element={<HelpAndSettings />} />
</Route>
{/* Debug routes */}
<Route
path="/question-types-demo"
element={
<RequireAuth>
<RequireOrgSelection>
<OrgProviderWrapper>
<QuestionTypesDemo />
</OrgProviderWrapper>
</RequireOrgSelection>
</RequireAuth>
}
/>
<Route
path="/forms-dashboard"
element={
<RequireAuth>
<RequireOrgSelection>
<OrgProviderWrapper>
<FormsDashboard />
</OrgProviderWrapper>
</RequireOrgSelection>
</RequireAuth>
}
/>
<Route
path="/debug-employee"
element={
<RequireAuth>
<RequireOrgSelection>
<OrgProviderWrapper>
<DebugEmployee />
</OrgProviderWrapper>
</RequireOrgSelection>
</RequireAuth>
}
/>
</Routes>
</HashRouter>
</UserOrganizationsProvider>
</AuthProvider>
</ThemeProvider>
);
}
export default App;

1091
bun.lock Normal file

File diff suppressed because it is too large Load Diff

361
components/UiKit.tsx Normal file
View File

@@ -0,0 +1,361 @@
import React, { useState, ReactNode } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useTheme } from '../contexts/ThemeContext';
import { Theme, NavItem } from '../types';
import { useOrg } from '../contexts/OrgContext';
import { useAuth } from '../contexts/AuthContext';
// ========== ICONS ==========
export const CompanyWikiIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 22h16a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1zM8 7h8v2H8V7zm0 4h8v2H8v-2zm0 4h5v2H8v-2z" fill="currentColor" /></svg>
);
export const SubmissionsIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM9 13v-2h6v2H9zm6 4H9v-2h6v2zm-6-8V4h1l5 5h-1V9H9z" fill="currentColor" /></svg>
);
export const ReportsIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 6h3a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2zm-4-2v2h-2V4h2zm-2 9v-2h-2v2h2zm4 0v-2h-2v2h2zm-4 4v-2h-2v2h2zm4 0v-2h-2v2h2zm-6-4H8V9h2v2z" fill="currentColor" /></svg>
);
export const ChatIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21 11.5a8.5 8.5 0 0 1-17 0 8.5 8.5 0 0 1 17 0z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /><path d="M21 11.5a8.5 8.5 0 0 0-8.5-8.5V3m0 17v-1.5a8.5 8.5 0 0 0 8.5-8.5H11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
);
export const HelpIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-13a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0v-4a1 1 0 0 0-1-1zm0 8a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" fill="currentColor" /></svg>
);
export const SettingsIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19.14 12.94a2.001 2.001 0 0 0-2.28 0l-.71.71a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71zM4.86 12.94a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71a2 2 0 0 0-2.83 0zM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7.78-1.94a2 2 0 0 0-2.83 0l-.71.71a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71zM4.86 5.22a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71a2 2 0 0 0-2.83 0z" fill="currentColor" /></svg>
);
export const CopyIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 2H8a2 2 0 0 0-2 2v12h2V4h8v4h4v8h2V8l-4-4h-2z" fill="currentColor" /><path d="M4 8h12v12H4z" fill="currentColor" opacity="0.5" /></svg>
);
export const PlusIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor" /></svg>
);
export const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 15.5l-6-6 1.4-1.4 4.6 4.6 4.6-4.6L18 9.5l-6 6z" fill="currentColor" /></svg>
);
export const UploadIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 16h6v-6h4l-8-8-8 8h4v6zm-4 2h14v2H5v-2z" fill="currentColor" /></svg>
);
export const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor" /></svg>
);
export const WarningIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2V7h2v7z" fill="currentColor" /></svg>
);
export const DownloadIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor" /></svg>
);
export const MinusIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 13H5v-2h14v2z" fill="currentColor" /></svg>
);
export const SunIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
);
export const MoonIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
);
export const SystemIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
);
export const SendIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
);
const NAV_ITEMS: NavItem[] = [
{ href: '/company-wiki', label: 'Company Wiki', icon: CompanyWikiIcon },
{ href: '/submissions', label: 'Submissions', icon: SubmissionsIcon },
{ href: '/reports', label: 'Reports', icon: ReportsIcon },
{ href: '/chat', label: 'Chat', icon: ChatIcon },
{ href: '/help', label: 'Help', icon: HelpIcon },
];
const BOTTOM_NAV_ITEMS: NavItem[] = [
{ href: '/settings', label: 'Settings', icon: SettingsIcon }
];
// ========== LAYOUT COMPONENTS ==========
const Sidebar = () => {
const { org, issueInviteViaApi } = useOrg();
const { signOutUser } = useAuth();
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteForm, setInviteForm] = useState({ name: '', email: '', role: '', department: '' });
const [inviteLink, setInviteLink] = useState('');
const [emailLink, setEmailLink] = useState('');
const commonLinkClasses = "flex items-center w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-200";
const activeClass = "bg-[--sidebar-active-bg] text-[--sidebar-active-text]";
const inactiveClass = "text-[--sidebar-text] hover:bg-[--sidebar-active-bg] hover:text-[--sidebar-active-text]";
const handleInvite = async () => {
try {
const result = await issueInviteViaApi({
name: inviteForm.name,
email: inviteForm.email,
role: inviteForm.role,
department: inviteForm.department
});
setInviteLink(result.inviteLink);
setEmailLink(result.emailLink);
setInviteForm({ name: '', email: '', role: '', department: '' });
} catch (error) {
console.error('Failed to invite employee:', error);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const renderNavItems = (items: NavItem[]) => items.map(item => (
<li key={item.href}>
<NavLink
to={item.href}
className={({ isActive }) => `${commonLinkClasses} ${isActive ? activeClass : inactiveClass}`}
>
<item.icon className="w-5 h-5 mr-3" />
<span>{item.label}</span>
</NavLink>
</li>
));
return (
<aside className="w-64 flex-shrink-0 bg-[--sidebar-bg] border-r border-[--border-color] flex flex-col p-4">
<div className="flex items-center mb-8">
<div className="w-8 h-8 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-lg mr-2">A</div>
<h1 className="text-xl font-bold text-[--text-primary]">{org?.name || 'Auditly'}</h1>
</div>
<nav className="flex-1">
<ul className="space-y-2">
{renderNavItems(NAV_ITEMS)}
</ul>
</nav>
<div className="mt-auto space-y-4">
<ul className="space-y-2 border-t border-[--border-color] pt-4">
{renderNavItems(BOTTOM_NAV_ITEMS)}
</ul>
<div className="p-4 rounded-lg bg-[--background-tertiary] text-center">
<h3 className="font-bold text-sm text-[--text-primary]">Build [Company]'s Report</h3>
<p className="text-xs text-[--text-secondary] mt-1 mb-3">Share this form with your team members to capture valuable info about your company to train Auditly.</p>
<div className="flex space-x-2">
<Button size="sm" className="w-full" onClick={() => setShowInviteModal(true)}>
<PlusIcon className="w-4 h-4 mr-1" /> Invite
</Button>
<Button size="sm" variant="secondary" className="w-full" onClick={() => (emailLink || inviteLink) && copyToClipboard(emailLink || inviteLink)}>
<CopyIcon className="w-4 h-4 mr-1" /> Copy
</Button>
</div>
<Button size="sm" variant="ghost" className="w-full mt-2" onClick={signOutUser}>Sign out</Button>
</div>
{/* Invite Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-[--background-secondary] p-6 rounded-lg max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Invite Employee</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">Name</label>
<input
type="text"
value={inviteForm.name}
onChange={(e) => setInviteForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Employee name"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">Email</label>
<input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="employee@company.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">Role</label>
<input
type="text"
value={inviteForm.role}
onChange={(e) => setInviteForm(prev => ({ ...prev, role: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g. Software Engineer"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">Department</label>
<input
type="text"
value={inviteForm.department}
onChange={(e) => setInviteForm(prev => ({ ...prev, department: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g. Engineering"
/>
</div>
{(inviteLink || emailLink) && (
<div className="space-y-3">
{emailLink && (
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Email Link (for GET requests)
</label>
<div className="flex space-x-2">
<input
type="text"
value={emailLink}
readOnly
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none text-sm"
/>
<Button size="sm" onClick={() => copyToClipboard(emailLink)}>
<CopyIcon className="w-4 h-4" />
</Button>
</div>
</div>
)}
{inviteLink && (
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
App Link (direct)
</label>
<div className="flex space-x-2">
<input
type="text"
value={inviteLink}
readOnly
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none text-sm"
/>
<Button size="sm" onClick={() => copyToClipboard(inviteLink)}>
<CopyIcon className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
)}
</div>
<div className="flex space-x-2 mt-6">
<Button
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
setInviteLink('');
setInviteForm({ name: '', email: '', role: '', department: '' });
}}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleInvite}
disabled={!inviteForm.name || !inviteForm.email}
>
Generate Invite
</Button>
</div>
</div>
</div>
)}
</div>
</aside>
);
};
export const Layout = () => (
<div className="flex h-screen bg-[--background-primary]">
<Sidebar />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
);
// ========== UI PRIMITIVES ==========
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
className?: string;
padding?: 'sm' | 'md' | 'lg';
}
export const Card: React.FC<CardProps> = ({ children, className, padding = 'md', ...props }) => {
const paddingClasses = {
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
return (
<div className={`bg-[--background-secondary] border border-[--border-color] rounded-xl shadow-sm ${paddingClasses[padding]} ${className || ''}`} {...props}>
{children}
</div>
);
};
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
export const Button: React.FC<ButtonProps> = ({ children, variant = 'primary', size = 'md', className, ...props }) => {
const baseClasses = 'inline-flex items-center justify-center font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-[--accent] text-[--accent-text] hover:bg-[--accent-hover] focus:ring-[--accent]',
secondary: 'bg-[--button-secondary-bg] text-[--text-primary] hover:bg-[--button-secondary-hover] focus:ring-[--accent] border border-[--border-color]',
danger: 'bg-[--status-red] text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'bg-transparent text-[--text-primary] hover:bg-[--background-tertiary]'
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className || ''}`} {...props}>
{children}
</button>
);
};
interface AccordionProps {
items: { question: string; answer: string }[];
}
export const Accordion = ({ items }: AccordionProps) => {
const [openIndex, setOpenIndex] = useState<number | null>(0);
const toggleItem = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="space-y-3">
{items.map((item, index) => (
<div key={index} className="border-b border-[--border-color]">
<button
onClick={() => toggleItem(index)}
className="w-full flex justify-between items-center py-4 text-left text-[--text-primary] font-medium"
>
<span>{item.question}</span>
{openIndex === index ? <MinusIcon className="w-5 h-5" /> : <PlusIcon className="w-5 h-5" />}
</button>
{openIndex === index && (
<div className="pb-4 text-[--text-secondary]">
{item.answer}
</div>
)}
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip, Legend } from 'recharts';
export interface RadarMetric {
label: string;
value: number; // 0-100
max?: number;
}
interface Props {
title?: string;
data: RadarMetric[];
height?: number;
color?: string;
}
const RadarPerformanceChart: React.FC<Props> = ({ title, data, height = 320, color = '#3b82f6' }) => {
const chartData = data.map(d => ({ subject: d.label, A: d.value, fullMark: d.max ?? 100 }));
return (
<div className="w-full h-full">
{title && <h4 className="text-sm font-medium text-[--text-secondary] mb-2">{title}</h4>}
<div style={{ width: '100%', height }}>
<ResponsiveContainer>
<RadarChart data={chartData} margin={{ top: 10, right: 30, bottom: 10, left: 10 }}>
<PolarGrid stroke="var(--border-color)" />
<PolarAngleAxis dataKey="subject" tick={{ fill: 'var(--text-secondary)', fontSize: 11 }} />
<PolarRadiusAxis angle={30} domain={[0, 100]} tick={{ fill: 'var(--text-secondary)', fontSize: 10 }} />
<Radar name={title || 'Score'} dataKey="A" stroke={color} fill={color} fillOpacity={0.35} />
<Tooltip wrapperStyle={{ fontSize: 12 }} contentStyle={{ background: 'var(--background-secondary)', border: '1px solid var(--border-color)' }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</RadarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default RadarPerformanceChart;

View File

@@ -0,0 +1,30 @@
import React from 'react';
interface ScoreItem { label: string; value: number; max?: number; }
interface Props { title?: string; items: ScoreItem[]; color?: string; }
const ScoreBarList: React.FC<Props> = ({ title, items, color = '#6366f1' }) => {
return (
<div className="space-y-3">
{title && <h4 className="text-sm font-medium text-[--text-secondary]">{title}</h4>}
<ul className="space-y-2">
{items.map(it => {
const pct = Math.min(100, Math.round((it.value / (it.max ?? 100)) * 100));
return (
<li key={it.label} className="space-y-1">
<div className="flex justify-between text-xs text-[--text-secondary]">
<span>{it.label}</span>
<span>{it.value}{it.max ? `/${it.max}` : ''}</span>
</div>
<div className="h-2 bg-[--background-secondary] rounded overflow-hidden">
<div className="h-full transition-all" style={{ width: pct + '%', background: color }} />
</div>
</li>
);
})}
</ul>
</div>
);
};
export default ScoreBarList;

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { EmployeeQuestion } from '../../employeeQuestions';
import { QuestionInput } from '../ui/QuestionInput';
interface EnhancedFigmaQuestionProps {
questionNumber?: string | number;
question: EmployeeQuestion;
answer?: string;
onAnswerChange?: (value: string) => void;
onBack?: () => void;
onNext?: () => void;
showNavigation?: boolean;
nextLabel?: string;
backLabel?: string;
className?: string;
}
export const EnhancedFigmaQuestion: React.FC<EnhancedFigmaQuestionProps> = ({
questionNumber = 'Q',
question,
answer = '',
onAnswerChange,
onBack,
onNext,
showNavigation = true,
nextLabel = 'Next',
backLabel = 'Back',
className = ''
}) => {
return (
<div className={`w-full max-w-[600px] px-5 pt-5 pb-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 inline-flex flex-col justify-end items-end gap-4 ${className}`}>
{/* Question Header */}
<div className="self-stretch inline-flex justify-start items-start gap-3">
<div className="justify-start text-gray-400 text-xl font-medium font-['Inter'] leading-loose">
{questionNumber}
</div>
<div className="flex-1 inline-flex flex-col justify-start items-start gap-2">
<div className="self-stretch justify-start text-[--text-primary] text-xl font-semibold font-['Inter'] leading-loose">
{question.prompt}
</div>
<div className="self-stretch justify-start text-[--text-secondary] text-sm font-normal font-['Inter'] leading-tight">
{question.required ? 'Required' : 'Optional'} {question.category}
{question.type && `${question.type}`}
</div>
</div>
</div>
{/* Separator */}
<div className="self-stretch h-px bg-gray-200 dark:bg-gray-700"></div>
{/* Answer Section */}
<div className="self-stretch inline-flex justify-start items-start gap-3">
<div className="justify-start text-gray-400 text-xl font-medium font-['Inter'] leading-loose">A</div>
<div className="flex-1">
<QuestionInput
question={question}
value={answer}
onChange={onAnswerChange || (() => { })}
className="border-0 bg-transparent focus:ring-0 p-0"
/>
</div>
</div>
{/* Navigation */}
{showNavigation && (
<div className="inline-flex justify-start items-center gap-3">
{onBack && (
<button
onClick={onBack}
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 rounded-full flex justify-center items-center gap-1 overflow-hidden hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<div className="relative">
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 15L8 10L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="px-1 flex justify-center items-center">
<div className="justify-center text-[--text-primary] text-sm font-medium font-['Inter'] leading-tight">
{backLabel}
</div>
</div>
</button>
)}
{onNext && (
<button
onClick={onNext}
className="px-4 py-3.5 bg-blue-500 rounded-full border-2 border-blue-400 flex justify-center items-center gap-1 overflow-hidden hover:bg-blue-600 transition-colors"
>
<div className="px-1 flex justify-center items-center">
<div className="justify-center text-white text-sm font-medium font-['Inter'] leading-tight">
{nextLabel}
</div>
</div>
<div className="relative">
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 15L13 10L8 5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</button>
)}
</div>
)}
</div>
);
};
export default EnhancedFigmaQuestion;

View File

@@ -0,0 +1,50 @@
import React from 'react';
// Figma-based Alert component with proper CSS variables and styling
interface FigmaAlertProps {
title: string;
variant?: 'success' | 'error' | 'warning' | 'info';
children?: React.ReactNode;
onClose?: () => void;
className?: string;
}
export const FigmaAlert: React.FC<FigmaAlertProps> = ({
title,
variant = 'info',
children,
onClose,
className = ''
}) => {
const getBorderColor = () => {
switch (variant) {
case 'success': return 'bg-Other-Green';
case 'error': return 'bg-red-500';
case 'warning': return 'bg-yellow-500';
default: return 'bg-blue-500';
}
};
return (
<div className={`p-4 relative bg-Other-White rounded-lg shadow-[0px_2px_2px_-1px_rgba(10,13,18,0.04)] shadow-[0px_4px_6px_-2px_rgba(10,13,18,0.03)] shadow-[0px_12px_16px_-4px_rgba(10,13,18,0.08)] outline outline-1 outline-offset-[-1px] outline-Neutrals-NeutralSlate200 inline-flex justify-center items-center gap-2.5 overflow-hidden ${className}`}>
<div className="w-96 max-w-96 justify-start text-Neutrals-NeutralSlate950 text-sm font-medium font-['Inter'] leading-tight">
{title}
{children && <div className="mt-1 text-xs text-Neutrals-NeutralSlate600">{children}</div>}
</div>
{onClose && (
<button onClick={onClose} className="flex-shrink-0">
<div data-svg-wrapper className="relative">
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6663 5.83325L6.33301 14.1666M6.33301 5.83325L14.6663 14.1666" stroke="var(--Neutrals-NeutralSlate600, #535862)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</button>
)}
<div className={`w-2 h-32 left-[-4px] top-[-41px] absolute ${getBorderColor()}`} />
</div>
);
};
export default FigmaAlert;

View File

@@ -0,0 +1,76 @@
import React from 'react';
interface FigmaInputProps {
label?: string;
placeholder?: string;
type?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
icon?: React.ReactNode;
buttonText?: string;
onButtonClick?: () => void;
className?: string;
required?: boolean;
}
export const FigmaInput: React.FC<FigmaInputProps> = ({
label = 'Email',
placeholder = 'Enter your email',
type = 'text',
value,
onChange,
icon,
buttonText,
onButtonClick,
className = '',
required = false
}) => {
return (
<div className={`w-[464px] inline-flex flex-col justify-start items-start gap-2 ${className}`}>
{label && (
<div className="self-stretch inline-flex justify-start items-center gap-0.5">
<div className="justify-start text-Neutrals-NeutralSlate800 text-sm font-medium font-['Inter'] leading-tight">
{label} {required && <span className="text-red-500">*</span>}
</div>
</div>
)}
<div className="self-stretch inline-flex justify-start items-start gap-2">
<div className="flex-1 px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] flex justify-start items-center gap-2 overflow-hidden">
{icon && (
<div data-svg-wrapper className="relative">
{icon}
</div>
)}
<input
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
className="flex-1 bg-transparent outline-none text-Neutrals-NeutralSlate950 text-sm font-normal font-['Inter'] leading-tight placeholder:text-Neutrals-NeutralSlate500"
/>
</div>
{buttonText && (
<button
onClick={onButtonClick}
className="w-32 max-w-32 px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] flex justify-center items-center gap-1 overflow-hidden hover:bg-Neutrals-NeutralSlate200 transition-colors"
>
<div className="justify-center text-Neutrals-NeutralSlate500 text-sm font-medium font-['Inter'] leading-tight">
{buttonText}
</div>
</button>
)}
</div>
</div>
);
};
// Email icon component for convenience
export const EmailIcon: React.FC = () => (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66675 5.83325L8.47085 10.5961C9.02182 10.9818 9.29731 11.1746 9.59697 11.2493C9.86166 11.3153 10.1385 11.3153 10.4032 11.2493C10.7029 11.1746 10.9783 10.9818 11.5293 10.5961L18.3334 5.83325M5.66675 16.6666H14.3334C15.7335 16.6666 16.4336 16.6666 16.9684 16.3941C17.4388 16.1544 17.8212 15.772 18.0609 15.3016C18.3334 14.7668 18.3334 14.0667 18.3334 12.6666V7.33325C18.3334 5.93312 18.3334 5.23306 18.0609 4.69828C17.8212 4.22787 17.4388 3.84542 16.9684 3.60574C16.4336 3.33325 15.7335 3.33325 14.3334 3.33325H5.66675C4.26662 3.33325 3.56655 3.33325 3.03177 3.60574C2.56137 3.84542 2.17892 4.22787 1.93923 4.69828C1.66675 5.23306 1.66675 5.93312 1.66675 7.33325V12.6666C1.66675 14.0667 1.66675 14.7668 1.93923 15.3016C2.17892 15.772 2.56137 16.1544 3.03177 16.3941C3.56655 16.6666 4.26662 16.6666 5.66675 16.6666Z" stroke="var(--Neutrals-NeutralSlate500, #717680)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export default FigmaInput;

View File

@@ -0,0 +1,71 @@
import React from 'react';
interface ProgressStepProps {
number: number;
title: string;
isActive?: boolean;
isCompleted?: boolean;
}
interface FigmaProgressProps {
steps: ProgressStepProps[];
currentStep?: number;
className?: string;
}
const ProgressStep: React.FC<ProgressStepProps> = ({ number, title, isActive = false, isCompleted = false }) => {
const stepClasses = isActive
? "p-2 bg-Main-BG-Gray-50 rounded-[10px] shadow-[0px_1px_2px_0px_rgba(10,13,20,0.03)]"
: "p-2 bg-Other-White rounded-[10px]";
const numberClasses = isActive || isCompleted
? "h-5 p-0.5 bg-Brand-Orange rounded-[999px]"
: "h-5 p-0.5 bg-bg-white-0 rounded-[999px] outline outline-1 outline-offset-[-1px] outline-Neutrals-NeutralSlate200";
const numberTextClasses = isActive || isCompleted
? "w-4 text-center justify-start text-Neutrals-NeutralSlate0 text-xs font-medium font-['Inter'] leading-none"
: "w-4 text-center justify-start text-Neutrals-NeutralSlate600 text-xs font-medium font-['Inter'] leading-none";
const titleClasses = isActive
? "flex-1 justify-start text-Neutrals-NeutralSlate950 text-sm font-normal font-['Inter'] leading-tight"
: "flex-1 justify-start text-Neutrals-NeutralSlate600 text-sm font-normal font-['Inter'] leading-tight";
return (
<div className={`self-stretch inline-flex justify-start items-center gap-2.5 overflow-hidden ${stepClasses}`}>
<div className={`inline-flex flex-col justify-center items-center gap-0.5 overflow-hidden ${numberClasses}`}>
<div className={numberTextClasses}>{number}</div>
</div>
<div className={titleClasses}>{title}</div>
</div>
);
};
export const FigmaProgress: React.FC<FigmaProgressProps> = ({ steps, currentStep = 1, className = '' }) => {
return (
<div className={`self-stretch inline-flex flex-col justify-start items-start gap-2 ${className}`}>
{steps.map((step, index) => (
<ProgressStep
key={index}
number={step.number}
title={step.title}
isActive={step.number === currentStep}
isCompleted={step.number < currentStep}
/>
))}
</div>
);
};
// Default onboarding steps as shown in Figma
export const defaultOnboardingSteps: ProgressStepProps[] = [
{ number: 1, title: "Company Overview & Vision" },
{ number: 2, title: "Leadership & Organizational Structure" },
{ number: 3, title: "Operations & Execution" },
{ number: 4, title: "Culture & Team Health" },
{ number: 5, title: "Sales, Marketing & Growth" },
{ number: 6, title: "Financial Health & Metrics" },
{ number: 7, title: "Innovation & Product/Service Strategy" },
{ number: 8, title: "Personal Leadership & Risk" }
];
export default FigmaProgress;

View File

@@ -0,0 +1,127 @@
import React from 'react';
interface FigmaQuestionProps {
questionNumber?: string | number;
title: string;
description?: string;
answer?: string;
onAnswerChange?: (value: string) => void;
onBack?: () => void;
onNext?: () => void;
showNavigation?: boolean;
nextLabel?: string;
backLabel?: string;
className?: string;
}
export const FigmaQuestion: React.FC<FigmaQuestionProps> = ({
questionNumber = 'Q',
title,
description,
answer = '',
onAnswerChange,
onBack,
onNext,
showNavigation = true,
nextLabel = 'Next',
backLabel = 'Back',
className = ''
}) => {
return (
<div className={`w-[600px] px-5 pt-5 pb-6 bg-Other-White rounded-2xl outline outline-1 outline-offset-[-1px] outline-Neutrals-NeutralSlate200 inline-flex flex-col justify-end items-end gap-4 ${className}`}>
{/* Question Header */}
<div className="self-stretch inline-flex justify-start items-start gap-3">
<div className="justify-start text-zinc-300 text-xl font-medium font-['Inter'] leading-loose">
{questionNumber}
</div>
<div className="flex-1 inline-flex flex-col justify-start items-start gap-2">
<div className="self-stretch justify-start text-Neutrals-NeutralSlate950 text-xl font-semibold font-['Inter'] leading-loose">
{title}
</div>
{description && (
<div className="self-stretch justify-start text-Neutrals-NeutralSlate500 text-sm font-normal font-['Inter'] leading-tight">
{description}
</div>
)}
</div>
</div>
{/* Separator */}
<div data-svg-wrapper>
<svg width="563" height="5" viewBox="0 0 563 5" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_37_3168)">
<path d="M1.5 1L561.5 1" stroke="var(--Neutrals-NeutralSlate200, #E9EAEB)" />
</g>
<defs>
<filter id="filter0_d_37_3168" x="0" y="0.5" width="563" height="4" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="1.5" />
<feGaussianBlur stdDeviation="0.75" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_37_3168" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_37_3168" result="shape" />
</filter>
</defs>
</svg>
</div>
{/* Answer Section */}
<div className="self-stretch inline-flex justify-start items-center gap-3">
<div className="justify-start text-zinc-300 text-xl font-medium font-['Inter'] leading-loose">A</div>
<div className="flex-1">
<textarea
value={answer}
onChange={(e) => onAnswerChange?.(e.target.value)}
placeholder="Type your answer...."
className="w-full bg-transparent outline-none resize-none text-Neutrals-NeutralSlate950 text-base font-normal font-['Inter'] leading-normal placeholder:text-Neutrals-NeutralSlate500 min-h-[100px]"
/>
</div>
</div>
{/* Navigation */}
{showNavigation && (
<div className="inline-flex justify-start items-center gap-3">
{onBack && (
<button
onClick={onBack}
className="px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] flex justify-center items-center gap-1 overflow-hidden hover:bg-Neutrals-NeutralSlate200 transition-colors"
>
<div data-svg-wrapper className="relative">
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 15L8 10L13 5" stroke="var(--Neutrals-NeutralSlate950, #0A0D12)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="px-1 flex justify-center items-center">
<div className="justify-center text-Neutrals-NeutralSlate950 text-sm font-medium font-['Inter'] leading-tight">
{backLabel}
</div>
</div>
</button>
)}
{onNext && (
<button
onClick={onNext}
className="px-4 py-3.5 bg-Brand-Orange rounded-[999px] outline outline-2 outline-offset-[-2px] outline-blue-400 flex justify-center items-center gap-1 overflow-hidden hover:bg-orange-600 transition-colors"
>
<div className="px-1 flex justify-center items-center">
<div className="justify-center text-Other-White text-sm font-medium font-['Inter'] leading-tight">
{nextLabel}
</div>
</div>
<div data-svg-wrapper className="relative">
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 15L13 10L8 5" stroke="var(--Neutrals-NeutralSlate0, #FDFDFD)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</button>
)}
</div>
)}
</div>
);
};
export default FigmaQuestion;

35
components/ui/Alert.tsx Normal file
View File

@@ -0,0 +1,35 @@
import React from 'react';
type AlertVariant = 'info' | 'success' | 'warning' | 'error';
const variantStyles: Record<AlertVariant, string> = {
info: 'bg-blue-50 text-blue-800 border-blue-300 dark:bg-blue-900/30 dark:text-blue-200 dark:border-blue-700',
success: 'bg-green-50 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-200 dark:border-green-700',
warning: 'bg-amber-50 text-amber-800 border-amber-300 dark:bg-amber-900/30 dark:text-amber-200 dark:border-amber-700',
error: 'bg-red-50 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-200 dark:border-red-700'
};
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
variant?: AlertVariant;
children?: React.ReactNode;
icon?: React.ReactNode;
compact?: boolean;
}
export const Alert: React.FC<AlertProps> = ({ title, variant = 'info', children, icon, compact, className, ...rest }) => {
return (
<div
className={`border rounded-md ${variantStyles[variant]} ${compact ? 'px-3 py-2 text-sm' : 'px-4 py-3'} flex items-start space-x-3 ${className || ''}`}
{...rest}
>
{icon && <div className="pt-0.5">{icon}</div>}
<div className="flex-1">
{title && <div className="font-medium mb-0.5">{title}</div>}
{children && <div className="leading-snug">{children}</div>}
</div>
</div>
);
};
export default Alert;

View File

@@ -0,0 +1,25 @@
import React from 'react';
export interface BreadcrumbItem { label: string; href?: string; }
export const Breadcrumbs: React.FC<{ items: BreadcrumbItem[]; onNavigate?: (href: string) => void; }> = ({ items, onNavigate }) => (
<nav className="text-sm text-[--text-secondary]" aria-label="Breadcrumb">
<ol className="flex flex-wrap items-center gap-1">
{items.map((item, i) => (
<li key={i} className="flex items-center">
{item.href ? (
<button
onClick={() => onNavigate?.(item.href!)}
className="text-blue-600 hover:underline dark:text-blue-400"
>{item.label}</button>
) : (
<span className="text-[--text-primary] font-medium">{item.label}</span>
)}
{i < items.length - 1 && <span className="mx-2 text-[--border-color]">/</span>}
</li>
))}
</ol>
</nav>
);
export default Breadcrumbs;

1
components/ui/Icons.tsx Normal file
View File

@@ -0,0 +1 @@
export { CompanyWikiIcon, SubmissionsIcon, ReportsIcon, ChatIcon, HelpIcon, SettingsIcon, CopyIcon, PlusIcon, ChevronDownIcon, UploadIcon, CheckIcon, WarningIcon, DownloadIcon, MinusIcon, SunIcon, MoonIcon, SystemIcon, SendIcon } from '../UiKit';

49
components/ui/Inputs.tsx Normal file
View File

@@ -0,0 +1,49 @@
import React from 'react';
interface BaseFieldProps { label?: string; description?: string; error?: string; required?: boolean; children: React.ReactNode; className?: string; }
export const Field: React.FC<BaseFieldProps> = ({ label, description, error, required, children, className }) => (
<div className={`space-y-2 ${className || ''}`}>
{label && (
<label className="block text-sm font-medium text-[--text-primary] tracking-[-0.14px]">
{label} {required && <span className="text-[--status-red]">*</span>}
</label>
)}
{children}
{description && !error && <p className="text-sm text-[--text-secondary] tracking-[-0.14px]">{description}</p>}
{error && <p className="text-sm text-[--status-red]">{error}</p>}
</div>
);
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { invalid?: boolean; }
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, invalid, ...rest }, ref) => (
<input
ref={ref}
className={`w-full px-4 py-3.5 rounded-full border text-sm bg-[--input-bg] text-[--text-primary] placeholder:text-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent] border-[--input-border] transition-all duration-200 ${invalid ? 'border-red-500 focus:ring-red-500' : ''} ${className || ''}`}
{...rest}
/>
));
Input.displayName = 'Input';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { invalid?: boolean; }
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, invalid, ...rest }, ref) => (
<textarea
ref={ref}
className={`w-full px-4 py-3.5 rounded-2xl border text-sm resize-vertical bg-[--input-bg] text-[--text-primary] placeholder:text-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent] border-[--input-border] transition-all duration-200 ${invalid ? 'border-red-500 focus:ring-red-500' : ''} ${className || ''}`}
{...rest}
/>
));
Textarea.displayName = 'Textarea';
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> { invalid?: boolean; }
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(({ className, invalid, children, ...rest }, ref) => (
<select
ref={ref}
className={`w-full px-4 py-3.5 rounded-full border text-sm bg-[--input-bg] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent] border-[--input-border] transition-all duration-200 ${invalid ? 'border-red-500 focus:ring-red-500' : ''} ${className || ''}`}
{...rest}
>
{children}
</select>
));
Select.displayName = 'Select';
export default { Field, Input, Textarea, Select };

View File

@@ -0,0 +1,24 @@
import React from 'react';
export const LinearProgress: React.FC<{ value: number; className?: string; }> = ({ value, className }) => (
<div className={`w-full h-2 rounded-full bg-[--background-tertiary] overflow-hidden ${className || ''}`}>
<div className="h-full bg-blue-500 transition-all" style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
</div>
);
export interface StepProgressProps { current: number; total: number; labels?: string[]; }
export const StepProgress: React.FC<StepProgressProps> = ({ current, total, labels }) => (
<div>
<div className="flex justify-between mb-2">
{Array.from({ length: total }).map((_, i) => (
<div key={i} className="flex-1 flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold mb-1 ${i <= current ? 'bg-blue-500 text-white' : 'bg-[--background-tertiary] text-[--text-secondary]'}`}>{i + 1}</div>
{labels && labels[i] && <span className="text-[10px] text-center px-1 text-[--text-secondary] truncate max-w-[72px]">{labels[i]}</span>}
</div>
))}
</div>
<LinearProgress value={((current + 1) / total) * 100} />
</div>
);
export default { LinearProgress, StepProgress };

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { Field } from './Inputs';
interface QuestionProps { label: string; required?: boolean; description?: string; error?: string; children: React.ReactNode; }
export const Question: React.FC<QuestionProps> = ({ label, required, description, error, children }) => (
<Field label={label} required={required} description={description} error={error}>{children}</Field>
);
export default Question;

View File

@@ -0,0 +1,182 @@
import React from 'react';
import { EmployeeQuestion, EMPLOYEE_QUESTIONS } from '../../employeeQuestions';
import { Input, Textarea } from './Inputs';
interface QuestionInputProps {
question: EmployeeQuestion;
value: string;
onChange: (value: string) => void;
className?: string;
// For yes/no questions with follow-ups, we need access to all answers and ability to set follow-up
allAnswers?: Record<string, string>;
onFollowupChange?: (questionId: string, value: string) => void;
}
// Helper function to find follow-up question for a given question
const findFollowupQuestion = (questionId: string): EmployeeQuestion | null => {
return EMPLOYEE_QUESTIONS.find(q => q.followupTo === questionId) || null;
};
export const QuestionInput: React.FC<QuestionInputProps> = ({
question,
value,
onChange,
className = '',
allAnswers = {},
onFollowupChange
}) => {
const baseInputClasses = "w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500";
switch (question.type) {
case 'text':
return (
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder}
className={className}
/>
);
case 'textarea':
return (
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder}
className={className}
rows={4}
/>
);
case 'yesno':
const followupQuestion = findFollowupQuestion(question.id);
const followupValue = followupQuestion ? allAnswers[followupQuestion.id] || '' : '';
return (
<div className={`space-y-4 ${className}`}>
{/* Yes/No Radio Buttons */}
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={question.id}
value="Yes"
checked={value === 'Yes'}
onChange={(e) => onChange(e.target.value)}
className="w-4 h-4 text-[--accent] border-[--border-color] focus:ring-[--accent]"
/>
<span className="text-[--text-primary]">Yes</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={question.id}
value="No"
checked={value === 'No'}
onChange={(e) => onChange(e.target.value)}
className="w-4 h-4 text-[--accent] border-[--border-color] focus:ring-[--accent]"
/>
<span className="text-[--text-primary]">No</span>
</label>
</div>
{/* Conditional Follow-up Textarea */}
{followupQuestion && value === 'Yes' && (
<div className="space-y-2">
<label className="block text-sm font-medium text-[--text-primary] tracking-[-0.14px]">
{followupQuestion.prompt}
</label>
<Textarea
value={followupValue}
onChange={(e) => onFollowupChange?.(followupQuestion.id, e.target.value)}
placeholder={followupQuestion.placeholder}
rows={3}
className="w-full"
/>
</div>
)}
</div>
);
case 'scale':
const scaleMin = question.scaleMin || 1;
const scaleMax = question.scaleMax || 10;
const currentValue = parseInt(value) || scaleMin;
return (
<div className={`space-y-3 ${className}`}>
<div className="flex w-full justify-between text-sm text-[--text-secondary]">
<span>{question.scaleLabels?.min || `${scaleMin}`}</span>
<span>{question.scaleLabels?.max || `${scaleMax}`}</span>
</div>
{/* Grid container that aligns slider and numbers */}
<div className="grid grid-cols-[auto_1fr_auto] gap-4 items-center">
{/* Left label space - dynamically sized */}
<div className="text-xs -me-12 text-transparent select-none">
{question.scaleLabels?.min || `${scaleMin}`}
</div>
{/* Slider container */}
<div className="relative">
<input
type="range"
min={scaleMin}
max={scaleMax}
value={currentValue}
onChange={(e) => onChange(e.target.value)}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
/>
{/* Numbers positioned absolutely under the slider */}
<div className="flex justify-between absolute -bottom-5 left-0 right-0 text-xs text-[--text-secondary]">
{Array.from({ length: scaleMax - scaleMin + 1 }, (_, i) => (
<span key={i} className="w-4 text-center">
{scaleMin + i}
</span>
))}
</div>
</div>
{/* Current value badge */}
<div className="w-12 h-8 bg-[--accent] text-white rounded flex items-center justify-center text-sm font-medium">
{currentValue}
</div>
</div>
{/* Add some bottom padding to account for the absolute positioned numbers */}
<div className="h-4"></div>
</div>
);
case 'select':
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${baseInputClasses} ${className}`}
>
<option value="">Select an option...</option>
{question.options?.map((option, index) => (
<option key={index} value={option}>
{option}
</option>
))}
</select>
);
default:
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || "Type your answer here..."}
className={`${baseInputClasses} min-h-[100px] resize-vertical ${className}`}
rows={4}
/>
);
}
};
export default QuestionInput;

11
components/ui/Table.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
export const Table: React.FC<React.TableHTMLAttributes<HTMLTableElement>> = ({ className, children, ...rest }) => (
<table className={`w-full text-sm border-separate border-spacing-0 ${className || ''}`} {...rest}>{children}</table>
);
export const THead: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({ className, children, ...rest }) => (<thead className={`${className || ''}`} {...rest}>{children}</thead>);
export const TBody: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({ className, children, ...rest }) => (<tbody className={`${className || ''}`} {...rest}>{children}</tbody>);
export const TR: React.FC<React.HTMLAttributes<HTMLTableRowElement>> = ({ className, children, ...rest }) => (<tr className={`hover:bg-[--background-tertiary] transition-colors ${className || ''}`} {...rest}>{children}</tr>);
export const TH: React.FC<React.ThHTMLAttributes<HTMLTableHeaderCellElement>> = ({ className, children, ...rest }) => (<th className={`text-left font-medium px-4 py-2 text-[--text-secondary] border-b border-[--border-color] bg-[--background-secondary] first:rounded-tl-md last:rounded-tr-md ${className || ''}`} {...rest}>{children}</th>);
export const TD: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>> = ({ className, children, ...rest }) => (<td className={`px-4 py-2 border-b border-[--border-color] text-[--text-primary] ${className || ''}`} {...rest}>{children}</td>);
export default { Table, THead, TBody, TR, TH, TD };

8
components/ui/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from './Alert';
export * from './Breadcrumb';
export * from './Icons';
export * from './Inputs';
export * from './Progress';
export * from './Question';
export * from './QuestionInput';
export * from './Table';

208
constants.ts Normal file
View File

@@ -0,0 +1,208 @@
import { Employee, Report, Submission, FaqItem, CompanyReport } from './types';
// URL Configuration - reads from environment variables with fallbacks
export const SITE_URL = import.meta.env.VITE_SITE_URL || 'http://localhost:5173';
// API Base URL - auto-detect development vs production
const isLocalhost = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
// Use Firebase Functions emulator in development, production functions in production
export const API_URL = isLocalhost
? 'http://127.0.0.1:5002/auditly-c0027/us-central1' // Firebase Functions Emulator
: 'https://your-project.cloudfunctions.net'; // Production Firebase Functions
// Log URL configuration in development
if (import.meta.env.DEV) {
console.log('🌐 Frontend URL Configuration:');
console.log(` SITE_URL: ${SITE_URL}`);
console.log(` API_URL: ${API_URL}`);
}
export const EMPLOYEES: Employee[] = [
{ id: 'AG', name: 'Alex Green', initials: 'AG', email: 'alex.green@zitlac.com', department: 'Influencer Marketing', role: 'Influencer Coordinator & Business Development Outreach' },
{ id: 'MB', name: 'Michael Brown', initials: 'MB', email: 'michael.brown@zitlac.com', department: 'Engineering', role: 'Senior Developer' },
{ id: 'KT', name: 'Kevin Taylor', initials: 'KT', email: 'kevin.taylor@zitlac.com', department: 'Marketing', role: 'Marketing Manager' },
{ id: 'LR', name: 'Laura Robinson', initials: 'LR', email: 'laura.robinson@zitlac.com', department: 'HR', role: 'HR Manager', isOwner: true },
{ id: 'DS', name: 'David Stone', initials: 'DS', email: 'david.stone@zitlac.com', department: 'Sales', role: 'Sales Representative' },
{ id: 'SR', name: 'Samantha Reed', initials: 'SR', email: 'samantha.reed@zitlac.com', department: 'Operations', role: 'Operations Specialist' },
];
export const REPORT_DATA: Report = {
employeeId: 'AG',
department: 'Influencer Marketing',
role: 'Influencer Coordinator & Business Development Outreach',
roleAndOutput: {
responsibilities: 'Recruiting influencers, onboarding, campaign support, business development.',
clarityOnRole: '10/10 - Feels very clear on responsibilities.',
selfRatedOutput: '7/10 - Indicates decent performance but room to grow.',
recurringTasks: 'Influencer outreach, onboarding, communications.',
},
insights: {
personalityTraits: 'Loyal, well-liked by influencers, eager to grow, client-facing interest.',
psychologicalIndicators: [
'Scores high on optimism and external motivation.',
'Shows ambition but lacks self-discipline in execution.',
'Displays a desire for recognition and community; seeks more appreciation.',
],
selfAwareness: 'High - acknowledges weaknesses like lateness and disorganization.',
emotionalResponses: 'Frustrated by campaign disorganization; would prefer closer collaboration.',
growthDesire: 'Interested in becoming more client-facing and shifting toward biz dev.',
},
strengths: [
'Builds strong relationships with influencers.',
'Has sales and outreach potential.',
'Loyal, driven, and values-aligned with the company mission.',
'Open to feedback and self-improvement.',
],
weaknesses: [
{ isCritical: true, description: 'Disorganized and late with deliverables — confirmed by previous internal notes.' },
{ isCritical: false, description: 'Poor implementation and recruiting output — does not effectively close the loop on influencer onboarding.' },
{ isCritical: false, description: 'May unintentionally cause friction with campaigns team by stepping outside process boundaries.' },
],
opportunities: {
roleAdjustment: 'Shift fully to Influencer Manager & Biz Dev Outreach as planned. Remove all execution and recruitment responsibilities.',
accountabilitySupport: "Pair with a high-output implementer (new hire) to balance Gentry's strategic skills.",
},
risks: [
"Without strict structure, Gentry's performance will stay flat or become a bottleneck.",
'If kept in a dual-role (recruiting + outreach), productivity will suffer.',
'He needs system constraints and direct oversight to stay focused.',
],
recommendation: {
action: 'Keep',
details: [
'But immediately restructure his role:',
'• Remove recruiting and logistical tasks.',
'• Focus only on influencer relationship-building, pitching, and business development.',
"Pair him with a new hire who is ultra-organized and can execute on Gentry's deals.",
],
},
grading: [],
};
export const SUBMISSIONS_DATA: Submission = {
employeeId: 'AG',
answers: [
{
question: 'What is the mission of your company?',
answer: 'To empower small businesses with AI-driven automation tools that increase efficiency and reduce operational overhead.',
},
{
question: 'How has your mission evolved in the last 1-3 years?',
answer: 'We shifted from general SaaS tools to vertical-specific solutions, with deeper integrations and onboarding support.',
},
{
question: 'What is your 5-year vision for the company?',
answer: 'To become the leading AI operations platform for SMBs in North America, serving over 100,000 customers.',
},
{
question: "What are your company's top 3 strategic advantages?",
answer: '1. Fast product iteration enabled by in-house AI capabilities\n2. Deep customer understanding from vertical specialization\n3. High customer retention due to integrated onboarding',
},
{
question: 'What are your biggest vulnerabilities or threats?',
answer: 'Dependence on a single marketing channel, weak middle management, and rising customer acquisition costs.',
},
],
};
export const FAQ_DATA: FaqItem[] = [
{
question: "What is the process for submitting a support ticket?",
answer: "Team members will undergo evaluations every three months, focusing on their performance, teamwork, and communication skills. Role advancements will be considered at these intervals."
},
{
question: "How can I reset my password?",
answer: "You can reset your password by clicking the 'Forgot Password' link on the login page. An email will be sent to you with instructions."
},
{
question: "What are the criteria for performance reviews?",
answer: "Performance reviews are based on a combination of self-assessment, peer feedback, and manager evaluation. Key criteria include goal achievement, collaboration, and contribution to company values."
},
{
question: "How can I access the company's training resources?",
answer: "All training resources are available in the 'Company Wiki' section of the platform. You can find documents, videos, and links to external courses."
},
{
question: "What should I do if I encounter a technical issue?",
answer: "For any technical issues, please submit a ticket through the 'Help & Support' page. Our IT team will get back to you within 24 hours."
},
{
question: "How do I provide feedback on team projects?",
answer: "Feedback can be provided directly within the project management tool or during scheduled team retrospectives. We encourage open and constructive communication."
}
];
export const CHAT_STARTERS = [
"Summarize Alex Green's latest report.",
"What are Alex's biggest strengths?",
"Identify any risks associated with Alex.",
"Should Alex be considered for a promotion?"
];
export const SAMPLE_COMPANY_REPORT: CompanyReport = {
id: 'sample-company-report',
createdAt: Date.now() - 86400000, // 1 day ago
overview: {
totalEmployees: 0, // Fixed: Start with 0 employees instead of hardcoded 6
departmentBreakdown: [],
submissionRate: 0,
lastUpdated: Date.now() - 86400000
},
gradingBreakdown: [],
operatingPlan: { nextQuarterGoals: [], keyInitiatives: [], resourceNeeds: [], riskMitigation: [] },
personnelChanges: { newHires: [], promotions: [], departures: [] },
keyPersonnelChanges: [
{ employeeName: "Alex Green", department: "Influencer Marketing", role: "Influencer Coordinator", changeType: "newHire" },
{ employeeName: "Jordan Smith", department: "Engineering", role: "Software Engineer", changeType: "promotion" }
],
immediateHiringNeeds: [
{
department: 'Engineering',
role: 'Frontend Developer',
priority: 'High',
reasoning: 'Growing product development workload requires additional frontend expertise'
},
{
department: 'Marketing',
role: 'Content Creator',
priority: 'Medium',
reasoning: 'Increasing content demands for influencer campaigns'
}
],
forwardOperatingPlan: {
quarterlyGoals: [
'Expand influencer network by 40%',
'Launch automated campaign tracking system',
'Implement comprehensive onboarding process',
'Increase team collaboration efficiency by 25%'
],
resourceNeeds: [
'Additional engineering talent',
'Enhanced project management tools',
'Training budget for skill development',
'Upgraded communication infrastructure'
],
riskMitigation: [
'Cross-train team members to reduce single points of failure',
'Implement backup processes for critical operations',
'Regular performance reviews and feedback cycles',
'Diversify client base to reduce dependency risks'
]
},
organizationalStrengths: [
],
organizationalRisks: [
'Key personnel dependency in critical roles',
'Limited project management oversight',
'Potential burnout from rapid growth',
'Communication gaps between departments'
],
gradingOverview: {
"overallGrade": 4,
"strengths": 3,
"weaknesses": 1
},
executiveSummary: `Your organization is ready to get started with employee assessments. Begin by inviting team members to complete their questionnaires and build comprehensive insights about your workforce.`
};

234
contexts/AuthContext.tsx Normal file
View File

@@ -0,0 +1,234 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { onAuthStateChanged, signInWithPopup, signOut, User, createUserWithEmailAndPassword, signInWithEmailAndPassword, updateProfile } from 'firebase/auth';
import { auth, googleProvider, isFirebaseConfigured } from '../services/firebase';
import { demoStorage } from '../services/demoStorage';
import { API_URL } from '../constants';
interface AuthContextType {
user: User | null;
loading: boolean;
signInWithGoogle: () => Promise<void>;
signOutUser: () => Promise<void>;
signInWithEmail: (email: string, password: string) => Promise<void>;
signUpWithEmail: (email: string, password: string, displayName?: string) => Promise<void>;
sendOTP: (email: string, inviteCode?: string) => Promise<any>;
verifyOTP: (email: string, otp: string, inviteCode?: string) => Promise<void>;
signInWithOTP: (token: string, userData: any) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log('AuthContext initializing, isFirebaseConfigured:', isFirebaseConfigured);
if (!isFirebaseConfigured) {
// Demo mode: check for persisted session
console.log('Demo mode: checking for persisted session');
const sessionUser = sessionStorage.getItem('auditly_demo_session');
if (sessionUser) {
const parsedUser = JSON.parse(sessionUser);
console.log('Restoring demo session for:', parsedUser.email);
setUser(parsedUser as User);
}
setLoading(false);
return () => { };
}
console.log('Setting up Firebase auth listener');
const unsub = onAuthStateChanged(auth, (u) => {
console.log('Auth state changed:', u);
setUser(u);
setLoading(false);
});
return () => unsub();
}, []);
const signInWithGoogle = async () => {
if (!isFirebaseConfigured) {
// No-op in demo mode
return;
}
await signInWithPopup(auth, googleProvider);
};
const signOutUser = async () => {
if (!isFirebaseConfigured) {
// Clear demo session
sessionStorage.removeItem('auditly_demo_session');
setUser(null);
return;
}
await signOut(auth);
};
const signInWithEmail = async (email: string, password: string) => {
console.log('signInWithEmail called, isFirebaseConfigured:', isFirebaseConfigured);
if (!isFirebaseConfigured) {
console.log('Demo mode: authenticating user', email);
const existingUser = demoStorage.getUserByEmail(email);
if (existingUser) {
// Verify password
if (demoStorage.verifyPassword(password, existingUser.passwordHash)) {
const mockUser = {
uid: existingUser.uid,
email: existingUser.email,
displayName: existingUser.displayName
} as unknown as User;
setUser(mockUser);
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
console.log('Demo login successful for:', email);
} else {
throw new Error('Invalid password');
}
} else {
throw new Error('User not found. Please sign up first.');
}
return;
}
try {
console.log('Attempting Firebase auth');
await signInWithEmailAndPassword(auth, email, password);
} catch (e: any) {
const code = e?.code || '';
console.error('Firebase Auth Error:', code, e?.message);
if (code === 'auth/configuration-not-found' || code === 'auth/operation-not-allowed') {
console.warn('Email/Password provider disabled in Firebase. Falling back to local mock user for development.');
const mock = { uid: `demo-${btoa(email).slice(0, 8)}`, email, displayName: email.split('@')[0] } as unknown as User;
setUser(mock);
return;
}
throw e;
}
};
const signUpWithEmail = async (email: string, password: string, displayName?: string) => {
if (!isFirebaseConfigured) {
console.log('Demo mode: creating new user', email);
// Check if user already exists
const existingUser = demoStorage.getUserByEmail(email);
if (existingUser) {
throw new Error('User already exists with this email');
}
// Create new user
const uid = `demo-${btoa(email).slice(0, 8)}`;
const newUser = {
uid,
email,
displayName: displayName || email.split('@')[0],
passwordHash: demoStorage.hashPassword(password)
};
demoStorage.saveUser(newUser);
const mockUser = {
uid: newUser.uid,
email: newUser.email,
displayName: newUser.displayName
} as unknown as User;
setUser(mockUser);
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
console.log('Demo signup successful for:', email);
return;
}
try {
const cred = await createUserWithEmailAndPassword(auth, email, password);
if (displayName) {
try { await updateProfile(cred.user, { displayName }); } catch { }
}
} catch (e: any) {
const code = e?.code || '';
if (code === 'auth/configuration-not-found' || code === 'auth/operation-not-allowed') {
console.warn('Email/Password provider disabled in Firebase. Falling back to local mock user for development.');
const mock = { uid: `demo-${btoa(email).slice(0, 8)}`, email, displayName: displayName || email.split('@')[0] } as unknown as User;
setUser(mock);
return;
}
throw e;
}
};
const sendOTP = async (email: string, inviteCode?: string) => {
const response = await fetch(`${API_URL}/sendOTP`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, inviteCode })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to send OTP');
}
return response.json();
};
const verifyOTP = async (email: string, otp: string, inviteCode?: string) => {
const response = await fetch(`${API_URL}/verifyOTP`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, otp, inviteCode })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to verify OTP');
}
const data = await response.json();
// Set user in auth context
const mockUser = {
uid: data.user.uid,
email: data.user.email,
displayName: data.user.displayName,
emailVerified: true
} as unknown as User;
setUser(mockUser);
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
sessionStorage.setItem('auditly_auth_token', data.token);
return data;
};
const signInWithOTP = async (token: string, userData: any) => {
const mockUser = {
uid: userData.uid,
email: userData.email,
displayName: userData.displayName,
emailVerified: true
} as unknown as User;
setUser(mockUser);
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
sessionStorage.setItem('auditly_auth_token', token);
};
return (
<AuthContext.Provider value={{
user,
loading,
signInWithGoogle,
signOutUser,
signInWithEmail,
signUpWithEmail,
sendOTP,
verifyOTP,
signInWithOTP
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
};

799
contexts/OrgContext.tsx Normal file
View File

@@ -0,0 +1,799 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { collection, doc, getDoc, getDocs, onSnapshot, setDoc } from 'firebase/firestore';
import { db, isFirebaseConfigured } from '../services/firebase';
import { useAuth } from './AuthContext';
import { Employee, Report, Submission, CompanyReport } from '../types';
import { REPORT_DATA, SUBMISSIONS_DATA, SAMPLE_COMPANY_REPORT, API_URL } from '../constants';
import { demoStorage } from '../services/demoStorage';
interface OrgData {
orgId: string;
name: string;
industry?: string;
size?: string;
description?: string;
mission?: string;
vision?: string;
values?: string;
foundingYear?: string;
evolution?: string;
majorMilestones?: string;
advantages?: string;
vulnerabilities?: string;
competitors?: string;
marketPosition?: string;
currentChallenges?: string;
shortTermGoals?: string;
longTermGoals?: string;
keyMetrics?: string;
cultureDescription?: string;
workEnvironment?: string;
leadershipStyle?: string;
communicationStyle?: string;
additionalContext?: string;
onboardingCompleted?: boolean;
}
interface OrgContextType {
org: OrgData | null;
orgId: string;
employees: Employee[];
submissions: Record<string, Submission>;
reports: Record<string, Report>;
upsertOrg: (data: Partial<OrgData>) => Promise<void>;
saveReport: (employeeId: string, report: Report) => Promise<void>;
inviteEmployee: (args: { name: string; email: string }) => Promise<{ employeeId: string; inviteLink: string }>;
issueInviteViaApi: (args: { name: string; email: string; role?: string; department?: string }) => Promise<{ code: string; inviteLink: string; emailLink: string; employee: any }>;
getInviteStatus: (code: string) => Promise<{ used: boolean; employee: any } | null>;
consumeInvite: (code: string) => Promise<{ employee: any } | null>;
getReportVersions: (employeeId: string) => Promise<Array<{ id: string; createdAt: number; report: Report }>>;
saveReportVersion: (employeeId: string, report: Report) => Promise<void>;
acceptInvite: (code: string) => Promise<void>;
saveCompanyReport: (summary: string) => Promise<void>;
getCompanyReportHistory: () => Promise<Array<{ id: string; createdAt: number; summary: string }>>;
saveFullCompanyReport: (report: CompanyReport) => Promise<void>;
getFullCompanyReportHistory: () => Promise<CompanyReport[]>;
generateCompanyReport: () => Promise<CompanyReport>;
generateCompanyWiki: (orgOverride?: OrgData) => Promise<CompanyReport>;
seedInitialData: () => Promise<void>;
isOwner: (employeeId?: string) => boolean;
submitEmployeeAnswers: (employeeId: string, answers: Record<string, string>) => Promise<boolean>;
generateEmployeeReport: (employee: Employee) => Promise<Report | null>;
getEmployeeReport: (employeeId: string) => Promise<{ success: boolean; report?: Report; error?: string }>;
getEmployeeReports: () => Promise<{ success: boolean; reports?: Report[]; error?: string }>;
}
const OrgContext = createContext<OrgContextType | undefined>(undefined);
export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: string }> = ({ children, selectedOrgId }) => {
const { user } = useAuth();
const [org, setOrg] = useState<OrgData | null>(null);
const [employees, setEmployees] = useState<Employee[]>([]);
const [submissions, setSubmissions] = useState<Record<string, Submission>>({});
const [reports, setReports] = useState<Record<string, Report>>({});
const [reportVersions, setReportVersions] = useState<Record<string, Array<{ id: string; createdAt: number; report: Report }>>>({});
const [companyReports, setCompanyReports] = useState<Array<{ id: string; createdAt: number; summary: string }>>([]);
const [fullCompanyReports, setFullCompanyReports] = useState<CompanyReport[]>([]);
// Use the provided selectedOrgId instead of deriving from user
const orgId = selectedOrgId;
useEffect(() => {
console.log('OrgContext effect running, orgId:', orgId, 'isFirebaseConfigured:', isFirebaseConfigured);
if (!orgId) return; // Wait for orgId to be available
if (!isFirebaseConfigured) {
// Demo mode data - use persistent localStorage with proper initialization
console.log('Setting up demo org data with persistence');
// Get or create persistent demo org
let demoOrg = demoStorage.getOrganization(orgId);
if (!demoOrg) {
demoOrg = {
orgId: orgId,
name: 'Demo Company',
onboardingCompleted: false
};
demoStorage.saveOrganization(demoOrg);
// Initialize with empty employee list for clean start
// (Removed automatic seeding of 6 default employees per user feedback)
// Create sample submissions for multiple employees
const sampleSubmissions = [
{
employeeId: 'AG',
orgId,
createdAt: Date.now(),
answers: {
role_clarity: "I understand my role very clearly as Influencer Coordinator & Business Development Outreach.",
key_outputs: "Recruited 15 new influencers, managed 8 campaigns, initiated 3 business development partnerships.",
bottlenecks: "Campaign organization could be better, sometimes unclear on priorities between recruiting and outreach.",
hidden_talent: "Strong relationship building skills that could be leveraged for client-facing work.",
retention_risk: "Happy with the company but would like more structure and clearer processes.",
energy_distribution: "50% influencer recruiting, 30% campaign support, 20% business development outreach.",
performance_indicators: "Good influencer relationships, but delivery timeline improvements needed.",
workflow: "Morning outreach, afternoon campaign work, weekly business development calls."
}
},
{
employeeId: 'MB',
orgId,
createdAt: Date.now(),
answers: {
role_clarity: "I understand my role as a Senior Developer very clearly. I'm responsible for architecting solutions, code reviews, and mentoring junior developers.",
key_outputs: "Delivered 3 major features this quarter, reduced technical debt by 20%, and led code review process improvements.",
bottlenecks: "Sometimes waiting for design specs from the product team, and occasional deployment pipeline issues.",
hidden_talent: "I have strong business analysis skills and could help bridge the gap between technical and business requirements.",
retention_risk: "I'm satisfied with my current role and compensation. The only concern would be limited growth opportunities.",
energy_distribution: "80% development work, 15% mentoring, 5% planning and architecture.",
performance_indicators: "Code quality metrics improved, zero production bugs in my recent releases, positive peer feedback.",
workflow: "Morning standup, focused coding blocks, afternoon reviews and collaboration, weekly planning sessions."
}
},
{
employeeId: 'KT',
orgId,
createdAt: Date.now(),
answers: {
role_clarity: "My role as Marketing Manager is clear - I oversee campaigns, analyze performance metrics, and coordinate with sales.",
key_outputs: "Launched 5 successful campaigns this quarter, increased lead quality by 30%, improved attribution tracking.",
bottlenecks: "Limited budget for premium tools, sometimes slow approval process for creative assets.",
hidden_talent: "I have experience with data science and could help build predictive models for customer behavior.",
retention_risk: "Overall happy, but would like more strategic input in product positioning and pricing decisions.",
energy_distribution: "40% campaign execution, 30% analysis and reporting, 20% strategy, 10% team coordination.",
performance_indicators: "Campaign ROI improved by 25%, lead conversion rates increased, better cross-team collaboration.",
workflow: "Weekly campaign planning, daily performance monitoring, bi-weekly strategy reviews, monthly board reporting."
}
}
];
// Save all sample submissions
sampleSubmissions.forEach(submission => {
demoStorage.saveSubmission(submission);
});
// Save sample employee report (only for AG initially)
demoStorage.saveEmployeeReport(orgId, REPORT_DATA.employeeId, REPORT_DATA);
// Save sample company report
demoStorage.saveCompanyReport(orgId, SAMPLE_COMPANY_REPORT);
}
// Load persistent demo data
setOrg({ orgId, name: demoOrg.name, onboardingCompleted: demoOrg.onboardingCompleted });
// Convert employees to expected format
const demoEmployees = demoStorage.getEmployeesByOrg(orgId);
const convertedEmployees: Employee[] = demoEmployees.map(emp => ({
id: emp.id,
name: emp.name,
email: emp.email,
initials: emp.name ? emp.name.split(' ').map(n => n[0]).join('').toUpperCase() : emp.email.substring(0, 2).toUpperCase(),
department: emp.department,
role: emp.role,
isOwner: emp.id === user?.uid
}));
setEmployees(convertedEmployees);
// Convert submissions to expected format
const orgSubmissions = demoStorage.getSubmissionsByOrg(orgId);
const convertedSubmissions: Record<string, Submission> = {};
Object.entries(orgSubmissions).forEach(([employeeId, demoSub]) => {
convertedSubmissions[employeeId] = {
employeeId,
answers: Object.entries(demoSub.answers).map(([question, answer]) => ({
question,
answer
}))
};
});
setSubmissions(convertedSubmissions);
// Convert reports to expected format
const orgReports = demoStorage.getEmployeeReportsByOrg(orgId);
setReports(orgReports);
// Get company reports
const companyReports = demoStorage.getCompanyReportsByOrg(orgId);
setFullCompanyReports(companyReports);
return;
}
console.log('Setting up Firebase org data');
const orgRef = doc(db, 'orgs', orgId);
getDoc(orgRef).then(async (snap) => {
if (snap.exists()) {
setOrg({ orgId, ...(snap.data() as any) });
} else {
const seed = { name: 'Your Company', onboardingCompleted: false };
await setDoc(orgRef, seed);
setOrg({ orgId, ...(seed as any) });
}
});
const employeesCol = collection(db, 'orgs', orgId, 'employees');
const unsubEmp = onSnapshot(employeesCol, (snap) => {
const arr: Employee[] = [];
snap.forEach((d) => arr.push({ id: d.id, ...(d.data() as any) }));
setEmployees(arr);
});
const submissionsCol = collection(db, 'orgs', orgId, 'submissions');
const unsubSub = onSnapshot(submissionsCol, (snap) => {
const map: Record<string, Submission> = {};
snap.forEach((d) => (map[d.id] = { employeeId: d.id, ...(d.data() as any) }));
setSubmissions(map);
});
const reportsCol = collection(db, 'orgs', orgId, 'reports');
const unsubRep = onSnapshot(reportsCol, (snap) => {
const map: Record<string, Report> = {};
snap.forEach((d) => (map[d.id] = { employeeId: d.id, ...(d.data() as any) } as Report));
setReports(map);
});
return () => { unsubEmp(); unsubSub(); unsubRep(); };
}, [orgId]);
const upsertOrg = async (data: Partial<OrgData>) => {
if (!isFirebaseConfigured) {
const updatedOrg = { ...(org || { orgId, name: 'Demo Company' }), ...data } as OrgData;
setOrg(updatedOrg);
// Also sync with server for multi-tenant persistence
try {
const response = await fetch(`${API_URL}/api/organizations/${orgId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
console.warn('Failed to sync organization data with server');
}
} catch (error) {
console.warn('Failed to sync organization data:', error);
}
} else {
// Firebase mode - save to Firestore
const orgRef = doc(db, 'orgs', orgId);
await setDoc(orgRef, data, { merge: true });
// Update local state
const updatedOrg = { ...(org || { orgId, name: 'Your Company' }), ...data } as OrgData;
setOrg(updatedOrg);
}
};
const updateOrg = async (data: Partial<OrgData>) => {
if (!isFirebaseConfigured) {
const updatedOrg = { ...(org || { orgId, name: 'Demo Company' }), ...data } as OrgData;
setOrg(updatedOrg);
// Also sync with server for multi-tenant persistence
try {
const response = await fetch(`${API_URL}/api/organizations/${orgId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
console.warn('Failed to sync organization data with server');
}
} catch (error) {
console.warn('Failed to sync organization data:', error);
}
return;
}
const orgRef = doc(db, 'orgs', orgId);
await setDoc(orgRef, data, { merge: true });
};
const saveReport = async (employeeId: string, report: Report) => {
if (!isFirebaseConfigured) {
setReports(prev => ({ ...prev, [employeeId]: report }));
// Persist to localStorage
demoStorage.saveEmployeeReport(orgId, employeeId, report);
return;
}
const ref = doc(db, 'orgs', orgId, 'reports', employeeId);
await setDoc(ref, report, { merge: true });
};
const inviteEmployee = async ({ name, email }: { name: string; email: string }) => {
// Always use Cloud Functions for invites to ensure multi-tenant compliance
const response = await fetch(`${API_URL}/createInvitation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, orgId })
});
if (!response.ok) {
throw new Error(`Failed to create invite: ${response.status}`);
}
const data = await response.json();
const { code, employee, inviteLink } = data;
// Store employee locally for immediate UI update
if (!isFirebaseConfigured) {
const newEmployee = { ...employee, orgId };
setEmployees(prev => {
if (prev.find(e => e.id === employee.id)) return prev;
return [...prev, newEmployee];
});
demoStorage.saveEmployee(newEmployee);
} else {
// For Firebase, the employee will be created when they accept the invite
// But we can add them to local state for immediate UI update
const newEmployee = { ...employee, orgId };
setEmployees(prev => {
if (prev.find(e => e.id === employee.id)) return prev;
return [...prev, newEmployee];
});
}
return { employeeId: employee.id, inviteLink };
};
const getReportVersions = async (employeeId: string) => {
if (!isFirebaseConfigured) {
return reportVersions[employeeId] || [];
}
const col = collection(db, 'orgs', orgId, 'reports', employeeId, 'versions');
const snap = await getDocs(col);
const arr: Array<{ id: string; createdAt: number; report: Report }> = [];
snap.forEach(d => {
const data = d.data() as any;
arr.push({ id: d.id, createdAt: data.createdAt ?? 0, report: data.report as Report });
});
return arr.sort((a, b) => b.createdAt - a.createdAt);
};
const saveReportVersion = async (employeeId: string, report: Report) => {
const version = { id: Date.now().toString(), createdAt: Date.now(), report };
if (!isFirebaseConfigured) {
setReportVersions(prev => ({ ...prev, [employeeId]: [version, ...(prev[employeeId] || [])] }));
return;
}
const ref = doc(db, 'orgs', orgId, 'reports', employeeId, 'versions', version.id);
await setDoc(ref, { createdAt: version.createdAt, report });
};
const acceptInvite = async (code: string) => {
if (!code) return;
if (!isFirebaseConfigured) {
// Demo mode: mark invite as used
demoStorage.markInviteUsed(code);
return;
}
const inviteRef = doc(db, 'orgs', orgId, 'invites', code);
const snap = await getDoc(inviteRef);
if (!snap.exists()) return;
const data = snap.data() as any;
// Minimal: mark accepted
await setDoc(inviteRef, { ...data, acceptedAt: Date.now() }, { merge: true });
};
const saveCompanyReport = async (summary: string) => {
const id = Date.now().toString();
const createdAt = Date.now();
if (!isFirebaseConfigured) {
const reportData = { id, createdAt, summary };
setCompanyReports(prev => [reportData, ...prev]);
// Persist to localStorage (note: this method stores simple reports)
return;
}
const ref = doc(db, 'orgs', orgId, 'companyReports', id);
await setDoc(ref, { createdAt, summary });
};
const getCompanyReportHistory = async () => {
if (!isFirebaseConfigured) {
return companyReports;
}
const col = collection(db, 'orgs', orgId, 'companyReports');
const snap = await getDocs(col);
const arr: Array<{ id: string; createdAt: number; summary: string }> = [];
snap.forEach(d => {
const data = d.data() as any;
arr.push({ id: d.id, createdAt: data.createdAt ?? 0, summary: data.summary ?? '' });
});
return arr.sort((a, b) => b.createdAt - a.createdAt);
};
const seedInitialData = async () => {
if (!isFirebaseConfigured) {
// Start with empty employee list for clean demo experience
setEmployees([]);
setSubmissions({ [SUBMISSIONS_DATA.employeeId]: SUBMISSIONS_DATA });
setReports({ [REPORT_DATA.employeeId]: REPORT_DATA });
setFullCompanyReports([SAMPLE_COMPANY_REPORT]);
return;
}
// Start with clean slate - let users invite their own employees
// (Removed automatic seeding per user feedback)
};
const saveFullCompanyReport = async (report: CompanyReport) => {
if (!isFirebaseConfigured) {
setFullCompanyReports(prev => [report, ...prev]);
// Persist to localStorage
demoStorage.saveCompanyReport(orgId, report);
return;
}
const ref = doc(db, 'orgs', orgId, 'fullCompanyReports', report.id);
await setDoc(ref, report);
};
const getFullCompanyReportHistory = async (): Promise<CompanyReport[]> => {
if (!isFirebaseConfigured) {
return fullCompanyReports;
}
const col = collection(db, 'orgs', orgId, 'fullCompanyReports');
const snap = await getDocs(col);
const arr: CompanyReport[] = [];
snap.forEach(d => {
arr.push({ id: d.id, ...d.data() } as CompanyReport);
});
return arr.sort((a, b) => b.createdAt - a.createdAt);
};
const generateCompanyReport = async (): Promise<CompanyReport> => {
// Generate comprehensive company report based on current data
const totalEmployees = employees.length;
const submittedEmployees = Object.keys(submissions).length;
const submissionRate = totalEmployees > 0 ? (submittedEmployees / totalEmployees) * 100 : 0;
// Department breakdown
const deptMap = new Map<string, number>();
employees.forEach(emp => {
const dept = emp.department || 'Unassigned';
deptMap.set(dept, (deptMap.get(dept) || 0) + 1);
});
const departmentBreakdown = Array.from(deptMap.entries()).map(([department, count]) => ({ department, count }));
// Analyze employee reports for insights
const reportValues = Object.values(reports) as Report[];
const organizationalStrengths: string[] = [];
const organizationalRisks: string[] = [];
reportValues.forEach(report => {
if (report.strengths) {
organizationalStrengths.push(...report.strengths);
}
if (report.risks) {
organizationalRisks.push(...report.risks);
}
});
// Remove duplicates and take top items
const uniqueStrengths = [...new Set(organizationalStrengths)].slice(0, 5);
const uniqueRisks = [...new Set(organizationalRisks)].slice(0, 5);
const gradingBreakdown = [
{ category: 'Execution', value: 70 + Math.random() * 15 },
{ category: 'People', value: 70 + Math.random() * 15 },
{ category: 'Strategy', value: 65 + Math.random() * 15 },
{ category: 'Risk', value: 60 + Math.random() * 15 }
];
const legacy = gradingBreakdown.reduce<Record<string, number>>((acc, g) => { acc[g.category.toLowerCase()] = Math.round((g.value / 100) * 5 * 10) / 10; return acc; }, {});
const report: CompanyReport = {
id: Date.now().toString(),
createdAt: Date.now(),
overview: {
totalEmployees,
departmentBreakdown,
submissionRate,
lastUpdated: Date.now(),
averagePerformanceScore: gradingBreakdown.reduce((a, g) => a + g.value, 0) / gradingBreakdown.length / 20,
riskLevel: uniqueRisks.length > 4 ? 'High' : uniqueRisks.length > 2 ? 'Medium' : 'Low'
},
personnelChanges: { newHires: [], promotions: [], departures: [] },
immediateHiringNeeds: [],
operatingPlan: {
nextQuarterGoals: ['Increase productivity', 'Implement review system'],
keyInitiatives: ['Mentorship program'],
resourceNeeds: ['Senior engineer'],
riskMitigation: ['Cross-training']
},
forwardOperatingPlan: { // legacy fields
quarterlyGoals: ['Increase productivity'],
resourceNeeds: ['Senior engineer'],
riskMitigation: ['Cross-training']
},
organizationalStrengths: uniqueStrengths.map(s => ({ area: s, description: s })),
organizationalRisks: uniqueRisks,
organizationalImpactSummary: 'Impact summary placeholder',
gradingBreakdown,
gradingOverview: legacy,
executiveSummary: `Company overview for ${org?.name || 'Organization'} as of ${new Date().toLocaleDateString()}. Total workforce: ${totalEmployees}. Submission rate: ${submissionRate.toFixed(1)}%. Key strengths: ${uniqueStrengths.slice(0, 2).join(', ')}. Risks: ${uniqueRisks.slice(0, 2).join(', ')}.`
};
await saveFullCompanyReport(report);
return report;
};
const generateCompanyWiki = async (orgOverride?: OrgData): Promise<CompanyReport> => {
const orgData = orgOverride || org;
try {
const res = await fetch(`${API_URL}/generateCompanyWiki`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ org: orgData, submissions })
});
if (!res.ok) throw new Error('Failed to generate company wiki');
const payload = await res.json();
const data: CompanyReport = payload.report || payload; // backward compatibility
await saveFullCompanyReport(data);
return data;
} catch (e) {
console.error('generateCompanyWiki error, falling back to local synthetic:', e);
return generateCompanyReport();
}
};
const isOwner = (employeeId?: string): boolean => {
const currentEmployee = employeeId ? employees.find(e => e.id === employeeId) :
employees.find(e => e.email === user?.email);
return currentEmployee?.isOwner === true;
};
const getEmployeeReport = async (employeeId: string) => {
try {
if (isFirebaseConfigured && user) {
// Firebase implementation
const reportDoc = await getDoc(doc(db, 'organizations', orgId, 'employeeReports', employeeId));
if (reportDoc.exists()) {
return { success: true, report: reportDoc.data() };
}
return { success: false, error: 'Report not found' };
} else {
// Demo mode - call API
const response = await fetch(`${API_URL}/api/employee-report/${employeeId}`);
const result = await response.json();
return result;
}
} catch (error) {
console.error('Error fetching employee report:', error);
return { success: false, error: error.message };
}
};
const getEmployeeReports = async () => {
try {
if (isFirebaseConfigured && user) {
// Firebase implementation
const reportsSnapshot = await getDocs(collection(db, 'organizations', orgId, 'employeeReports'));
const reports = reportsSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
return { success: true, reports };
} else {
// Demo mode - call API
const response = await fetch(`${API_URL}/api/employee-reports`);
const result = await response.json();
return result;
}
} catch (error) {
console.error('Error fetching employee reports:', error);
return { success: false, error: error.message };
}
};
const value = {
org,
orgId,
employees,
submissions,
reports,
upsertOrg,
saveReport,
inviteEmployee,
getReportVersions,
saveReportVersion,
acceptInvite,
saveCompanyReport,
getCompanyReportHistory,
saveFullCompanyReport,
getFullCompanyReportHistory,
generateCompanyReport,
generateCompanyWiki,
seedInitialData,
isOwner,
issueInviteViaApi: async ({ name, email, role, department }) => {
try {
const res = await fetch(`${API_URL}/createInvitation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, role, department, orgId })
});
if (!res.ok) throw new Error('invite creation failed');
const json = await res.json();
// Optimistically add employee shell (not yet active until consume)
setEmployees(prev => prev.find(e => e.id === json.employee.id) ? prev : [...prev, { ...json.employee }]);
return json;
} catch (e) {
console.error('issueInviteViaApi error', e);
throw e;
}
},
getInviteStatus: async (code: string) => {
if (!isFirebaseConfigured) {
// Demo mode: check localStorage first, then server
const invite = demoStorage.getInvite(code);
if (invite) {
return { used: invite.used, employee: invite.employee };
}
}
try {
const res = await fetch(`${API_URL}/getInvitationStatus?code=${code}`);
if (!res.ok) return null;
return await res.json();
} catch (e) {
console.error('getInviteStatus error', e);
return null;
}
},
consumeInvite: async (code: string) => {
if (!isFirebaseConfigured) {
// Demo mode: mark invite as used in localStorage and update state
const invite = demoStorage.getInvite(code);
if (invite && !invite.used) {
demoStorage.markInviteUsed(code);
// Ensure employee is in the list with proper typing
const convertedEmployee: Employee = {
id: invite.employee.id,
name: invite.employee.name,
email: invite.employee.email,
initials: invite.employee.name ? invite.employee.name.split(' ').map(n => n[0]).join('').toUpperCase() : invite.employee.email.substring(0, 2).toUpperCase(),
department: invite.employee.department,
role: invite.employee.role
};
setEmployees(prev => prev.find(e => e.id === invite.employee.id) ? prev : [...prev, convertedEmployee]);
return { employee: convertedEmployee };
}
return null;
}
try {
const res = await fetch(`${API_URL}/consumeInvitation?code=${code}`, { method: 'POST' });
if (!res.ok) return null;
const json = await res.json();
// Mark employee as active (could set a flag later)
setEmployees(prev => prev.find(e => e.id === json.employee.id) ? prev : [...prev, json.employee]);
return json;
} catch (e) {
console.error('consumeInvite error', e);
return null;
}
},
submitEmployeeAnswers: async (employeeId: string, answers: Record<string, string>) => {
if (!isFirebaseConfigured) {
// Demo mode: save to localStorage and call server endpoint
try {
const submission = {
employeeId,
orgId,
answers,
createdAt: Date.now()
};
// Save to localStorage for persistence
demoStorage.saveSubmission(submission);
// Also call Cloud Function for processing with orgId
const employee = employees.find(e => e.id === employeeId);
const res = await fetch(`${API_URL}/submitEmployeeAnswers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
employeeId,
answers,
orgId,
employee
})
});
if (!res.ok) throw new Error('Failed to submit to server');
// Update local state for UI with proper typing
const convertedSubmission: Submission = {
employeeId,
answers: Object.entries(answers).map(([question, answer]) => ({
question,
answer
}))
};
setSubmissions(prev => ({ ...prev, [employeeId]: convertedSubmission }));
return true;
} catch (e) {
console.error('submitEmployeeAnswers error', e);
return false;
}
}
// Firebase mode: save to Firestore
try {
const ref = doc(db, 'orgs', orgId, 'submissions', employeeId);
await setDoc(ref, { ...answers, createdAt: Date.now() }, { merge: true });
return true;
} catch (e) {
console.error('submitEmployeeAnswers error', e);
return false;
}
},
generateEmployeeReport: async (employee: Employee) => {
try {
const submission = submissions[employee.id]?.answers || submissions[employee.id] || {};
const res = await fetch(`${API_URL}/generateEmployeeReport`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employee, submission })
});
if (!res.ok) throw new Error('failed to generate');
const json = await res.json();
if (json.report) {
setReports(prev => ({ ...prev, [employee.id]: json.report }));
return json.report as Report;
}
} catch (e) {
console.error('generateEmployeeReport error', e);
}
return null;
},
getEmployeeReport: async (employeeId: string) => {
try {
if (isFirebaseConfigured && user) {
// Firebase implementation
const reportDoc = await getDoc(doc(db, 'organizations', orgId, 'employeeReports', employeeId));
if (reportDoc.exists()) {
return { success: true, report: reportDoc.data() };
}
return { success: false, error: 'Report not found' };
} else {
// Demo mode - call Cloud Function
const response = await fetch(`${API_URL}/generateEmployeeReport?employeeId=${employeeId}`);
const result = await response.json();
return result;
}
} catch (error) {
console.error('Error fetching employee report:', error);
return { success: false, error: error.message };
}
},
getEmployeeReports: async () => {
try {
if (isFirebaseConfigured && user) {
// Firebase implementation
const reportsSnapshot = await getDocs(collection(db, 'organizations', orgId, 'employeeReports'));
const reports = reportsSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
return { success: true, reports };
} else {
// Demo mode - call Cloud Function
const response = await fetch(`${API_URL}/generateEmployeeReport`);
const result = await response.json();
return result;
}
} catch (error) {
console.error('Error fetching employee reports:', error);
return { success: false, error: error.message };
}
},
};
return (
<OrgContext.Provider value={value}>
{children}
</OrgContext.Provider>
);
};
export const useOrg = () => {
const ctx = useContext(OrgContext);
if (!ctx) throw new Error('useOrg must be used within OrgProvider');
return ctx;
};

53
contexts/ThemeContext.tsx Normal file
View File

@@ -0,0 +1,53 @@
import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react';
import { Theme } from '../types';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useState<Theme>(() => {
try {
const storedTheme = localStorage.getItem('theme');
return (storedTheme as Theme) || Theme.System;
} catch (error) {
console.warn('Could not access localStorage. Defaulting to system theme.', error);
return Theme.System;
}
});
useEffect(() => {
const root = window.document.documentElement;
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === Theme.System) {
root.classList.toggle('dark', systemIsDark);
} else {
root.classList.toggle('dark', theme === Theme.Dark);
}
try {
localStorage.setItem('theme', theme);
} catch (error) {
console.warn(`Could not save theme to localStorage: ${error}`);
}
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@@ -0,0 +1,300 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useAuth } from './AuthContext';
import { isFirebaseConfigured } from '../services/firebase';
import { API_URL } from '../constants';
import { demoStorage } from '../services/demoStorage';
interface UserOrganization {
orgId: string;
name: string;
role: 'owner' | 'admin' | 'employee';
onboardingCompleted: boolean;
joinedAt: number;
}
interface UserOrganizationsContextType {
organizations: UserOrganization[];
selectedOrgId: string | null;
loading: boolean;
selectOrganization: (orgId: string) => void;
createOrganization: (name: string) => Promise<{ orgId: string; requiresSubscription?: boolean }>;
joinOrganization: (inviteCode: string) => Promise<string>;
refreshOrganizations: () => Promise<void>;
createCheckoutSession: (orgId: string, userEmail: string) => Promise<{ sessionUrl: string; sessionId: string }>;
getSubscriptionStatus: (orgId: string) => Promise<any>;
}
const UserOrganizationsContext = createContext<UserOrganizationsContextType | undefined>(undefined);
export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user } = useAuth();
const [organizations, setOrganizations] = useState<UserOrganization[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Load user's organizations
const loadOrganizations = async () => {
if (!user) {
setOrganizations([]);
setLoading(false);
return;
}
try {
if (!isFirebaseConfigured) {
// Demo mode - fetch from server API
const response = await fetch(`${API_URL}/api/user/${user.uid}/organizations`);
if (response.ok) {
const data = await response.json();
setOrganizations(data.organizations || []);
} else {
console.error('Failed to load organizations:', response.status);
setOrganizations([]);
}
} else {
// Firebase mode - fetch from Cloud Functions
const response = await fetch(`${API_URL}/getUserOrganizations?userId=${user.uid}`);
if (response.ok) {
const data = await response.json();
setOrganizations(data.organizations || []);
} else {
console.error('Failed to load organizations:', response.status);
setOrganizations([]);
}
}
} catch (error) {
console.error('Failed to load organizations:', error);
setOrganizations([]);
} finally {
setLoading(false);
}
};
// Initialize selected org from session storage
useEffect(() => {
const savedOrgId = sessionStorage.getItem('auditly_selected_org');
if (savedOrgId) {
setSelectedOrgId(savedOrgId);
}
}, []);
// Load organizations when user changes
useEffect(() => {
loadOrganizations();
}, [user]);
const selectOrganization = (orgId: string) => {
setSelectedOrgId(orgId);
sessionStorage.setItem('auditly_selected_org', orgId);
};
const createOrganization = async (name: string): Promise<{ orgId: string; requiresSubscription?: boolean }> => {
if (!user) throw new Error('User not authenticated');
try {
let newOrg: UserOrganization;
let requiresSubscription = false;
if (!isFirebaseConfigured) {
// Demo mode - use server API
const response = await fetch(`${API_URL}/api/organizations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, userId: user.uid })
});
if (!response.ok) {
throw new Error(`Failed to create organization: ${response.status}`);
}
const data = await response.json();
newOrg = {
orgId: data.orgId,
name: data.name,
role: data.role,
onboardingCompleted: data.onboardingCompleted,
joinedAt: data.joinedAt
};
setOrganizations(prev => [...prev, newOrg]);
} else {
// Firebase mode - use Cloud Function
const response = await fetch(`${API_URL}/createOrganization`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, userId: user.uid })
});
if (!response.ok) {
throw new Error(`Failed to create organization: ${response.status}`);
}
const data = await response.json();
newOrg = {
orgId: data.orgId,
name: data.name,
role: data.role,
onboardingCompleted: data.onboardingCompleted,
joinedAt: data.joinedAt
};
requiresSubscription = data.requiresSubscription || false;
setOrganizations(prev => [...prev, newOrg]);
}
return { orgId: newOrg.orgId, requiresSubscription };
} catch (error) {
console.error('Failed to create organization:', error);
throw error;
}
};
const joinOrganization = async (inviteCode: string): Promise<string> => {
if (!user) throw new Error('User not authenticated');
try {
if (!isFirebaseConfigured) {
// Demo mode - use server API to get and consume invite
const inviteStatusRes = await fetch(`/api/invitations/${inviteCode}`);
if (!inviteStatusRes.ok) {
throw new Error('Invalid or expired invite code');
}
const inviteData = await inviteStatusRes.json();
if (inviteData.used) {
throw new Error('Invite code has already been used');
}
// Consume the invite
const consumeRes = await fetch(`/api/invitations/${inviteCode}/consume`, {
method: 'POST'
});
if (!consumeRes.ok) {
throw new Error('Failed to consume invite');
}
const consumedData = await consumeRes.json();
const orgId = consumedData.orgId;
// Get organization data (this might be from localStorage for demo mode)
const orgData = demoStorage.getOrganization(orgId);
if (!orgData) {
throw new Error('Organization not found');
}
const userOrg: UserOrganization = {
orgId: orgId,
name: orgData.name,
role: 'employee',
onboardingCompleted: orgData.onboardingCompleted || false,
joinedAt: Date.now()
};
setOrganizations(prev => [...prev, userOrg]);
return orgId;
} else {
// Firebase mode - use Cloud Function
const response = await fetch(`${API_URL}/joinOrganization`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.uid, inviteCode })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to join organization');
}
const data = await response.json();
const userOrg: UserOrganization = {
orgId: data.orgId,
name: data.name,
role: data.role,
onboardingCompleted: data.onboardingCompleted,
joinedAt: data.joinedAt
};
setOrganizations(prev => [...prev, userOrg]);
return data.orgId;
}
} catch (error) {
console.error('Failed to join organization:', error);
throw error;
}
};
const refreshOrganizations = async () => {
setLoading(true);
await loadOrganizations();
};
const createCheckoutSession = async (orgId: string, userEmail: string): Promise<{ sessionUrl: string; sessionId: string }> => {
if (!user) throw new Error('User not authenticated');
try {
const response = await fetch(`${API_URL}/createCheckoutSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
orgId,
userId: user.uid,
userEmail
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create checkout session');
}
const data = await response.json();
return {
sessionUrl: data.sessionUrl,
sessionId: data.sessionId
};
} catch (error) {
console.error('Failed to create checkout session:', error);
throw error;
}
};
const getSubscriptionStatus = async (orgId: string) => {
try {
const response = await fetch(`${API_URL}/getSubscriptionStatus?orgId=${orgId}`);
if (!response.ok) {
throw new Error('Failed to get subscription status');
}
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to get subscription status:', error);
throw error;
}
};
return (
<UserOrganizationsContext.Provider value={{
organizations,
selectedOrgId,
loading,
selectOrganization,
createOrganization,
joinOrganization,
refreshOrganizations,
createCheckoutSession,
getSubscriptionStatus
}}>
{children}
</UserOrganizationsContext.Provider>
);
};
export const useUserOrganizations = () => {
const context = useContext(UserOrganizationsContext);
if (!context) {
throw new Error('useUserOrganizations must be used within UserOrganizationsProvider');
}
return context;
};

1064
functions/bun.lock Normal file

File diff suppressed because it is too large Load Diff

1454
functions/index.js Normal file

File diff suppressed because it is too large Load Diff

25
functions/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase emulators:start --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "18"
},
"main": "index.js",
"dependencies": {
"firebase-admin": "^13.4.0",
"firebase-functions": "^4.5.0",
"openai": "^5.12.2",
"stripe": "^18.4.0"
},
"devDependencies": {
"firebase-functions-test": "^3.1.0"
},
"private": true
}

182
index.css Normal file
View File

@@ -0,0 +1,182 @@
@import "tailwindcss";
:root {
/* Light theme variables - using new Figma color palette */
--background-primary : #FFFFFF;
/* Base White */
--background-secondary : #FDFDFD;
/* Gray 6 */
--background-tertiary : #FAFAFA;
/* Gray 5 */
--text-primary : #0A0D12;
/* Dark 7 */
--text-secondary : #717680;
/* Dark 2 */
--text-tertiary : #A4A7AE;
/* Gray 1 */
--accent : #5E48FC;
/* Brand Main */
--accent-hover : #4C3CF0;
/* Slightly darker brand */
--accent-text : #FFFFFF;
/* Base White */
--border-color : #E9EAEB;
/* Gray 3 */
--border-light : #F5F5F5;
/* Gray 4 */
--sidebar-bg : #FDFDFD;
/* Gray 6 */
--sidebar-text : #717680;
/* Dark 2 */
--sidebar-active-bg : #5E48FC;
/* Brand Main */
--sidebar-active-text : #FFFFFF;
/* Base White */
--input-bg : #F5F5F5;
/* Gray 4 */
--input-border : #E9EAEB;
/* Gray 3 */
--input-placeholder : #717680;
/* Dark 2 */
--button-secondary-bg : #F5F5F5;
/* Gray 4 */
--button-secondary-hover: #E9EAEB;
/* Gray 3 */
--status-red : #F63D68;
/* Other Red */
--status-green : #3CCB7F;
/* Other Green */
--status-orange : #FF4405;
/* Other Orange */
--status-yellow : #FEEE95;
/* Other Yellow */
}
.dark {
/* Dark theme variables - using new Figma color palette */
--background-primary : #0A0D12;
/* Dark 7 */
--background-secondary : #181D27;
/* Dark 6 */
--background-tertiary : #252B37;
/* Dark 5 */
--text-primary : #FDFDFD;
/* Gray 6 */
--text-secondary : #D5D7DA;
/* Gray 2 */
--text-tertiary : #A4A7AE;
/* Gray 1 */
--accent : #5E48FC;
/* Brand Main */
--accent-hover : #6B56FF;
/* Slightly lighter brand */
--accent-text : #FFFFFF;
/* Base White */
--border-color : #535862;
/* Dark 3 */
--border-light : #414651;
/* Dark 4 */
--sidebar-bg : #181D27;
/* Dark 6 */
--sidebar-text : #D5D7DA;
/* Gray 2 */
--sidebar-active-bg : #5E48FC;
/* Brand Main */
--sidebar-active-text : #FFFFFF;
/* Base White */
--input-bg : #252B37;
/* Dark 5 */
--input-border : #414651;
/* Dark 4 */
--input-placeholder : #717680;
/* Dark 2 */
--button-secondary-bg : #414651;
/* Dark 4 */
--button-secondary-hover: #535862;
/* Dark 3 */
--status-red : #F63D68;
/* Other Red */
--status-green : #3CCB7F;
/* Other Green */
--status-orange : #FF4405;
/* Other Orange */
--status-yellow : #FEEE95;
/* Other Yellow */
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing : antialiased;
-moz-osx-font-smoothing: grayscale;
background-color : var(--background-primary);
color : var(--text-primary);
}
#root {
height: 100vh;
}
/* Custom slider styling */
.slider {
-webkit-appearance: none;
appearance : none;
background : var(--border-color);
outline : none;
border-radius : 8px;
transition : all 0.2s ease;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance : none;
width : 20px;
height : 20px;
border-radius : 50%;
background : var(--accent);
cursor : pointer;
border : 2px solid white;
box-shadow : 0 2px 4px rgba(0, 0, 0, 0.2);
transition : all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
background: var(--accent-hover);
transform : scale(1.1);
}
.slider::-moz-range-thumb {
width : 20px;
height : 20px;
border-radius: 50%;
background : var(--accent);
cursor : pointer;
border : 2px solid white;
box-shadow : 0 2px 4px rgba(0, 0, 0, 0.2);
transition : all 0.2s ease;
}
.slider::-moz-range-thumb:hover {
background: var(--accent-hover);
transform : scale(1.1);
}
/* Radio button styling */
input[type="radio"] {
accent-color: var(--accent);
}
/* Focus states */
.slider:focus {
box-shadow: 0 0 0 3px rgba(94, 72, 252, 0.3);
}
input[type="radio"]:focus {
box-shadow: 0 0 0 3px rgba(94, 72, 252, 0.3);
}

93
index.html Normal file
View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auditly</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root {
--background-primary: #F9FAFB;
/* gray-50 */
--background-secondary: #FFFFFF;
/* white */
--background-tertiary: #F3F4F6;
/* gray-100 */
--text-primary: #1F2937;
/* gray-800 */
--text-secondary: #6B7280;
/* gray-500 */
--text-tertiary: #9CA3AF;
/* gray-400 */
--border-color: #E5E7EB;
/* gray-200 */
--accent: #2563EB;
/* blue-600 */
--accent-hover: #1D4ED8;
/* blue-700 */
--accent-text: #FFFFFF;
/* white */
--sidebar-bg: #FFFFFF;
/* white */
--sidebar-active-bg: #F3F4F6;
/* gray-100 */
--sidebar-text: #374151;
/* gray-700 */
--sidebar-active-text: #1F2937;
/* gray-800 */
--sidebar-icon: #9CA3AF;
/* gray-400 */
}
html.dark {
--background-primary: #111827;
/* gray-900 */
--background-secondary: #1F2937;
/* gray-800 */
--background-tertiary: #374151;
/* gray-700 */
--text-primary: #F9FAFB;
/* gray-50 */
--text-secondary: #9CA3AF;
/* gray-400 */
--text-tertiary: #6B7280;
/* gray-500 */
--border-color: #374151;
/* gray-700 */
--accent: #3B82F6;
/* blue-500 */
--accent-hover: #2563EB;
/* blue-600 */
--accent-text: #FFFFFF;
/* white */
--sidebar-bg: #1F2937;
/* gray-800 */
--sidebar-active-bg: #374151;
/* gray-700 */
--sidebar-text: #D1D5DB;
/* gray-300 */
--sidebar-active-text: #FFFFFF;
/* white */
--sidebar-icon: #9CA3AF;
/* gray-400 */
}
body {
background-color: var(--background-primary);
color: var(--text-primary);
font-family: 'Inter', sans-serif;
}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

17
index.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

3028
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "auditly",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently -k \"bun run dev:firebase\" \"bun run dev:client\"",
"dev:client": "vite",
"dev:server": "bun --watch server/index.js",
"dev:firebase": "firebase emulators:start --only functions,firestore,database",
"build": "vite build",
"preview": "vite preview",
"deploy:functions": "firebase deploy --only functions",
"deploy:firestore": "firebase deploy --only firestore",
"deploy:database": "firebase deploy --only database",
"emulators": "firebase emulators:start"
},
"dependencies": {
"@google/genai": "^1.14.0",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"cors": "^2.8.5",
"express": "^5.1.0",
"firebase": "^12.1.0",
"firebase-admin": "^13.4.0",
"firebase-functions": "^6.4.0",
"lucide-react": "^0.539.0",
"openai": "^4.104.0",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.0",
"recharts": "^3.1.2",
"tailwindcss": "^4.1.12"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/dotenv": "^8.2.3",
"@types/express": "^4.17.23",
"@types/node": "^22.17.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.2.0",
"dotenv": "^17.2.1",
"tsx": "^4.20.4",
"typescript": "~5.8.3",
"vite": "^7.1.2"
}
}

137
pages/Chat.tsx Normal file
View File

@@ -0,0 +1,137 @@
import React, { useState, useMemo } from 'react';
import { Card, Button } from '../components/UiKit';
import { useOrg } from '../contexts/OrgContext';
import { CHAT_STARTERS } from '../constants';
const Chat: React.FC = () => {
const { employees, reports, generateEmployeeReport } = useOrg();
const [messages, setMessages] = useState<Array<{ id: string, role: 'user' | 'assistant', text: string }>>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>('');
const selectedReport = selectedEmployeeId ? reports[selectedEmployeeId] : undefined;
const dynamicStarters = useMemo(() => {
if (!selectedReport) return CHAT_STARTERS.slice(0, 4);
const strengths = selectedReport.insights.strengths?.slice(0, 2) || [];
const weaknesses = selectedReport.insights.weaknesses?.slice(0, 1) || [];
const risk = selectedReport.retentionRisk;
const starters: string[] = [];
if (strengths[0]) starters.push(`How can we further leverage ${strengths[0]} for cross-team impact?`);
if (weaknesses[0]) starters.push(`What is an actionable plan to address ${weaknesses[0]} this quarter?`);
if (risk) starters.push(`What factors contribute to ${selectedReport.employeeId}'s ${risk} retention risk?`);
starters.push(`Is ${selectedReport.employeeId} a candidate for expanded scope or leadership?`);
while (starters.length < 4) starters.push(CHAT_STARTERS[starters.length] || 'Provide an organizational insight.');
return starters.slice(0, 4);
}, [selectedReport]);
const handleSend = async (message?: string) => {
const textToSend = message || input;
if (!textToSend.trim()) return;
const userMessage = { id: Date.now().toString(), role: 'user' as const, text: textToSend };
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
// Simulate AI response (placeholder for server /api/chat usage)
setTimeout(() => {
const aiResponse = {
id: (Date.now() + 1).toString(),
role: 'assistant' as const,
text: `Based on ${selectedEmployeeId ? 'the selected employee\'s' : 'organizational'} data, here's an insight related to: "${textToSend}".`
};
setMessages(prev => [...prev, aiResponse]);
setIsLoading(false);
}, 1500);
};
return (
<div className="p-6 max-w-4xl mx-auto h-full flex flex-col">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[--text-primary]">Chat with AI</h1>
<p className="text-[--text-secondary] mt-1">Ask questions about your employees and organization</p>
</div>
<div className="mb-4 flex flex-col md:flex-row md:items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-sm text-[--text-secondary]">Focus Employee:</label>
<select
className="px-2 py-1 text-sm bg-[--background-secondary] border border-[--border-color] rounded"
value={selectedEmployeeId}
onChange={e => setSelectedEmployeeId(e.target.value)}
>
<option value="">(Organization)</option>
{employees.map(emp => <option key={emp.id} value={emp.id}>{emp.name}</option>)}
</select>
{selectedEmployeeId && !selectedReport && (
<Button size="sm" variant="secondary" onClick={() => generateEmployeeReport(employees.find(e => e.id === selectedEmployeeId)!)}>
Generate Report
</Button>
)}
</div>
</div>
{messages.length === 0 && (
<Card className="mb-6">
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Get started with these questions:</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{dynamicStarters.map((starter, idx) => (
<Button
key={idx}
variant="secondary"
size="sm"
className="text-left justify-start"
onClick={() => handleSend(starter)}
>
{starter}
</Button>
))}
</div>
</Card>
)}
<div className="flex-1 overflow-y-auto mb-4 space-y-4">
{messages.map(message => (
<div key={message.id} className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] p-4 rounded-lg ${message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-[--background-secondary] text-[--text-primary]'
}`}>
{message.text}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-[--background-secondary] text-[--text-primary] p-4 rounded-lg">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
)}
</div>
<Card padding="sm">
<div className="flex space-x-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
placeholder="Ask about employees, reports, or company insights..."
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<Button onClick={() => handleSend()} disabled={isLoading || !input.trim()}>
Send
</Button>
</div>
</Card>
</div>
);
};
export default Chat;

252
pages/CompanyWiki.tsx Normal file
View File

@@ -0,0 +1,252 @@
import React, { useEffect, useState } from 'react';
import { useOrg } from '../contexts/OrgContext';
import { Card, Button } from '../components/UiKit';
import { FigmaAlert } from '../components/figma/FigmaAlert';
import { CompanyReport } from '../types';
import RadarPerformanceChart from '../components/charts/RadarPerformanceChart';
const CompanyWiki: React.FC = () => {
const { org, employees, getFullCompanyReportHistory, generateCompanyWiki } = useOrg();
const [isGenerating, setIsGenerating] = useState(false);
const [companyReport, setCompanyReport] = useState<CompanyReport | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const history = await getFullCompanyReportHistory();
if (history.length) setCompanyReport(history[0]);
} catch (e) {
console.error('Failed loading company report history', e);
}
})();
}, [getFullCompanyReportHistory]);
const generateReport = async () => {
setIsGenerating(true);
setError(null);
try {
const report = await generateCompanyWiki();
setCompanyReport(report);
} catch (e: any) {
console.error(e);
setError('Failed to generate company wiki');
} finally {
setIsGenerating(false);
}
};
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[--text-primary]">Company Wiki</h1>
<p className="text-[--text-secondary] mt-1">
Organization overview and insights
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">Company Info</h3>
<div className="space-y-2">
<div>
<span className="text-sm text-[--text-secondary]">Name:</span>
<div className="font-medium text-[--text-primary]">{org?.name}</div>
</div>
<div>
<span className="text-sm text-[--text-secondary]">Industry:</span>
<div className="font-medium text-[--text-primary]">{org?.industry}</div>
</div>
<div>
<span className="text-sm text-[--text-secondary]">Size:</span>
<div className="font-medium text-[--text-primary]">{org?.size}</div>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">Team Stats</h3>
<div className="space-y-2">
<div>
<span className="text-sm text-[--text-secondary]">Total Employees:</span>
<div className="font-medium text-[--text-primary]">{employees.length}</div>
</div>
<div>
<span className="text-sm text-[--text-secondary]">Departments:</span>
<div className="font-medium text-[--text-primary]">
{[...new Set(employees.map(e => e.department))].length}
</div>
</div>
<div>
<span className="text-sm text-[--text-secondary]">Roles:</span>
<div className="font-medium text-[--text-primary]">
{[...new Set(employees.map(e => e.role))].length}
</div>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">Quick Actions</h3>
<div className="space-y-3">
<Button onClick={generateReport} disabled={isGenerating} className="w-full">
{isGenerating ? 'Generating...' : companyReport ? 'Regenerate Company Wiki' : 'Generate Company Wiki'}
</Button>
{error && <FigmaAlert type="error" message={error} />}
{!companyReport && !isGenerating && (
<FigmaAlert type="info" message="No company wiki generated yet. Use the button above to create one." />
)}
<Button variant="secondary" className="w-full" disabled={!companyReport}>
Export Data
</Button>
</div>
</Card>
</div>
{companyReport && (
<div className="space-y-6">
<Card>
<h3 className="text-xl font-semibold text-[--text-primary] mb-4">Executive Summary</h3>
<p className="text-[--text-secondary] whitespace-pre-line mb-4">{companyReport.executiveSummary}</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-[--background-secondary] rounded-lg">
<div className="text-2xl font-bold text-blue-500">{companyReport.overview.totalEmployees}</div>
<div className="text-sm text-[--text-secondary]">Employees</div>
</div>
<div className="text-center p-4 bg-[--background-secondary] rounded-lg">
<div className="text-2xl font-bold text-green-500">{companyReport.overview.departmentBreakdown.length}</div>
<div className="text-sm text-[--text-secondary]">Departments</div>
</div>
<div className="text-center p-4 bg-[--background-secondary] rounded-lg">
<div className="text-2xl font-bold text-purple-500">{companyReport.organizationalStrengths.length}</div>
<div className="text-sm text-[--text-secondary]">Strength Areas</div>
</div>
<div className="text-center p-4 bg-[--background-secondary] rounded-lg">
<div className="text-2xl font-bold text-orange-500">{companyReport.organizationalRisks.length}</div>
<div className="text-sm text-[--text-secondary]">Risks</div>
</div>
</div>
{companyReport.gradingOverview && (
<div className="mt-6 p-4 bg-[--background-tertiary] rounded-lg">
<RadarPerformanceChart
title="Organizational Grading"
data={companyReport.gradingOverview.map((g: any) => ({
label: g.category || g.department || g.subject || 'Metric',
value: g.value ?? g.averageScore ?? 0
}))}
/>
</div>
)}
</Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<h4 className="text-lg font-semibold text-[--text-primary] mb-3">Strengths</h4>
<ul className="space-y-2">
{companyReport.organizationalStrengths.map((s: any, i) => <li key={i} className="text-[--text-secondary] text-sm"> {s.area}</li>)}
</ul>
</Card>
<Card>
<h4 className="text-lg font-semibold text-[--text-primary] mb-3">Risks</h4>
<ul className="space-y-2">
{companyReport.organizationalRisks.map((r, i) => <li key={i} className="text-[--text-secondary] text-sm"> {r}</li>)}
</ul>
</Card>
<Card>
<h4 className="text-lg font-semibold text-[--text-primary] mb-3">Forward Plan</h4>
<div>
<h5 className="font-medium text-[--text-primary] text-sm mb-1">Goals</h5>
<ul className="mb-2 list-disc list-inside text-[--text-secondary] text-sm space-y-1">
{(companyReport.operatingPlan?.nextQuarterGoals || companyReport.forwardOperatingPlan?.quarterlyGoals || []).map((g: string, i: number) => <li key={i}>{g}</li>)}
</ul>
<h5 className="font-medium text-[--text-primary] text-sm mb-1">Resource Needs</h5>
<ul className="mb-2 list-disc list-inside text-[--text-secondary] text-sm space-y-1">
{(companyReport.operatingPlan?.resourceNeeds || companyReport.forwardOperatingPlan?.resourceNeeds || []).map((g: string, i: number) => <li key={i}>{g}</li>)}
</ul>
<h5 className="font-medium text-[--text-primary] text-sm mb-1">Risk Mitigation</h5>
<ul className="list-disc list-inside text-[--text-secondary] text-sm space-y-1">
{(companyReport.operatingPlan?.riskMitigation || companyReport.forwardOperatingPlan?.riskMitigation || []).map((g: string, i: number) => <li key={i}>{g}</li>)}
</ul>
</div>
</Card>
</div>
</div>
)}
{/* Company Profile - Onboarding Data */}
<Card className="mt-6">
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Company Profile</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{org?.mission && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Mission</h4>
<p className="text-[--text-secondary] text-sm">{org.mission}</p>
</div>
)}
{org?.vision && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Vision</h4>
<p className="text-[--text-secondary] text-sm">{org.vision}</p>
</div>
)}
{org?.evolution && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Company Evolution</h4>
<p className="text-[--text-secondary] text-sm">{org.evolution}</p>
</div>
)}
{org?.advantages && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Competitive Advantages</h4>
<p className="text-[--text-secondary] text-sm">{org.advantages}</p>
</div>
)}
{org?.vulnerabilities && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Vulnerabilities</h4>
<p className="text-[--text-secondary] text-sm">{org.vulnerabilities}</p>
</div>
)}
{org?.shortTermGoals && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Short Term Goals</h4>
<p className="text-[--text-secondary] text-sm">{org.shortTermGoals}</p>
</div>
)}
{org?.longTermGoals && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Long Term Goals</h4>
<p className="text-[--text-secondary] text-sm">{org.longTermGoals}</p>
</div>
)}
{org?.cultureDescription && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Culture</h4>
<p className="text-[--text-secondary] text-sm">{org.cultureDescription}</p>
</div>
)}
{org?.workEnvironment && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Work Environment</h4>
<p className="text-[--text-secondary] text-sm">{org.workEnvironment}</p>
</div>
)}
{org?.additionalContext && (
<div className="md:col-span-2">
<h4 className="font-medium text-[--text-primary] mb-2">Additional Context</h4>
<p className="text-[--text-secondary] text-sm">{org.additionalContext}</p>
</div>
)}
</div>
</Card>
{org?.description && (
<Card className="mt-6">
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">About</h3>
<p className="text-[--text-secondary]">{org.description}</p>
</Card>
)}
</div>
);
};
export default CompanyWiki;

215
pages/DebugEmployee.tsx Normal file
View File

@@ -0,0 +1,215 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useOrg } from '../contexts/OrgContext';
import { Card } from '../components/UiKit';
const DebugEmployee: React.FC = () => {
const { user } = useAuth();
const { employees, org } = useOrg();
return (
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-[--text-primary] mb-8 text-center">
Employee Debug Information
</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Current User Info */}
<Card className="p-6">
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
Current User
</h2>
<div className="space-y-2">
<div>
<span className="font-medium">Email:</span>
<span className="ml-2 text-[--text-secondary]">{user?.email || 'Not logged in'}</span>
</div>
<div>
<span className="font-medium">Display Name:</span>
<span className="ml-2 text-[--text-secondary]">{user?.displayName || 'N/A'}</span>
</div>
<div>
<span className="font-medium">UID:</span>
<span className="ml-2 text-[--text-secondary] text-xs break-all">{user?.uid || 'N/A'}</span>
</div>
</div>
</Card>
{/* Organization Info */}
<Card className="p-6">
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
Organization
</h2>
<div className="space-y-2">
<div>
<span className="font-medium">Name:</span>
<span className="ml-2 text-[--text-secondary]">{org?.name || 'Not set'}</span>
</div>
<div>
<span className="font-medium">Org ID:</span>
<span className="ml-2 text-[--text-secondary] text-xs break-all">{org?.orgId || 'N/A'}</span>
</div>
<div>
<span className="font-medium">Onboarding Complete:</span>
<span className="ml-2 text-[--text-secondary]">{org?.onboardingCompleted ? 'Yes' : 'No'}</span>
</div>
</div>
</Card>
{/* Employee Matching Analysis */}
<Card className="p-6 lg:col-span-2">
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
Employee Matching Analysis
</h2>
<div className="space-y-4">
<div>
<span className="font-medium">Total Employees:</span>
<span className="ml-2 text-[--text-secondary]">{employees.length}</span>
</div>
{user?.email && (
<div className="space-y-2">
<h3 className="font-medium text-[--text-primary]">Matching Results:</h3>
{/* Exact match */}
<div className="pl-4">
<span className="text-sm font-medium">Exact Email Match:</span>
<span className="ml-2 text-[--text-secondary]">
{employees.find(emp => emp.email === user.email) ? '✅ Found' : '❌ Not found'}
</span>
</div>
{/* Case insensitive match */}
<div className="pl-4">
<span className="text-sm font-medium">Case-Insensitive Match:</span>
<span className="ml-2 text-[--text-secondary]">
{employees.find(emp => emp.email?.toLowerCase() === user.email?.toLowerCase()) ? '✅ Found' : '❌ Not found'}
</span>
</div>
{/* Domain match */}
<div className="pl-4">
<span className="text-sm font-medium">Same Domain Match:</span>
<span className="ml-2 text-[--text-secondary]">
{(() => {
const userDomain = user.email?.split('@')[1];
const domainMatch = employees.find(emp => emp.email?.split('@')[1] === userDomain);
return domainMatch ? `✅ Found: ${domainMatch.name} (${domainMatch.email})` : '❌ Not found';
})()}
</span>
</div>
{/* Username partial match */}
<div className="pl-4">
<span className="text-sm font-medium">Username Partial Match:</span>
<span className="ml-2 text-[--text-secondary]">
{(() => {
const username = user.email?.split('@')[0];
const partialMatch = employees.find(emp =>
emp.email?.toLowerCase().includes(username?.toLowerCase() || '')
);
return partialMatch ? `✅ Found: ${partialMatch.name} (${partialMatch.email})` : '❌ Not found';
})()}
</span>
</div>
</div>
)}
</div>
</Card>
{/* All Employees List */}
<Card className="p-6 lg:col-span-2">
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
All Employees ({employees.length})
</h2>
<div className="space-y-3">
{employees.length === 0 ? (
<p className="text-[--text-secondary] italic">No employees found</p>
) : (
employees.map((employee, index) => (
<div key={employee.id} className="border border-[--border-color] rounded-lg p-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<div>
<span className="font-medium">Name:</span>
<span className="ml-2">{employee.name}</span>
</div>
<div>
<span className="font-medium">Email:</span>
<span className="ml-2 text-sm">{employee.email || 'Not set'}</span>
</div>
<div>
<span className="font-medium">Role:</span>
<span className="ml-2 text-sm">{employee.role || 'Not set'}</span>
</div>
</div>
<div className="mt-2">
<span className="font-medium">ID:</span>
<span className="ml-2 text-xs text-[--text-secondary] break-all">{employee.id}</span>
</div>
{user?.email && employee.email && (
<div className="mt-2 text-sm">
<span className="font-medium">Match Analysis:</span>
<span className="ml-2">
{employee.email === user.email && (
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs mr-1">Exact</span>
)}
{employee.email?.toLowerCase() === user.email?.toLowerCase() && employee.email !== user.email && (
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded text-xs mr-1">Case Diff</span>
)}
{employee.email?.split('@')[1] === user.email?.split('@')[1] && (
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs mr-1">Same Domain</span>
)}
{employee.email?.toLowerCase().includes(user.email?.split('@')[0]?.toLowerCase() || '') && (
<span className="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs mr-1">Username Match</span>
)}
</span>
</div>
)}
</div>
))
)}
</div>
</Card>
{/* Quick Actions */}
<Card className="p-6 lg:col-span-2">
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
Quick Actions
</h2>
<div className="flex flex-wrap gap-3">
<a
href="#/employee-questionnaire"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm"
>
Try Traditional Questionnaire
</a>
<a
href="#/employee-questionnaire-steps"
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors text-sm"
>
Try Stepped Questionnaire
</a>
<a
href="#/reports"
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors text-sm"
>
Go to Reports
</a>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors text-sm"
>
Refresh Page
</button>
</div>
</Card>
</div>
</div>
</div>
);
};
export default DebugEmployee;

453
pages/EmployeeData.tsx Normal file
View File

@@ -0,0 +1,453 @@
import React, { useState, useEffect } from 'react';
import { useOrg } from '../contexts/OrgContext';
import { Card, Button } from '../components/UiKit';
import { CompanyReport, Employee, Report } from '../types';
import RadarPerformanceChart from '../components/charts/RadarPerformanceChart';
import ScoreBarList from '../components/charts/ScoreBarList';
import { SAMPLE_COMPANY_REPORT } from '../constants';
interface EmployeeDataProps {
mode: 'submissions' | 'reports';
}
const CompanyReportCard: React.FC<{ report: CompanyReport }> = ({ report }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<Card className="mb-6 border-l-4 border-blue-500">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white font-bold text-sm">
ZM
</div>
<div>
<h2 className="text-xl font-bold text-[--text-primary]">Company Report</h2>
<p className="text-sm text-[--text-secondary]">
Last updated: {new Date(report.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex space-x-2">
<Button
size="sm"
variant="secondary"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Collapse' : 'View Details'}
</Button>
<Button size="sm">Download as PDF</Button>
</div>
</div>
{/* Overview Section - Always Visible */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div className="bg-[--background-tertiary] p-4 rounded-lg">
<h3 className="text-sm font-medium text-[--text-secondary]">Total Employees</h3>
<p className="text-2xl font-bold text-[--text-primary]">{report.overview.totalEmployees}</p>
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg">
<h3 className="text-sm font-medium text-[--text-secondary]">Departments</h3>
<p className="text-2xl `font-bold text-[--text-primary]">{report.overview.departmentBreakdown.length}</p>
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg">
<h3 className="text-sm font-medium text-[--text-secondary]">Avg Performance</h3>
<p className="text-2xl font-bold text-[--text-primary]">{report.overview.averagePerformanceScore}/5</p>
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg">
<h3 className="text-sm font-medium text-[--text-secondary]">Risk Level</h3>
<p className="text-2xl font-bold text-[--text-primary]">{report.overview.riskLevel}</p>
</div>
</div>
{isExpanded && (
<div className="mt-6 space-y-6">
{/* Key Personnel Changes */}
{report.keyPersonnelChanges && report.keyPersonnelChanges.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
<span className="w-2 h-2 bg-orange-500 rounded-full mr-2"></span>
Key Personnel Changes
</h3>
<div className="space-y-2">
{report.keyPersonnelChanges.map((change, idx) => (
<div key={idx} className="p-3 bg-[--background-tertiary] rounded-lg">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-[--text-primary]">{change.employeeName}</p>
<p className="text-sm text-[--text-secondary]">{change.role} - {change.department}</p>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${change.changeType === 'departure' ? 'bg-red-100 text-red-800' :
change.changeType === 'promotion' ? 'bg-green-100 text-green-800' :
'bg-blue-100 text-blue-800'
}`}>
{change.changeType}
</span>
</div>
<p className="text-sm text-[--text-secondary] mt-2">{change.impact}</p>
</div>
))}
</div>
</div>
)}
{/* Immediate Hiring Needs */}
{report.immediateHiringNeeds && report.immediateHiringNeeds.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
<span className="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
Immediate Hiring Needs
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{report.immediateHiringNeeds.map((need, idx) => (
<div key={idx} className="p-4 bg-[--background-tertiary] rounded-lg">
<div className="flex justify-between items-start mb-2">
<h4 className="font-medium text-[--text-primary]">{need.role}</h4>
<span className={`px-2 py-1 text-xs rounded-full ${need.urgency === 'high' ? 'bg-red-100 text-red-800' :
need.urgency === 'medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{need.urgency} priority
</span>
</div>
<p className="text-sm text-[--text-secondary] mb-2">{need.department}</p>
<p className="text-sm text-[--text-secondary]">{need.reason}</p>
</div>
))}
</div>
</div>
)}
{/* Forward Operating Plan */}
{report.forwardOperatingPlan && (
<div>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
<span className="w-2 h-2 bg-blue-500 rounded-full mr-2"></span>
Forward Operating Plan
</h3>
<div className="space-y-4">
<div className="p-4 bg-[--background-tertiary] rounded-lg">
<h4 className="font-medium text-[--text-primary] mb-2">Next Quarter Goals</h4>
<ul className="space-y-1">
{report.forwardOperatingPlan.nextQuarterGoals.map((goal, idx) => (
<li key={idx} className="text-sm text-[--text-secondary] flex items-start">
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
{goal}
</li>
))}
</ul>
</div>
<div className="p-4 bg-[--background-tertiary] rounded-lg">
<h4 className="font-medium text-[--text-primary] mb-2">Key Initiatives</h4>
<ul className="space-y-1">
{report.forwardOperatingPlan.keyInitiatives.map((initiative, idx) => (
<li key={idx} className="text-sm text-[--text-secondary] flex items-start">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
{initiative}
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Organizational Strengths */}
{report.organizationalStrengths && report.organizationalStrengths.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
Organizational Strengths
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{report.organizationalStrengths.map((strength, idx) => (
<div key={idx} className="p-3 bg-[--background-tertiary] rounded-lg">
<div className="flex items-start space-x-3">
<span className="text-2xl">{strength.icon}</span>
<div>
<h4 className="font-medium text-[--text-primary]">{strength.area}</h4>
<p className="text-sm text-[--text-secondary]">{strength.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Organizational Impact Summary */}
{report.organizationalImpactSummary && (
<div>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
<span className="w-2 h-2 bg-purple-500 rounded-full mr-2"></span>
Organizational Impact Summary
</h3>
<div className="p-4 bg-[--background-tertiary] rounded-lg">
<p className="text-[--text-secondary] text-sm leading-relaxed">
{report.organizationalImpactSummary}
</p>
</div>
</div>
)}
{/* Grading Overview */}
{report.gradingOverview && (
<div>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-2"></span>
Grading Overview
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(report.gradingOverview).map(([category, score], idx) => (
<div key={idx} className="text-center p-4 bg-[--background-tertiary] rounded-lg">
<div className="text-2xl font-bold text-[--text-primary] mb-1">{score}/5</div>
<div className="text-sm text-[--text-secondary] capitalize">{category.replace(/([A-Z])/g, ' $1').trim()}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</Card>
);
};
const EmployeeCard: React.FC<{
employee: Employee;
report?: Report;
mode: 'submissions' | 'reports';
isOwner: boolean;
onGenerateReport?: (employee: Employee) => void;
isGeneratingReport?: boolean;
}> = ({ employee, report, mode, isOwner, onGenerateReport, isGeneratingReport }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<Card className="hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold text-sm">
{employee.initials}
</div>
<div>
<h3 className="font-semibold text-[--text-primary]">{employee.name}</h3>
<p className="text-sm text-[--text-secondary]">
{employee.role} {employee.department && `${employee.department}`}
</p>
</div>
{employee.isOwner && (
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full">
Owner
</span>
)}
</div>
<div className="flex space-x-2">
{report && (
<Button
size="sm"
variant="secondary"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Hide' : 'View'} Report
</Button>
)}
{isOwner && mode === 'reports' && (
<Button
size="sm"
onClick={() => onGenerateReport?.(employee)}
disabled={isGeneratingReport}
>
{isGeneratingReport ? 'Generating...' : report ? 'Regenerate Report' : 'Generate Report'}
</Button>
)}
</div>
</div>
{isExpanded && report && (
<div className="mt-4 pt-4 border-t border-[--border-color] space-y-4">
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Role & Output</h4>
<p className="text-sm text-[--text-secondary]">{report.roleAndOutput.responsibilities}</p>
</div>
{report.grading?.[0]?.scores && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-[--background-tertiary] rounded-lg p-4">
<RadarPerformanceChart
title="Performance Profile"
data={report.grading[0].scores.map(s => ({ label: s.subject, value: (s.value / s.fullMark) * 100 }))}
/>
</div>
<div className="bg-[--background-tertiary] rounded-lg p-4">
<ScoreBarList
title="Score Breakdown"
items={report.grading[0].scores.map(s => ({ label: s.subject, value: s.value, max: s.fullMark }))}
/>
</div>
</div>
)}
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Key Strengths</h4>
<div className="flex flex-wrap gap-2">
{report.insights.strengths.map((strength, idx) => (
<span key={idx} className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
{strength}
</span>
))}
</div>
</div>
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Development Areas</h4>
<div className="flex flex-wrap gap-2">
{report.insights.weaknesses.map((weakness, idx) => (
<span key={idx} className="px-2 py-1 bg-orange-100 text-orange-800 text-xs rounded-full">
{weakness}
</span>
))}
</div>
</div>
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Recommendations</h4>
<ul className="space-y-1">
{report.recommendations.map((rec, idx) => (
<li key={idx} className="text-sm text-[--text-secondary] flex items-start">
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
{rec}
</li>
))}
</ul>
</div>
</div>
)}
</Card>
);
};
const EmployeeData: React.FC<EmployeeDataProps> = ({ mode }) => {
const { employees, reports, user, isOwner, getFullCompanyReportHistory, generateEmployeeReport, saveReport, orgId } = useOrg();
const [companyReport, setCompanyReport] = useState<CompanyReport | null>(null);
const [generatingReports, setGeneratingReports] = useState<Set<string>>(new Set());
useEffect(() => {
// Load company report for owners
const loadCompanyReport = async () => {
if (isOwner(user?.uid || '') && mode === 'reports') {
try {
const history = await getFullCompanyReportHistory();
if (history.length > 0) {
setCompanyReport(history[0]);
} else {
// Fallback to sample report if no real report exists
setCompanyReport(SAMPLE_COMPANY_REPORT);
}
} catch (error) {
console.error('Failed to load company report:', error);
setCompanyReport(SAMPLE_COMPANY_REPORT);
}
}
};
loadCompanyReport();
}, [isOwner, user?.uid, mode, getFullCompanyReportHistory]);
const handleGenerateReport = async (employee: Employee) => {
setGeneratingReports(prev => new Set(prev).add(employee.id));
try {
console.log('Generating report for employee:', employee.name, 'in org:', orgId);
// Call the API endpoint with orgId
const response = await fetch(`/api/employee-report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
employeeId: employee.id,
orgId: orgId
}),
});
if (response.ok) {
const result = await response.json();
if (result.success && result.report) {
// Save the report using the context method
await saveReport(employee.id, result.report);
console.log('Report generated and saved successfully');
} else {
console.error('Report generation failed:', result.error || 'Unknown error');
}
} else {
console.error('API call failed:', response.status, response.statusText);
}
} catch (error) {
console.error('Error generating report:', error);
} finally {
setGeneratingReports(prev => {
const newSet = new Set(prev);
newSet.delete(employee.id);
return newSet;
});
}
}; const currentUserIsOwner = isOwner(user?.uid || '');
// Filter employees based on user access
const visibleEmployees = currentUserIsOwner
? employees
: employees.filter(emp => emp.id === user?.uid);
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[--text-primary]">
{mode === 'submissions' ? 'Employee Submissions' : 'Employee Reports'}
</h1>
<p className="text-[--text-secondary] mt-1">
{mode === 'submissions'
? 'Manage employee data and submissions'
: 'View AI-generated insights and reports'}
</p>
</div>
{/* Company Report - Only visible to owners in reports mode */}
{currentUserIsOwner && mode === 'reports' && companyReport && (
<CompanyReportCard report={companyReport} />
)}
{/* Employee Cards */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-[--text-primary]">
{currentUserIsOwner ? 'All Employees' : 'Your Information'}
</h2>
{visibleEmployees.length === 0 ? (
<Card>
<div className="text-center py-8">
<p className="text-[--text-secondary]">No employees found.</p>
{currentUserIsOwner && (
<Button className="mt-4" size="sm">
Invite First Employee
</Button>
)}
</div>
</Card>
) : (
visibleEmployees.map(employee => (
<EmployeeCard
key={employee.id}
employee={employee}
report={reports[employee.id]}
mode={mode}
isOwner={currentUserIsOwner}
onGenerateReport={handleGenerateReport}
isGeneratingReport={generatingReports.has(employee.id)}
/>
))
)}
</div>
</div>
);
};
export default EmployeeData;

View File

@@ -0,0 +1,390 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation, useParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useOrg } from '../contexts/OrgContext';
import { Card, Button } from '../components/UiKit';
import { EMPLOYEE_QUESTIONS, EmployeeSubmissionAnswers } from '../employeeQuestions';
import { Question } from '../components/ui/Question';
import { QuestionInput } from '../components/ui/QuestionInput';
import { LinearProgress } from '../components/ui/Progress';
import { Alert } from '../components/ui/Alert';
import { API_URL } from '../constants';
const EmployeeQuestionnaire: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
const { user } = useAuth();
const { submitEmployeeAnswers, generateEmployeeReport, employees } = useOrg();
const [answers, setAnswers] = useState<EmployeeSubmissionAnswers>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [inviteEmployee, setInviteEmployee] = useState<any>(null);
const [isLoadingInvite, setIsLoadingInvite] = useState(false);
// Check if this is an invite-based flow (no auth needed)
const inviteCode = params.inviteCode;
const isInviteFlow = !!inviteCode;
// Load invite details if this is an invite flow
useEffect(() => {
if (inviteCode) {
loadInviteDetails(inviteCode);
}
}, [inviteCode]);
const loadInviteDetails = async (code: string) => {
setIsLoadingInvite(true);
try {
const response = await fetch(`${API_URL}/api/invitations/${code}`);
if (response.ok) {
const data = await response.json();
setInviteEmployee(data.employee);
setError('');
} else {
setError('Invalid or expired invitation link');
}
} catch (err) {
setError('Failed to load invitation details');
} finally {
setIsLoadingInvite(false);
}
};
// Get employee info from multiple sources
const invitedEmployee = location.state?.invitedEmployee;
// Determine current employee - for invite flow, use invite employee data
let currentEmployee;
if (isInviteFlow) {
currentEmployee = inviteEmployee;
} else {
// Original auth-based logic
currentEmployee = invitedEmployee || employees.find(emp => emp.email === user?.email);
// Additional matching strategies for edge cases
if (!currentEmployee && user?.email) {
// Try case-insensitive email matching
currentEmployee = employees.find(emp =>
emp.email?.toLowerCase() === user.email?.toLowerCase()
);
// Try matching by name if email doesn't work (for invite flow)
if (!currentEmployee && invitedEmployee) {
currentEmployee = employees.find(emp =>
emp.name === invitedEmployee.name || emp.id === invitedEmployee.id
);
}
}
// If no match by email, and we're in demo mode with only one recent employee, use that
if (!currentEmployee && user?.email === 'demo@auditly.local' && employees.length > 0) {
// In demo mode, if there's only one employee or the most recent one, use it
currentEmployee = employees[employees.length - 1];
}
// If still no match and there's only one employee, assume it's them
if (!currentEmployee && employees.length === 1) {
currentEmployee = employees[0];
}
}
// Enhanced debugging
console.log('EmployeeQuestionnaire debug:', {
userEmail: user?.email,
employeesCount: employees.length,
employeeEmails: employees.map(e => ({ id: e.id, email: e.email, name: e.name })),
invitedEmployee,
currentEmployee,
locationState: location.state
});
const handleAnswerChange = (questionId: string, value: string) => {
setAnswers(prev => ({ ...prev, [questionId]: value }));
};
// Filter out followup questions that shouldn't be shown yet
const getVisibleQuestions = () => {
return EMPLOYEE_QUESTIONS.filter(question => {
// Hide follow-up questions since they're now integrated into the parent yes/no question
if (question.followupTo) return false;
return true;
});
};
const handleFollowupChange = (questionId: string, value: string) => {
setAnswers(prev => ({
...prev,
[questionId]: value
}));
};
const submitViaInvite = async (employee: any, answers: EmployeeSubmissionAnswers, inviteCode: string) => {
try {
// First, consume the invite to mark it as used
const consumeResponse = await fetch(`${API_URL}/api/invitations/${inviteCode}/consume`, {
method: 'POST'
});
if (!consumeResponse.ok) {
throw new Error('Failed to process invitation');
}
// Submit the questionnaire answers
const submitResponse = await fetch(`${API_URL}/api/employee-submissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
employeeId: employee.id,
employee: employee,
answers: answers
})
});
if (!submitResponse.ok) {
throw new Error('Failed to submit questionnaire');
}
const result = await submitResponse.json();
return { success: true, reportGenerated: !!result.report };
} catch (error) {
console.error('Invite submission error:', error);
return { success: false, error: error.message };
}
};
const visibleQuestions = getVisibleQuestions();
const handleSubmit = async () => {
setIsSubmitting(true);
setError('');
try {
// Validate required questions
const requiredQuestions = visibleQuestions.filter(q => q.required);
const missingAnswers = requiredQuestions.filter(q => !answers[q.id]?.trim());
if (missingAnswers.length > 0) {
setError(`Please answer all required questions: ${missingAnswers.map(q => q.prompt).join(', ')}`);
setIsSubmitting(false);
return;
}
if (!currentEmployee) {
// Enhanced fallback logic
if (employees.length > 0) {
// Try to find employee by matching with the user's email more aggressively
let fallbackEmployee = employees.find(emp =>
emp.email?.toLowerCase().includes(user?.email?.toLowerCase().split('@')[0] || '')
);
// If still no match, use the most recent employee or one with matching domain
if (!fallbackEmployee) {
const userDomain = user?.email?.split('@')[1];
fallbackEmployee = employees.find(emp =>
emp.email?.split('@')[1] === userDomain
) || employees[employees.length - 1];
}
console.log('Using enhanced fallback employee:', fallbackEmployee);
const success = await submitEmployeeAnswers(fallbackEmployee.id, answers);
if (success) {
// Generate LLM report for fallback employee
console.log('Questionnaire submitted for fallback employee, generating report...');
try {
const report = await generateEmployeeReport(fallbackEmployee);
if (report) {
console.log('Report generated successfully for fallback employee:', report);
}
} catch (reportError) {
console.error('Failed to generate report for fallback employee:', reportError);
}
navigate('/questionnaire-complete', {
replace: true,
state: {
employeeId: fallbackEmployee.id,
employeeName: fallbackEmployee.name,
message: 'Questionnaire submitted successfully! Your responses have been recorded.'
}
});
return;
}
}
setError(`We couldn't match your account (${user?.email}) with an employee record. Please contact your administrator to ensure your invite was set up correctly.`);
setIsSubmitting(false);
return;
}
// Submit answers - different logic for invite vs auth flow
let result;
if (isInviteFlow) {
// Direct API submission for invite flow (no auth needed)
result = await submitViaInvite(currentEmployee, answers, inviteCode);
} else {
// Use org context for authenticated flow
result = await submitEmployeeAnswers(currentEmployee.id, answers);
}
if (result.success) {
// Show success message with AI report info
const message = result.reportGenerated
? 'Questionnaire submitted successfully! Your AI-powered performance report has been generated.'
: 'Questionnaire submitted successfully! Your report will be available shortly.';
setError(null);
// Navigate to completion page with success info
navigate('/questionnaire-complete', {
state: {
employeeId: currentEmployee.id,
employeeName: currentEmployee.name,
reportGenerated: result.reportGenerated,
message: message
}
});
} else {
setError(result.message || 'Failed to submit questionnaire');
}
} catch (error) {
console.error('Submission error:', error);
setError('Failed to submit questionnaire. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const getProgressPercentage = () => {
const answeredQuestions = Object.keys(answers).filter(key => answers[key]?.trim()).length;
return Math.round((answeredQuestions / visibleQuestions.length) * 100);
};
// Early return for invite flow loading state
if (isInviteFlow && isLoadingInvite) {
return (
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
<div className="max-w-4xl mx-auto text-center">
<div className="w-16 h-16 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
A
</div>
<h1 className="text-3xl font-bold text-[--text-primary] mb-4">Loading Your Invitation...</h1>
<p className="text-[--text-secondary]">Please wait while we verify your invitation.</p>
</div>
</div>
);
}
// Early return for invite flow error state
if (isInviteFlow && error) {
return (
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
<div className="max-w-4xl mx-auto text-center">
<div className="w-16 h-16 bg-red-500 rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
!
</div>
<h1 className="text-3xl font-bold text-[--text-primary] mb-4">Invitation Error</h1>
<p className="text-[--text-secondary] mb-6">{error}</p>
<Button onClick={() => window.location.href = '/'}>
Return to Homepage
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
A
</div>
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
Welcome to Auditly!
</h1>
<p className="text-[--text-secondary] mb-4">
Please complete this questionnaire to help us understand your role and create personalized insights.
</p>
{currentEmployee ? (
<div className="inline-flex items-center px-4 py-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<span className="text-sm text-blue-800 dark:text-blue-200">
👋 Hello {currentEmployee.name}! {currentEmployee.role && `(${currentEmployee.role})`}
</span>
</div>
) : (
<div className="space-y-2">
<div className="inline-flex items-center px-4 py-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<span className="text-sm text-yellow-800 dark:text-yellow-200">
Employee info not found. User: {user?.email}, Employees: {employees.length}
</span>
</div>
<div className="text-xs text-[--text-secondary] max-w-md mx-auto">
<p>Don't worry - your account was created successfully! This is likely a temporary sync issue.</p>
<p className="mt-1">You can still complete the questionnaire, and we'll match it to your profile automatically.</p>
</div>
</div>
)}
</div>
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-[--text-secondary]">Progress</span>
<span className="text-sm text-[--text-secondary]">{getProgressPercentage()}%</span>
</div>
<LinearProgress value={getProgressPercentage()} />
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-6">
{visibleQuestions.map((question, index) => (
<Question
key={question.id}
label={`${index + 1}. ${question.prompt}`}
required={question.required}
description={`Category: ${question.category}`}
>
<QuestionInput
question={question}
value={answers[question.id] || ''}
onChange={(value) => handleAnswerChange(question.id, value)}
allAnswers={answers}
onFollowupChange={handleFollowupChange}
/>
</Question>
))}
</div>
{error && (
<div className="mt-6">
<Alert variant="error" title="Error">
{error}
</Alert>
</div>
)}
<div className="mt-8 flex justify-center">
<Button
type="submit"
disabled={isSubmitting || getProgressPercentage() < 70}
className="px-8 py-3"
>
{isSubmitting ? 'Submitting & Generating Report...' : 'Submit & Generate AI Report'}
</Button>
</div>
{getProgressPercentage() < 70 && (
<p className="text-center text-sm text-[--text-secondary] mt-4">
Please answer at least 70% of the questions to submit.
</p>
)}
</form>
</div>
</div>
);
};
export default EmployeeQuestionnaire;

View File

@@ -0,0 +1,381 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useOrg } from '../contexts/OrgContext';
import { Card, Button } from '../components/UiKit';
import { EMPLOYEE_QUESTIONS, EmployeeSubmissionAnswers } from '../employeeQuestions';
import { Question } from '../components/ui/Question';
import { QuestionInput } from '../components/ui/QuestionInput';
import { FigmaQuestion } from '../components/figma/FigmaQuestion';
import { EnhancedFigmaQuestion } from '../components/figma/EnhancedFigmaQuestion';
import { LinearProgress } from '../components/ui/Progress';
import { Alert } from '../components/ui/Alert';
const EmployeeQuestionnaireSteps: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { submitEmployeeAnswers, generateEmployeeReport, employees } = useOrg();
const [answers, setAnswers] = useState<EmployeeSubmissionAnswers>({});
const [currentStep, setCurrentStep] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
// Get employee info from multiple sources
const invitedEmployee = location.state?.invitedEmployee;
// Find current employee info - try multiple strategies
let currentEmployee = invitedEmployee || employees.find(emp => emp.email === user?.email);
// Additional matching strategies for edge cases
if (!currentEmployee && user?.email) {
// Try case-insensitive email matching
currentEmployee = employees.find(emp =>
emp.email?.toLowerCase() === user.email?.toLowerCase()
);
// Try matching by name if email doesn't work (for invite flow)
if (!currentEmployee && invitedEmployee) {
currentEmployee = employees.find(emp =>
emp.name === invitedEmployee.name || emp.id === invitedEmployee.id
);
}
}
// Demo mode fallbacks
if (!currentEmployee && user?.email === 'demo@auditly.local' && employees.length > 0) {
currentEmployee = employees[employees.length - 1];
}
if (!currentEmployee && employees.length === 1) {
currentEmployee = employees[0];
}
// Filter out followup questions that shouldn't be shown yet
const getVisibleQuestions = () => {
return EMPLOYEE_QUESTIONS.filter(question => {
if (!question.followupTo) return true;
const parentAnswer = answers[question.followupTo];
if (question.followupTo === 'has_kpis') {
return parentAnswer === 'Yes';
}
if (question.followupTo === 'unclear_responsibilities') {
return parentAnswer === 'Yes';
}
if (question.followupTo === 'role_shift_interest') {
return parentAnswer === 'Yes';
}
return false;
});
};
const visibleQuestions = getVisibleQuestions();
const currentQuestion = visibleQuestions[currentStep];
const handleAnswerChange = (value: string) => {
if (currentQuestion) {
setAnswers(prev => ({ ...prev, [currentQuestion.id]: value }));
}
};
const handleNext = () => {
if (currentStep < visibleQuestions.length - 1) {
setCurrentStep(prev => prev + 1);
} else {
handleSubmit();
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
};
const handleSubmit = async () => {
setIsSubmitting(true);
setError('');
try {
// Validate required questions
const requiredQuestions = visibleQuestions.filter(q => q.required);
const missingAnswers = requiredQuestions.filter(q => !answers[q.id]?.trim());
if (missingAnswers.length > 0) {
setError(`Please answer all required questions: ${missingAnswers.map(q => q.prompt).join(', ')}`);
setIsSubmitting(false);
return;
}
if (!currentEmployee) {
// Enhanced fallback logic
if (employees.length > 0) {
// Try to find employee by matching with the user's email more aggressively
let fallbackEmployee = employees.find(emp =>
emp.email?.toLowerCase().includes(user?.email?.toLowerCase().split('@')[0] || '')
);
// If still no match, use the most recent employee or one with matching domain
if (!fallbackEmployee) {
const userDomain = user?.email?.split('@')[1];
fallbackEmployee = employees.find(emp =>
emp.email?.split('@')[1] === userDomain
) || employees[employees.length - 1];
}
console.log('Using enhanced fallback employee:', fallbackEmployee);
const success = await submitEmployeeAnswers(fallbackEmployee.id, answers);
if (success) {
try {
const report = await generateEmployeeReport(fallbackEmployee);
if (report) {
console.log('Report generated successfully for fallback employee:', report);
}
} catch (reportError) {
console.error('Failed to generate report for fallback employee:', reportError);
}
navigate('/questionnaire-complete', {
replace: true,
state: {
employeeId: fallbackEmployee.id,
employeeName: fallbackEmployee.name,
message: 'Questionnaire submitted successfully! Your responses have been recorded.'
}
});
return;
}
}
setError(`We couldn't match your account (${user?.email}) with an employee record. Please contact your administrator to ensure your invite was set up correctly.`);
setIsSubmitting(false);
return;
}
const result = await submitEmployeeAnswers(currentEmployee.id, answers);
if (result.success) {
const message = result.reportGenerated
? 'Questionnaire submitted successfully! Your AI-powered performance report has been generated.'
: 'Questionnaire submitted successfully! Your report will be available shortly.';
navigate('/questionnaire-complete', {
state: {
employeeId: currentEmployee.id,
employeeName: currentEmployee.name,
reportGenerated: result.reportGenerated,
message: message
}
});
} else {
setError(result.message || 'Failed to submit questionnaire');
}
} catch (error) {
console.error('Submission error:', error);
setError('Failed to submit questionnaire. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const getProgressPercentage = () => {
return Math.round(((currentStep + 1) / visibleQuestions.length) * 100);
};
const isCurrentQuestionAnswered = () => {
if (!currentQuestion) return false;
const answer = answers[currentQuestion.id];
return answer && answer.trim().length > 0;
};
if (!currentQuestion) {
return (
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-2xl font-bold text-[--text-primary] mb-4">
No questions available
</h1>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
A
</div>
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
Welcome to Auditly!
</h1>
<p className="text-[--text-secondary] mb-4">
Please complete this questionnaire to help us understand your role and create personalized insights.
</p>
{currentEmployee ? (
<div className="inline-flex items-center px-4 py-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<span className="text-sm text-blue-800 dark:text-blue-200">
👋 Hello {currentEmployee.name}! {currentEmployee.role && `(${currentEmployee.role})`}
</span>
</div>
) : (
<div className="space-y-2">
<div className="inline-flex items-center px-4 py-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<span className="text-sm text-yellow-800 dark:text-yellow-200">
Employee info not found. User: {user?.email}, Employees: {employees.length}
</span>
</div>
<div className="text-xs text-[--text-secondary] max-w-md mx-auto">
<p>Don't worry - your account was created successfully! This is likely a temporary sync issue.</p>
<p className="mt-1">You can still complete the questionnaire, and we'll match it to your profile automatically.</p>
</div>
</div>
)}
</div>
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-[--text-secondary]">
Question {currentStep + 1} of {visibleQuestions.length}
</span>
<span className="text-sm text-[--text-secondary]">{getProgressPercentage()}%</span>
</div>
<LinearProgress value={getProgressPercentage()} />
</div>
{/* Enhanced Question Card */}
<div className="flex justify-center mb-8">
<div className="w-full max-w-2xl">
<EnhancedFigmaQuestion
questionNumber={`Q${currentStep + 1}`}
question={currentQuestion}
answer={answers[currentQuestion.id] || ''}
onAnswerChange={handleAnswerChange}
onBack={currentStep > 0 ? handleBack : undefined}
onNext={
currentStep < visibleQuestions.length - 1
? handleNext
: (isCurrentQuestionAnswered() || !currentQuestion.required)
? handleSubmit
: undefined
}
nextLabel={
currentStep < visibleQuestions.length - 1
? 'Next'
: isSubmitting
? 'Submitting...'
: 'Submit & Generate Report'
}
showNavigation={true}
/>
</div>
</div>
{/* Original Figma Question Card for Comparison */}
<div className="flex justify-center mb-8">
<div className="w-full max-w-2xl">
<FigmaQuestion
questionNumber={`Q${currentStep + 1}`}
title={currentQuestion.prompt}
description={currentQuestion.required ? 'Required' : 'Optional'}
answer={answers[currentQuestion.id] || ''}
onAnswerChange={handleAnswerChange}
onBack={currentStep > 0 ? handleBack : undefined}
onNext={
currentStep < visibleQuestions.length - 1
? handleNext
: (isCurrentQuestionAnswered() || !currentQuestion.required)
? handleSubmit
: undefined
}
nextLabel={
currentStep < visibleQuestions.length - 1
? 'Next'
: isSubmitting
? 'Submitting...'
: 'Submit & Generate Report'
}
showNavigation={true}
/>
</div>
</div>
{/* Alternative Input for Different Question Types */}
<div className="flex justify-center mb-8">
<div className="w-full max-w-2xl">
<Card className="p-6">
<Question
label={`${currentStep + 1}. ${currentQuestion.prompt}`}
required={currentQuestion.required}
description={`Category: ${currentQuestion.category}`}
>
<QuestionInput
question={currentQuestion}
value={answers[currentQuestion.id] || ''}
onChange={handleAnswerChange}
/>
</Question>
</Card>
</div>
</div>
{/* Error Display */}
{error && (
<div className="mb-6 flex justify-center">
<div className="w-full max-w-2xl">
<Alert variant="error" title="Error">
{error}
</Alert>
</div>
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-center gap-4">
{currentStep > 0 && (
<Button
variant="secondary"
onClick={handleBack}
disabled={isSubmitting}
>
Back
</Button>
)}
{currentStep < visibleQuestions.length - 1 ? (
<Button
onClick={handleNext}
disabled={currentQuestion.required && !isCurrentQuestionAnswered()}
>
Next
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={
isSubmitting ||
(currentQuestion.required && !isCurrentQuestionAnswered())
}
>
{isSubmitting ? 'Submitting & Generating Report...' : 'Submit & Generate AI Report'}
</Button>
)}
</div>
{/* Help Text */}
<div className="text-center mt-6">
<p className="text-sm text-[--text-secondary]">
{currentQuestion.required && !isCurrentQuestionAnswered()
? 'Please answer this required question to continue.'
: 'You can skip optional questions or come back to them later.'
}
</p>
</div>
</div>
</div>
);
};
export default EmployeeQuestionnaireSteps;

132
pages/FormsDashboard.tsx Normal file
View File

@@ -0,0 +1,132 @@
import React from 'react';
import { Card } from '../components/UiKit';
const FormsDashboard: React.FC = () => {
const forms = [
{
title: 'Question Types Demo',
description: 'Interactive showcase of all question types and input styles',
url: '#/question-types-demo',
color: 'bg-purple-500',
features: ['All Question Types', 'Side-by-side Comparison', 'Live Preview']
},
{
title: 'Traditional Questionnaire',
description: 'Complete questionnaire on a single page with all questions visible',
url: '#/employee-questionnaire',
color: 'bg-blue-500',
features: ['All Questions Visible', 'Scroll Navigation', 'Conditional Logic']
},
{
title: 'Stepped Questionnaire',
description: 'One question at a time with progress tracking and navigation',
url: '#/employee-questionnaire-steps',
color: 'bg-green-500',
features: ['One Question Per Step', 'Progress Tracking', 'Back/Next Navigation']
}
];
return (
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
📝
</div>
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
Employee Forms Dashboard
</h1>
<p className="text-[--text-secondary] max-w-2xl mx-auto">
Explore the different employee questionnaire implementations.
Each form demonstrates different UX approaches and question types based on the Figma designs.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{forms.map((form, index) => (
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
<div className={`w-12 h-12 ${form.color} rounded-lg flex items-center justify-center text-white font-bold text-xl mb-4`}>
{index + 1}
</div>
<h3 className="text-xl font-semibold text-[--text-primary] mb-2">
{form.title}
</h3>
<p className="text-[--text-secondary] mb-4 text-sm">
{form.description}
</p>
<div className="space-y-2 mb-4">
{form.features.map((feature, featureIndex) => (
<div key={featureIndex} className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm text-[--text-secondary]">{feature}</span>
</div>
))}
</div>
<a
href={form.url}
className={`w-full inline-block text-center px-4 py-2 ${form.color} text-white rounded-lg hover:opacity-90 transition-opacity`}
>
Try This Form
</a>
</Card>
))}
</div>
{/* Technical Overview */}
<Card className="p-6">
<h2 className="text-2xl font-semibold text-[--text-primary] mb-4">
Implementation Overview
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">
Question Types Implemented
</h3>
<ul className="space-y-2 text-[--text-secondary]">
<li> <strong>Text Input:</strong> Single-line text for names, titles</li>
<li> <strong>Textarea:</strong> Multi-line for detailed responses</li>
<li> <strong>Scale Rating:</strong> 1-10 sliders with custom labels</li>
<li> <strong>Yes/No Radio:</strong> Binary choice questions</li>
<li> <strong>Select Dropdown:</strong> Predefined option selection</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">
Key Features
</h3>
<ul className="space-y-2 text-[--text-secondary]">
<li> <strong>Conditional Logic:</strong> Follow-up questions based on answers</li>
<li> <strong>Progress Tracking:</strong> Visual completion indicators</li>
<li> <strong>Responsive Design:</strong> Mobile-friendly layouts</li>
<li> <strong>Figma Integration:</strong> Design system compliance</li>
<li> <strong>Theme Support:</strong> Light/dark mode compatibility</li>
</ul>
</div>
</div>
</Card>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-blue-500">32</div>
<div className="text-sm text-[--text-secondary]">Total Questions</div>
</Card>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-green-500">5</div>
<div className="text-sm text-[--text-secondary]">Question Types</div>
</Card>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-purple-500">8</div>
<div className="text-sm text-[--text-secondary]">Categories</div>
</Card>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-orange-500">3</div>
<div className="text-sm text-[--text-secondary]">Conditional Flows</div>
</Card>
</div>
</div>
</div>
);
};
export default FormsDashboard;

335
pages/HelpAndSettings.tsx Normal file
View File

@@ -0,0 +1,335 @@
import React, { useState } from 'react';
import { useTheme } from '../contexts/ThemeContext';
import { useAuth } from '../contexts/AuthContext';
import { useOrg } from '../contexts/OrgContext';
import { Card, Button } from '../components/UiKit';
import { Theme } from '../types';
const HelpAndSettings: React.FC = () => {
const { theme, setTheme } = useTheme();
const { user, signOutUser } = useAuth();
const { org, upsertOrg, issueInviteViaApi } = useOrg();
const [activeTab, setActiveTab] = useState<'settings' | 'help'>('settings');
const [inviteForm, setInviteForm] = useState({ name: '', email: '', role: '', department: '' });
const [isInviting, setIsInviting] = useState(false);
const [inviteResult, setInviteResult] = useState<string | null>(null);
const handleLogout = async () => {
try {
await signOutUser();
} catch (error) {
console.error('Logout error:', error);
}
};
const handleRestartOnboarding = async () => {
try {
await upsertOrg({ onboardingCompleted: false });
// The RequireOnboarding component will redirect automatically
} catch (error) {
console.error('Failed to restart onboarding:', error);
}
};
const handleInviteEmployee = async () => {
if (!inviteForm.name.trim() || !inviteForm.email.trim() || isInviting) return;
setIsInviting(true);
setInviteResult(null);
try {
const result = await issueInviteViaApi({
name: inviteForm.name.trim(),
email: inviteForm.email.trim(),
role: inviteForm.role.trim() || undefined,
department: inviteForm.department.trim() || undefined
});
setInviteResult(`Invitation sent! Share this link: ${result.inviteLink}`);
setInviteForm({ name: '', email: '', role: '', department: '' });
} catch (error) {
console.error('Failed to send invitation:', error);
setInviteResult('Failed to send invitation. Please try again.');
} finally {
setIsInviting(false);
}
};
const renderSettings = () => (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Appearance</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Theme
</label>
<div className="flex space-x-2">
<Button
variant={theme === Theme.Light ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTheme(Theme.Light)}
>
Light
</Button>
<Button
variant={theme === Theme.Dark ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTheme(Theme.Dark)}
>
Dark
</Button>
<Button
variant={theme === Theme.System ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTheme(Theme.System)}
>
System
</Button>
</div>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Organization</h3>
<div className="space-y-3">
<div>
<span className="text-sm text-[--text-secondary]">Company:</span>
<div className="font-medium text-[--text-primary]">{org?.name}</div>
</div>
<div>
<span className="text-sm text-[--text-secondary]">Onboarding:</span>
<div className="font-medium text-[--text-primary]">
{org?.onboardingCompleted ? 'Completed' : 'Incomplete'}
</div>
</div>
<div className="pt-4">
<Button variant="secondary" onClick={handleRestartOnboarding}>
Restart Onboarding
</Button>
<p className="text-xs text-[--text-secondary] mt-2">
This will reset your company profile and require you to complete the setup process again.
</p>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Invite Employee</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-1">
Name *
</label>
<input
type="text"
value={inviteForm.name}
onChange={(e) => setInviteForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-1">
Email *
</label>
<input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="john.doe@company.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-1">
Role
</label>
<input
type="text"
value={inviteForm.role}
onChange={(e) => setInviteForm(prev => ({ ...prev, role: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Senior Developer"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-1">
Department
</label>
<input
type="text"
value={inviteForm.department}
onChange={(e) => setInviteForm(prev => ({ ...prev, department: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Engineering"
/>
</div>
</div>
<Button
onClick={handleInviteEmployee}
disabled={!inviteForm.name.trim() || !inviteForm.email.trim() || isInviting}
className="w-full"
>
{isInviting ? 'Sending Invitation...' : 'Send Invitation'}
</Button>
{inviteResult && (
<div className={`p-3 rounded-md text-sm ${inviteResult.includes('Failed')
? 'bg-red-50 text-red-800 border border-red-200'
: 'bg-green-50 text-green-800 border border-green-200'
}`}>
{inviteResult}
</div>
)}
<p className="text-xs text-[--text-secondary]">
The invited employee will receive an email with instructions to join your organization.
</p>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Account</h3>
<div className="space-y-3">
<div>
<span className="text-sm text-[--text-secondary]">Email:</span>
<div className="font-medium text-[--text-primary]">{user?.email}</div>
</div>
<div>
<span className="text-sm text-[--text-secondary]">User ID:</span>
<div className="font-medium text-[--text-primary] font-mono text-xs">{user?.uid}</div>
</div>
<div className="pt-4">
<Button variant="secondary" onClick={handleLogout}>
Sign Out
</Button>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Data & Privacy</h3>
<div className="space-y-3">
<Button variant="secondary" className="w-full justify-start">
Export My Data
</Button>
<Button variant="secondary" className="w-full justify-start">
Privacy Settings
</Button>
<Button variant="secondary" className="w-full justify-start text-red-600">
Delete Account
</Button>
</div>
</Card>
</div>
);
const renderHelp = () => (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Getting Started</h3>
<div className="space-y-4">
<div>
<h4 className="font-medium text-[--text-primary] mb-2">1. Set up your organization</h4>
<p className="text-[--text-secondary] text-sm">
Complete the onboarding process to configure your company information and preferences.
</p>
</div>
<div>
<h4 className="font-medium text-[--text-primary] mb-2">2. Add employees</h4>
<p className="text-[--text-secondary] text-sm">
Invite team members and add their basic information to start generating reports.
</p>
</div>
<div>
<h4 className="font-medium text-[--text-primary] mb-2">3. Generate reports</h4>
<p className="text-[--text-secondary] text-sm">
Use AI-powered reports to gain insights into employee performance and organizational health.
</p>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Frequently Asked Questions</h3>
<div className="space-y-4">
<div>
<h4 className="font-medium text-[--text-primary] mb-2">How do I add new employees?</h4>
<p className="text-[--text-secondary] text-sm">
Go to the Reports page and use the "Add Employee" button to invite new team members.
</p>
</div>
<div>
<h4 className="font-medium text-[--text-primary] mb-2">How are reports generated?</h4>
<p className="text-[--text-secondary] text-sm">
Reports use AI to analyze employee data and provide insights on performance, strengths, and development opportunities.
</p>
</div>
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Is my data secure?</h4>
<p className="text-[--text-secondary] text-sm">
Yes, all data is encrypted and stored securely. We follow industry best practices for data protection.
</p>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Contact Support</h3>
<div className="space-y-3">
<Button variant="secondary" className="w-full justify-start">
📧 Email Support
</Button>
<Button variant="secondary" className="w-full justify-start">
💬 Live Chat
</Button>
<Button variant="secondary" className="w-full justify-start">
📚 Documentation
</Button>
</div>
</Card>
</div>
);
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[--text-primary]">Help & Settings</h1>
<p className="text-[--text-secondary] mt-1">
Manage your account and get help
</p>
</div>
<div className="mb-6">
<div className="flex space-x-4 border-b border-[--border-color]">
<button
onClick={() => setActiveTab('settings')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${activeTab === 'settings'
? 'border-blue-500 text-blue-500'
: 'border-transparent text-[--text-secondary] hover:text-[--text-primary]'
}`}
>
Settings
</button>
<button
onClick={() => setActiveTab('help')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${activeTab === 'help'
? 'border-blue-500 text-blue-500'
: 'border-transparent text-[--text-secondary] hover:text-[--text-primary]'
}`}
>
Help
</button>
</div>
</div>
{activeTab === 'settings' ? renderSettings() : renderHelp()}
</div>
);
};
export default HelpAndSettings;

266
pages/Login.tsx Normal file
View File

@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useOrg } from '../contexts/OrgContext';
import { Card, Button } from '../components/UiKit';
const Login: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { inviteCode: routeInviteCode } = useParams<{ inviteCode: string }>();
const [email, setEmail] = useState('demo@auditly.com');
const [password, setPassword] = useState('demo123');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [inviteCode, setInviteCode] = useState<string | null>(null);
const { signInWithGoogle, signInWithEmail, signUpWithEmail, user, loading } = useAuth();
const { consumeInvite, org } = useOrg();
useEffect(() => {
// Check for invite code in route params first, then fallback to query params
if (routeInviteCode) {
console.log('Invite code from route params:', routeInviteCode);
setInviteCode(routeInviteCode);
// Clear demo credentials for invite flow
setEmail('');
setPassword('');
} else {
// Extract query params from hash-based URL
const hashSearch = location.hash.includes('?') ? location.hash.split('?')[1] : '';
const searchParams = new URLSearchParams(hashSearch);
const queryInvite = searchParams.get('invite');
if (queryInvite) {
console.log('Invite code from query params:', queryInvite);
setInviteCode(queryInvite);
// Clear demo credentials for invite flow
setEmail('');
setPassword('');
}
}
}, [routeInviteCode, location]);
const handleSuccessfulLogin = async () => {
if (inviteCode) {
// Invite flow - redirect to org selection with invite code
navigate(`/org-selection?invite=${inviteCode}`, { replace: true });
} else {
// Regular login - redirect to org selection to choose/create org
navigate('/org-selection', { replace: true });
}
};
useEffect(() => {
if (user && !loading) {
handleSuccessfulLogin();
}
}, [user, loading]);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
if (inviteCode) {
// For invites, try to create account first since they're new users
console.log('Invite flow: attempting to create account for', email);
await signUpWithEmail(email, password, email.split('@')[0]);
} else {
// Regular login
await signInWithEmail(email, password);
}
// Success will be handled by the useEffect hook
} catch (error) {
console.error('Auth failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (inviteCode) {
// For invite flow, if account creation failed, try login instead
if (errorMessage.includes('User already exists') || errorMessage.includes('already-exists')) {
try {
console.log('Account exists, trying login instead...');
await signInWithEmail(email, password);
} catch (loginError) {
console.error('Login also failed:', loginError);
setError(`Account exists but password is incorrect. Please check your password or contact your administrator.`);
setIsLoading(false);
}
} else {
setError(`Failed to create account: ${errorMessage}. Please try a different email or contact your administrator.`);
setIsLoading(false);
}
} else {
// Regular login flow - try signup if user not found
if (errorMessage.includes('User not found')) {
try {
console.log('User not found, attempting sign-up...');
await signUpWithEmail(email, password, email.split('@')[0]);
// Success will be handled by the useEffect hook
} catch (signUpError) {
console.error('Sign-up also failed:', signUpError);
setError(`Failed to create account: ${signUpError instanceof Error ? signUpError.message : 'Unknown error'}`);
setIsLoading(false);
}
} else {
setError(`Login failed: ${errorMessage}`);
setIsLoading(false);
}
}
}
};
const handleGoogleLogin = async () => {
setIsLoading(true);
setError('');
try {
await signInWithGoogle();
// Success will be handled by the useEffect hook
} catch (error) {
console.error('Google login failed:', error);
setError(`Google login failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[--background-primary] py-12 px-4 sm:px-6 lg:px-8">
<Card className="max-w-md w-full space-y-8" padding="lg">
<div className="text-center">
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
A
</div>
<h2 className="text-3xl font-bold text-[--text-primary]">Welcome to Auditly</h2>
<p className="text-[--text-secondary] mt-2">
{inviteCode ? 'Complete your profile to join the team survey' : 'Sign in to your account'}
</p>
{inviteCode && (
<div className="mt-3 p-3 bg-blue-100 dark:bg-blue-900 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200">
<EFBFBD> <strong>Employee Survey Invitation</strong><br />
No account needed! Just create a password to secure your responses and start the questionnaire.
</p>
</div>
)}
{error && (
<div className="mt-3 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">
{error}
</p>
</div>
)}
</div>
<form onSubmit={handleEmailLogin} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email {inviteCode && <span className="text-gray-500 dark:text-gray-400">(use your work email)</span>}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password {inviteCode && <span className="text-gray-500 dark:text-gray-400">(create a new password)</span>}
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
required
/>
{inviteCode && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Choose a secure password for your new account
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Processing...' : (inviteCode ? 'Create Account & Join Team' : 'Sign In')}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Or continue with</p>
<Button
variant="secondary"
className="w-full"
onClick={handleGoogleLogin}
disabled={isLoading}
>
Sign in with Google
</Button>
</div>
{/* Manual invite code entry - only show if no invite code in URL */}
{!inviteCode && (
<div className="border-t border-[--border-color] pt-6">
<div className="text-center mb-4">
<h3 className="text-sm font-medium text-[--text-primary] mb-2">Employee? Use Your Invite Code</h3>
<p className="text-xs text-[--text-secondary]">
Skip account creation - employees can go directly to their questionnaire
</p>
</div>
<div className="flex space-x-3">
<input
type="text"
placeholder="Enter your invite code"
className="flex-1 px-3 py-2 border border-[--input-border] rounded-lg bg-[--input-bg] text-[--text-primary] placeholder-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent]"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const code = (e.target as HTMLInputElement).value.trim();
if (code) {
window.location.href = `#/invite/${code}`;
} else {
alert('Please enter an invite code');
}
}
}}
/>
<Button
variant="secondary"
onClick={() => {
const input = document.querySelector('input[placeholder="Enter your invite code"]') as HTMLInputElement;
const code = input?.value.trim();
if (code) {
window.location.href = `#/invite/${code}`;
} else {
alert('Please enter an invite code');
}
}}
>
Start Survey
</Button>
</div>
<p className="text-xs text-[--text-secondary] mt-2 text-center">
No account needed - just answer questions and submit
</p>
</div>
)}
<div className="text-center">
<p className="text-xs text-[--text-secondary]">
{inviteCode ?
'Demo mode: Enter any email and password to create your account.' :
'Demo mode: No Firebase configuration detected.\nUse any email/password to continue.'
}
</p>
</div>
</Card>
</div>
);
};
export default Login;

403
pages/ModernLogin.tsx Normal file
View File

@@ -0,0 +1,403 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Button } from '../components/UiKit';
type AuthStep = 'email' | 'otp' | 'password-fallback';
const ModernLogin: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { inviteCode: routeInviteCode } = useParams<{ inviteCode: string }>();
// Auth state
const { signInWithGoogle, signInWithEmail, signUpWithEmail, user, loading, sendOTP: authSendOTP, verifyOTP: authVerifyOTP } = useAuth();
// Form state
const [step, setStep] = useState<AuthStep>('email');
const [email, setEmail] = useState('');
const [otp, setOtp] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [inviteCode, setInviteCode] = useState<string | null>(null);
const [resendCooldown, setResendCooldown] = useState(0);
const [demoOTP, setDemoOTP] = useState<string | null>(null);
// Extract invite code from URL
useEffect(() => {
if (routeInviteCode) {
setInviteCode(routeInviteCode);
} else {
const hashSearch = location.hash.includes('?') ? location.hash.split('?')[1] : '';
const searchParams = new URLSearchParams(hashSearch);
const queryInvite = searchParams.get('invite');
if (queryInvite) {
setInviteCode(queryInvite);
}
}
}, [routeInviteCode, location]);
// Handle successful authentication
useEffect(() => {
if (user && !loading) {
if (inviteCode) {
navigate(`/org-selection?invite=${inviteCode}`, { replace: true });
} else {
navigate('/org-selection', { replace: true });
}
}
}, [user, loading, navigate, inviteCode]);
// Resend cooldown timer
useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
const sendOTP = async (emailAddress: string) => {
try {
setIsLoading(true);
setError('');
setDemoOTP(null);
// Call auth context method
const response = await authSendOTP(emailAddress, inviteCode || undefined);
// If OTP is returned in response (demo mode), display it
if (response.otp) {
setDemoOTP(response.otp);
}
setStep('otp');
setResendCooldown(60); // 60 second cooldown
} catch (err) {
console.error('OTP send error:', err);
setError(err instanceof Error ? err.message : 'Failed to send verification code. Please try again.');
} finally {
setIsLoading(false);
}
}; const verifyOTP = async () => {
try {
setIsLoading(true);
setError('');
// Call auth context method
await authVerifyOTP(email, otp, inviteCode || undefined);
// Success - user will be set in auth context and useEffect will handle navigation
} catch (err) {
console.error('OTP verification error:', err);
setError(err instanceof Error ? err.message : 'Invalid verification code. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
await sendOTP(email);
};
const handleOTPSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) return;
await verifyOTP();
};
const handlePasswordFallback = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) return;
try {
setIsLoading(true);
setError('');
// Try login first, then signup if user doesn't exist
try {
await signInWithEmail(email, password);
} catch (loginError) {
// If login fails, try creating account
await signUpWithEmail(email, password, email.split('@')[0]);
}
} catch (err) {
console.error('Password auth error:', err);
setError(err instanceof Error ? err.message : 'Authentication failed');
} finally {
setIsLoading(false);
}
};
const handleGoogleAuth = async () => {
try {
setIsLoading(true);
setError('');
await signInWithGoogle();
} catch (err) {
console.error('Google auth error:', err);
setError('Google authentication failed. Please try again.');
} finally {
setIsLoading(false);
}
};
const renderEmailStep = () => (
<div className="w-full max-w-md mx-auto bg-white rounded-xl shadow-lg p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-2xl font-bold">A</span>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{inviteCode ? 'Join Organization' : 'Welcome to Auditly'}
</h1>
<p className="text-gray-600">
{inviteCode
? 'Enter your email to join the organization'
: 'Enter your email to get started'
}
</p>
</div>
<form onSubmit={handleEmailSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66675 5.83325L8.47085 10.5961C9.02182 10.9818 9.29731 11.1746 9.59697 11.2493C9.86166 11.3153 10.1385 11.3153 10.4032 11.2493C10.7029 11.1746 10.9783 10.9818 11.5293 10.5961L18.3334 5.83325M5.66675 16.6666H14.3334C15.7335 16.6666 16.4336 16.6666 16.9684 16.3941C17.4388 16.1544 17.8212 15.772 18.0609 15.3016C18.3334 14.7668 18.3334 14.0667 18.3334 12.6666V7.33325C18.3334 5.93312 18.3334 5.23306 18.0609 4.69828C17.8212 4.22787 17.4388 3.84542 16.9684 3.60574C16.4336 3.33325 15.7335 3.33325 14.3334 3.33325H5.66675C4.26662 3.33325 3.56655 3.33325 3.03177 3.60574C2.56137 3.84542 2.17892 4.22787 1.93923 4.69828C1.66675 5.23306 1.66675 5.93312 1.66675 7.33325V12.6666C1.66675 14.0667 1.66675 14.7668 1.93923 15.3016C2.17892 15.772 2.56137 16.1544 3.03177 16.3941C3.56655 16.6666 4.26662 16.6666 5.66675 16.6666Z" stroke="#718096" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
className="w-full pl-12 pr-4 py-3.5 bg-gray-50 border text-gray-700 border-gray-200 rounded-full focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
required
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 p-3 rounded-lg">
{error}
</div>
)}
<Button
type="submit"
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-3.5 rounded-full font-medium transition-all transform hover:scale-[1.02]"
disabled={isLoading || !email.trim()}
>
{isLoading ? 'Sending...' : 'Continue with Email'}
</Button>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-3 bg-white text-gray-500">or</span>
</div>
</div>
<Button
type="button"
variant="secondary"
className="w-full border-gray-200 py-3.5 rounded-full transition-colors"
onClick={handleGoogleAuth}
disabled={isLoading}
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Continue with Google
</Button>
<div className="text-center mt-6">
<button
type="button"
onClick={() => setStep('password-fallback')}
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
Use password instead
</button>
</div>
</form>
</div>
);
const renderOTPStep = () => (
<div className="w-full max-w-md mx-auto bg-white rounded-xl shadow-lg p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Check your email</h1>
<p className="text-gray-600">
We sent a verification code to <br />
<strong className="text-gray-900">{email}</strong>
</p>
</div>
<form onSubmit={handleOTPSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2 text-center">
Verification Code
</label>
<input
type="text"
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="w-full px-4 py-4 bg-gray-50 border border-gray-200 text-gray-700 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent text-center text-3xl tracking-[0.5em] font-mono outline-none transition-all"
maxLength={6}
required
/>
</div>
{demoOTP && (
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 border border-yellow-200 rounded-xl p-4">
<div className="text-yellow-800 text-sm text-center">
<div className="flex items-center justify-center mb-2">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<strong>Demo Mode</strong>
</div>
Your verification code is <strong className="text-2xl font-mono bg-yellow-100 px-2 py-1 rounded">{demoOTP}</strong>
</div>
</div>
)}
{error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 p-3 rounded-lg">
{error}
</div>
)}
<Button
type="submit"
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-4 rounded-xl font-medium transition-all transform hover:scale-[1.02]"
disabled={isLoading || otp.length !== 6}
>
{isLoading ? 'Verifying...' : 'Verify Code'}
</Button>
<div className="text-center space-y-3">
<button
type="button"
onClick={() => sendOTP(email)}
disabled={resendCooldown > 0 || isLoading}
className="text-sm text-blue-600 hover:text-blue-800 disabled:text-gray-400 transition-colors"
>
{resendCooldown > 0
? `Resend code in ${resendCooldown}s`
: 'Resend code'
}
</button>
<div>
<button
type="button"
onClick={() => { setStep('email'); setError(''); setOtp(''); }}
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
Change email address
</button>
</div>
</div>
</form>
</div>
);
const renderPasswordStep = () => (
<div className="w-full max-w-md mx-auto bg-white rounded-xl shadow-lg p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Sign in with password</h1>
<p className="text-gray-600">
Enter your password for <br />
<strong className="text-gray-900">{email}</strong>
</p>
</div>
<form onSubmit={handlePasswordFallback} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
className="w-full px-4 py-3.5 bg-gray-50 border border-gray-200 rounded-full focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
required
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 p-3 rounded-lg">
{error}
</div>
)}
<Button
type="submit"
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-3.5 rounded-full font-medium transition-all transform hover:scale-[1.02]"
disabled={isLoading || !password.trim()}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
<div className="text-center">
<button
type="button"
onClick={() => { setStep('email'); setError(''); setPassword(''); }}
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
Back to email verification
</button>
</div>
</form>
</div>
);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 flex items-center justify-center p-4">
{step === 'email' && renderEmailStep()}
{step === 'otp' && renderOTPStep()}
{step === 'password-fallback' && renderPasswordStep()}
</div>
);
};
export default ModernLogin;

719
pages/Onboarding.tsx Normal file
View File

@@ -0,0 +1,719 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOrg } from '../contexts/OrgContext';
import { Card, Button } from '../components/UiKit';
import { FigmaProgress } from '../components/figma/FigmaProgress';
import { FigmaInput } from '../components/figma/FigmaInput';
import { FigmaAlert } from '../components/figma/FigmaAlert';
interface OnboardingData {
// Step 1: Company Basics
companyName: string;
industry: string;
size: string;
description: string;
// Step 2: Mission & Vision
mission: string;
vision: string;
values: string[];
// Step 3: Company Evolution & History
foundingYear: string;
evolution: string;
majorMilestones: string;
// Step 4: Competitive Landscape
advantages: string;
vulnerabilities: string;
competitors: string;
marketPosition: string;
// Step 5: Current Challenges & Goals
currentChallenges: string[];
shortTermGoals: string;
longTermGoals: string;
keyMetrics: string;
// Step 6: Team & Culture
cultureDescription: string;
workEnvironment: string;
leadershipStyle: string;
communicationStyle: string;
// Step 7: Final Review
additionalContext: string;
}
const Onboarding: React.FC = () => {
const { org, upsertOrg, generateCompanyWiki } = useOrg();
const navigate = useNavigate();
const [step, setStep] = useState(0);
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
const [formData, setFormData] = useState<OnboardingData>({
companyName: org?.name || '',
industry: '',
size: '',
description: '',
mission: '',
vision: '',
values: [],
foundingYear: '',
evolution: '',
majorMilestones: '',
advantages: '',
vulnerabilities: '',
competitors: '',
marketPosition: '',
currentChallenges: [],
shortTermGoals: '',
longTermGoals: '',
keyMetrics: '',
cultureDescription: '',
workEnvironment: '',
leadershipStyle: '',
communicationStyle: '',
additionalContext: ''
});
const steps = [
{
title: 'Company Basics',
description: 'Tell us about your company fundamentals'
},
{
title: 'Mission & Vision',
description: 'Define your purpose and direction'
},
{
title: 'Evolution & History',
description: 'Share your company\'s journey'
},
{
title: 'Competitive Position',
description: 'Understand your market position'
},
{
title: 'Goals & Challenges',
description: 'Current objectives and obstacles'
},
{
title: 'Team & Culture',
description: 'Describe your work environment'
},
{
title: 'Final Review',
description: 'Complete your company profile'
}
];
const handleNext = async () => {
// Prevent re-entry during generation
if (isGeneratingReport) return;
if (step < steps.length - 1) {
setStep(prev => prev + 1);
return;
}
// Final step: persist org & generate report
setIsGeneratingReport(true);
console.log('Starting onboarding completion...', { step });
try {
const newOrgData = {
name: formData.companyName,
industry: formData.industry,
size: formData.size,
description: formData.description,
mission: formData.mission,
vision: formData.vision,
values: formData.values.join(','),
foundingYear: formData.foundingYear,
evolution: formData.evolution,
majorMilestones: formData.majorMilestones,
advantages: formData.advantages,
vulnerabilities: formData.vulnerabilities,
competitors: formData.competitors,
marketPosition: formData.marketPosition,
currentChallenges: formData.currentChallenges.join(','),
shortTermGoals: formData.shortTermGoals,
longTermGoals: formData.longTermGoals,
keyMetrics: formData.keyMetrics,
cultureDescription: formData.cultureDescription,
workEnvironment: formData.workEnvironment,
leadershipStyle: formData.leadershipStyle,
communicationStyle: formData.communicationStyle,
additionalContext: formData.additionalContext,
onboardingCompleted: true
};
console.log('Saving org data...', newOrgData);
await upsertOrg(newOrgData);
console.log('Org data saved successfully');
console.log('Generating company wiki...');
await generateCompanyWiki(newOrgData);
console.log('Company wiki generated successfully');
// Small delay to ensure states are updated, then redirect
console.log('Redirecting to reports...');
setTimeout(() => {
console.log('Navigation executing...');
navigate('/reports', { replace: true });
console.log('Navigation called successfully');
}, 100);
} catch (error) {
console.error('Error completing onboarding:', error);
// Show detailed error to user for debugging
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
alert(`There was an error completing the setup: ${errorMessage}. Please check the console for more details and try again.`);
} finally {
setIsGeneratingReport(false);
}
};
const handleBack = () => {
if (step > 0) setStep(step - 1);
};
const addToArray = (field: 'values' | 'currentChallenges', value: string) => {
if (value.trim()) {
setFormData(prev => ({
...prev,
[field]: [...prev[field], value.trim()]
}));
}
};
const removeFromArray = (field: 'values' | 'currentChallenges', index: number) => {
setFormData(prev => ({
...prev,
[field]: prev[field].filter((_, i) => i !== index)
}));
};
const renderStep = () => {
switch (step) {
case 0:
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Company Name *
</label>
<input
type="text"
value={formData.companyName}
onChange={(e) => setFormData(prev => ({ ...prev, companyName: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter your company name"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Industry *
</label>
<select
value={formData.industry}
onChange={(e) => setFormData(prev => ({ ...prev, industry: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select industry</option>
<option value="Technology">Technology</option>
<option value="Healthcare">Healthcare</option>
<option value="Finance">Finance</option>
<option value="Manufacturing">Manufacturing</option>
<option value="Retail">Retail</option>
<option value="Professional Services">Professional Services</option>
<option value="Education">Education</option>
<option value="Media & Entertainment">Media & Entertainment</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Company Size *
</label>
<select
value={formData.size}
onChange={(e) => setFormData(prev => ({ ...prev, size: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select size</option>
<option value="1-10">1-10 employees</option>
<option value="11-50">11-50 employees</option>
<option value="51-200">51-200 employees</option>
<option value="201-1000">201-1000 employees</option>
<option value="1000+">1000+ employees</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Company Description *
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Describe what your company does, its products/services, and target market"
/>
</div>
</div>
);
case 1:
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Mission Statement *
</label>
<textarea
value={formData.mission}
onChange={(e) => setFormData(prev => ({ ...prev, mission: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="What is your company's purpose? Why does it exist?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Vision Statement *
</label>
<textarea
value={formData.vision}
onChange={(e) => setFormData(prev => ({ ...prev, vision: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Where do you see your company in the future? What impact do you want to make?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Core Values
</label>
<div className="space-y-2">
{formData.values.map((value, index) => (
<div key={index} className="flex items-center space-x-2">
<span className="flex-1 px-3 py-2 bg-[--background-tertiary] rounded-lg text-[--text-primary]">
{value}
</span>
<Button
size="sm"
variant="danger"
onClick={() => removeFromArray('values', index)}
>
Remove
</Button>
</div>
))}
<div className="flex space-x-2">
<input
type="text"
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add a core value"
onKeyPress={(e) => {
if (e.key === 'Enter') {
addToArray('values', e.currentTarget.value);
e.currentTarget.value = '';
}
}}
/>
<Button
size="sm"
onClick={(e) => {
const input = (e.target as HTMLElement).parentElement?.querySelector('input');
if (input) {
addToArray('values', input.value);
input.value = '';
}
}}
>
Add
</Button>
</div>
</div>
</div>
</div>
);
case 2:
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Founding Year
</label>
<input
type="text"
value={formData.foundingYear}
onChange={(e) => setFormData(prev => ({ ...prev, foundingYear: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="When was your company founded?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Company Evolution *
</label>
<textarea
value={formData.evolution}
onChange={(e) => setFormData(prev => ({ ...prev, evolution: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="How has your company evolved since its founding? What major changes or pivots have occurred?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Major Milestones
</label>
<textarea
value={formData.majorMilestones}
onChange={(e) => setFormData(prev => ({ ...prev, majorMilestones: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="List key achievements, product launches, funding rounds, or other significant milestones"
/>
</div>
</div>
);
case 3:
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Competitive Advantages *
</label>
<textarea
value={formData.advantages}
onChange={(e) => setFormData(prev => ({ ...prev, advantages: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="What gives your company a competitive edge? What are your unique strengths?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Vulnerabilities & Weaknesses *
</label>
<textarea
value={formData.vulnerabilities}
onChange={(e) => setFormData(prev => ({ ...prev, vulnerabilities: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="What are your company's current weaknesses or areas of vulnerability?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Key Competitors
</label>
<textarea
value={formData.competitors}
onChange={(e) => setFormData(prev => ({ ...prev, competitors: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Who are your main competitors? How do you differentiate from them?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Market Position
</label>
<textarea
value={formData.marketPosition}
onChange={(e) => setFormData(prev => ({ ...prev, marketPosition: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="How do you position yourself in the market? What's your market share or standing?"
/>
</div>
</div>
);
case 4:
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Current Challenges
</label>
<div className="space-y-2">
{formData.currentChallenges.map((challenge, index) => (
<div key={index} className="flex items-center space-x-2">
<span className="flex-1 px-3 py-2 bg-[--background-tertiary] rounded-lg text-[--text-primary]">
{challenge}
</span>
<Button
size="sm"
variant="danger"
onClick={() => removeFromArray('currentChallenges', index)}
>
Remove
</Button>
</div>
))}
<div className="flex space-x-2">
<input
type="text"
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add a current challenge"
onKeyPress={(e) => {
if (e.key === 'Enter') {
addToArray('currentChallenges', e.currentTarget.value);
e.currentTarget.value = '';
}
}}
/>
<Button
size="sm"
onClick={(e) => {
const input = (e.target as HTMLElement).parentElement?.querySelector('input');
if (input) {
addToArray('currentChallenges', input.value);
input.value = '';
}
}}
>
Add
</Button>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Short-term Goals (6-12 months) *
</label>
<textarea
value={formData.shortTermGoals}
onChange={(e) => setFormData(prev => ({ ...prev, shortTermGoals: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="What are your immediate priorities and goals for the next 6-12 months?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Long-term Goals (1-3 years) *
</label>
<textarea
value={formData.longTermGoals}
onChange={(e) => setFormData(prev => ({ ...prev, longTermGoals: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="What are your strategic objectives for the next 1-3 years?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Key Metrics & Success Indicators
</label>
<textarea
value={formData.keyMetrics}
onChange={(e) => setFormData(prev => ({ ...prev, keyMetrics: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="How do you measure success? What are your key performance indicators?"
/>
</div>
</div>
);
case 5:
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Company Culture *
</label>
<textarea
value={formData.cultureDescription}
onChange={(e) => setFormData(prev => ({ ...prev, cultureDescription: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Describe your company culture. What's it like to work at your company?"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Work Environment *
</label>
<select
value={formData.workEnvironment}
onChange={(e) => setFormData(prev => ({ ...prev, workEnvironment: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select work environment</option>
<option value="Remote">Fully Remote</option>
<option value="Hybrid">Hybrid (Remote + Office)</option>
<option value="In-office">In-office</option>
<option value="Flexible">Flexible/Varies by role</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Leadership Style *
</label>
<textarea
value={formData.leadershipStyle}
onChange={(e) => setFormData(prev => ({ ...prev, leadershipStyle: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Describe the leadership approach and management style in your organization"
/>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Communication Style *
</label>
<textarea
value={formData.communicationStyle}
onChange={(e) => setFormData(prev => ({ ...prev, communicationStyle: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="How does your team communicate? What tools and processes do you use?"
/>
</div>
</div>
);
case 6:
return (
<div className="space-y-6">
<div className="text-center mb-6">
<div className="text-4xl mb-4">📋</div>
<h3 className="text-xl font-semibold text-[--text-primary] mb-2">
Review Your Information
</h3>
<p className="text-[--text-secondary]">
Please review the information below and add any additional context
</p>
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg space-y-3">
<div><strong>Company:</strong> {formData.companyName}</div>
<div><strong>Industry:</strong> {formData.industry}</div>
<div><strong>Size:</strong> {formData.size}</div>
<div><strong>Mission:</strong> {formData.mission.substring(0, 100)}{formData.mission.length > 100 ? '...' : ''}</div>
</div>
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Additional Context
</label>
<textarea
value={formData.additionalContext}
onChange={(e) => setFormData(prev => ({ ...prev, additionalContext: e.target.value }))}
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Is there anything else important about your company that Auditly should know to provide better insights?"
/>
</div>
<div className="text-center pt-4">
<div className="text-4xl mb-4">🎉</div>
<h3 className="text-xl font-semibold text-[--text-primary]">
Ready to Complete Setup!
</h3>
<p className="text-[--text-secondary]">
{isGeneratingReport
? 'Generating your personalized company insights...'
: 'Once you complete this step, you\'ll have access to all Auditly features and your personalized company wiki will be generated.'
}
</p>
{isGeneratingReport && (
<div className="mt-4 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-[--text-secondary]">Creating your company profile...</span>
</div>
)}
</div>
</div>
);
default:
return null;
}
};
const canProceed = () => {
switch (step) {
case 0:
return formData.companyName.trim().length > 0 && formData.industry && formData.size && formData.description.trim().length > 0;
case 1:
return formData.mission.trim().length > 0 && formData.vision.trim().length > 0;
case 2:
return formData.evolution.trim().length > 0;
case 3:
return formData.advantages.trim().length > 0 && formData.vulnerabilities.trim().length > 0;
case 4:
return formData.shortTermGoals.trim().length > 0 && formData.longTermGoals.trim().length > 0;
case 5:
return formData.cultureDescription.trim().length > 0 && formData.workEnvironment && formData.leadershipStyle.trim().length > 0 && formData.communicationStyle.trim().length > 0;
case 6:
return true;
default:
return false;
}
};
return (
<div className="min-h-screen bg-[--background-primary] flex items-center justify-center p-4">
<div className="max-w-4xl w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
Welcome to Auditly
</h1>
<p className="text-[--text-secondary]">
Let's build a comprehensive profile of your organization to provide the best insights
</p>
</div>
{/* Progress indicator */}
<div className="mb-8">
<FigmaProgress
currentStep={step + 1}
steps={steps.map((s, i) => ({ number: i + 1, title: s.title }))}
/>
</div>
<Card className="max-w-none">
<div className="mb-6">
<h2 className="text-xl font-semibold text-[--text-primary] mb-2">
{steps[step].title}
</h2>
<p className="text-[--text-secondary]">
{steps[step].description}
</p>
</div>
{renderStep()}
<div className="flex justify-between mt-8">
<Button
variant="secondary"
onClick={handleBack}
disabled={step === 0}
>
Back
</Button>
<Button
onClick={handleNext}
disabled={!canProceed() || isGeneratingReport}
>
{isGeneratingReport
? 'Generating Wiki...'
: step === steps.length - 1 ? 'Complete Setup & Generate Wiki' : 'Next'
}
</Button>
</div>
</Card>
</div>
</div>
);
};
export default Onboarding;

218
pages/OrgSelection.tsx Normal file
View File

@@ -0,0 +1,218 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useUserOrganizations } from '../contexts/UserOrganizationsContext';
import { Card, Button } from '../components/UiKit';
const OrgSelection: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const {
organizations,
loading,
selectOrganization,
createOrganization,
joinOrganization
} = useUserOrganizations();
const [showCreateOrg, setShowCreateOrg] = useState(false);
const [newOrgName, setNewOrgName] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [isJoining, setIsJoining] = useState(false);
useEffect(() => {
// Check for invite code in URL
const hashSearch = location.hash.includes('?') ? location.hash.split('?')[1] : '';
const searchParams = new URLSearchParams(hashSearch);
const queryInvite = searchParams.get('invite');
if (queryInvite) {
setInviteCode(queryInvite);
}
}, [location]);
// Auto-join organization when invite code is present
useEffect(() => {
if (inviteCode && !isJoining && !loading) {
handleJoinWithInvite();
}
}, [inviteCode, isJoining, loading]);
const handleSelectOrg = (orgId: string) => {
selectOrganization(orgId);
// Check if the organization needs onboarding completion
const selectedOrg = organizations.find(org => org.orgId === orgId);
if (selectedOrg && !selectedOrg.onboardingCompleted && selectedOrg.role === 'owner') {
navigate('/onboarding', { replace: true });
} else {
navigate('/reports', { replace: true });
}
};
const handleCreateOrg = async () => {
if (!newOrgName.trim() || isCreating) return;
setIsCreating(true);
try {
const result = await createOrganization(newOrgName);
selectOrganization(result.orgId);
// Check if subscription setup is required
if (result.requiresSubscription) {
navigate(`/subscription-setup?orgId=${result.orgId}`, { replace: true });
} else {
navigate('/onboarding', { replace: true });
}
} catch (error) {
console.error('Failed to create organization:', error);
alert('Failed to create organization. Please try again.');
} finally {
setIsCreating(false);
}
};
const handleJoinWithInvite = async () => {
if (!inviteCode.trim() || isJoining) return;
setIsJoining(true);
try {
const orgId = await joinOrganization(inviteCode);
selectOrganization(orgId);
navigate('/employee-questionnaire', { replace: true });
} catch (error) {
console.error('Failed to join organization:', error);
alert('Failed to join organization. Please check your invite code.');
} finally {
setIsJoining(false);
}
};
if (loading) {
return <div className="p-8">Loading your organizations...</div>;
}
return (
<div className="min-h-screen bg-[--background-primary] py-12">
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
A
</div>
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">Welcome to Auditly</h1>
<p className="text-[--text-secondary]">Select an organization to continue</p>
</div>
{/* Existing Organizations */}
{organizations.length > 0 && (
<Card className="mb-6">
<h2 className="text-lg font-semibold mb-4 text-[--text-primary]">Your Organizations</h2>
<div className="space-y-3">
{organizations.map((org) => (
<div
key={org.orgId}
className="flex items-center justify-between p-3 border border-[--border-color] rounded-lg hover:bg-[--background-secondary] cursor-pointer"
onClick={() => handleSelectOrg(org.orgId)}
>
<div>
<div className="font-medium text-[--text-primary]">{org.name}</div>
<div className="text-sm text-[--text-secondary]">
{org.role} {org.onboardingCompleted ? 'Active' : 'Setup Required'}
</div>
</div>
<Button variant="secondary" size="sm">
Enter
</Button>
</div>
))}
</div>
</Card>
)}
{/* Join with Invite */}
{inviteCode && (
<Card className="mb-6">
<h2 className="text-lg font-semibold mb-4 text-[--text-primary]">Join Organization</h2>
<p className="text-sm text-[--text-secondary] mb-4">
You've been invited to join an organization with code: <code className="bg-[--background-tertiary] px-2 py-1 rounded text-[--text-primary]">{inviteCode}</code>
</p>
<Button onClick={handleJoinWithInvite} className="w-full" disabled={isJoining}>
{isJoining ? 'Joining...' : 'Accept Invitation'}
</Button>
</Card>
)}
{/* Create New Organization */}
<Card>
<h2 className="text-lg font-semibold mb-4 text-[--text-primary]">Create New Organization</h2>
{!showCreateOrg ? (
<Button
onClick={() => setShowCreateOrg(true)}
variant="secondary"
className="w-full"
>
+ Create New Organization
</Button>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[--text-primary] mb-2">
Organization Name
</label>
<input
type="text"
value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)}
placeholder="Enter organization name"
className="w-full px-3 py-2 border border-[--input-border] rounded-lg bg-[--input-bg] text-[--text-primary] placeholder-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent]"
/>
</div>
<div className="flex space-x-3">
<Button
onClick={handleCreateOrg}
disabled={!newOrgName.trim() || isCreating}
className="flex-1"
>
{isCreating ? 'Creating...' : 'Create Organization'}
</Button>
<Button
onClick={() => setShowCreateOrg(false)}
variant="secondary"
className="flex-1"
>
Cancel
</Button>
</div>
</div>
)}
</Card>
{/* Manual Invite Code Entry */}
{!inviteCode && (
<Card className="mt-6">
<h2 className="text-lg font-semibold mb-4 text-[--text-primary]">Have an invite code?</h2>
<div className="flex space-x-3">
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Enter invite code"
className="flex-1 px-3 py-2 border border-[--input-border] rounded-lg bg-[--input-bg] text-[--text-primary] placeholder-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent]"
/>
<Button
onClick={handleJoinWithInvite}
disabled={!inviteCode.trim() || isJoining}
variant="secondary"
>
{isJoining ? 'Joining...' : 'Join'}
</Button>
</div>
</Card>
)}
</div>
</div>
);
};
export default OrgSelection;

218
pages/QuestionTypesDemo.tsx Normal file
View File

@@ -0,0 +1,218 @@
import React, { useState } from 'react';
import { Card } from '../components/UiKit';
import { QuestionInput } from '../components/ui/QuestionInput';
import { Question } from '../components/ui/Question';
import { FigmaQuestion } from '../components/figma/FigmaQuestion';
import { EmployeeQuestion } from '../employeeQuestions';
const QuestionTypesDemo: React.FC = () => {
const [answers, setAnswers] = useState<Record<string, string>>({});
const demoQuestions: EmployeeQuestion[] = [
{
id: 'text_demo',
prompt: 'What is your full name?',
category: 'personal',
type: 'text',
placeholder: 'Enter your full name',
required: true,
},
{
id: 'scale_demo',
prompt: 'How satisfied are you with your role?',
category: 'role',
type: 'scale',
scaleMin: 1,
scaleMax: 10,
scaleLabels: { min: 'Very dissatisfied', max: 'Very satisfied' },
required: true,
},
{
id: 'yesno_demo',
prompt: 'Do you have regular one-on-ones with your manager?',
category: 'collaboration',
type: 'yesno',
required: false,
},
{
id: 'textarea_demo',
prompt: 'Describe your main responsibilities',
category: 'role',
type: 'textarea',
placeholder: 'List your key daily tasks and responsibilities...',
required: true,
},
{
id: 'select_demo',
prompt: 'What is your department?',
category: 'role',
type: 'select',
options: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Operations'],
required: false,
}
];
const [currentDemoQuestion, setCurrentDemoQuestion] = useState(0);
const handleAnswerChange = (questionId: string, value: string) => {
setAnswers(prev => ({ ...prev, [questionId]: value }));
};
return (
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
Question Types Demo
</h1>
<p className="text-[--text-secondary]">
Showcase of different question input types for the employee questionnaire
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Traditional Question Layout */}
<div>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
Traditional Layout
</h2>
<div className="space-y-6">
{demoQuestions.map((question, index) => (
<Card key={question.id} className="p-6">
<Question
label={`${index + 1}. ${question.prompt}`}
required={question.required}
description={`Type: ${question.type} | Category: ${question.category}`}
>
<QuestionInput
question={question}
value={answers[question.id] || ''}
onChange={(value) => handleAnswerChange(question.id, value)}
/>
</Question>
</Card>
))}
</div>
</div>
{/* Figma Question Layout */}
<div>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
Figma Design Layout
</h2>
<div className="mb-4">
<div className="flex gap-2 mb-4">
{demoQuestions.map((_, index) => (
<button
key={index}
onClick={() => setCurrentDemoQuestion(index)}
className={`px-3 py-1 rounded text-sm ${currentDemoQuestion === index
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Q{index + 1}
</button>
))}
</div>
</div>
<div className="flex justify-center">
<FigmaQuestion
questionNumber={`Q${currentDemoQuestion + 1}`}
title={demoQuestions[currentDemoQuestion].prompt}
description={
demoQuestions[currentDemoQuestion].required
? 'Required'
: 'Optional'
}
answer={answers[demoQuestions[currentDemoQuestion].id] || ''}
onAnswerChange={(value) =>
handleAnswerChange(demoQuestions[currentDemoQuestion].id, value)
}
onBack={
currentDemoQuestion > 0
? () => setCurrentDemoQuestion(prev => prev - 1)
: undefined
}
onNext={
currentDemoQuestion < demoQuestions.length - 1
? () => setCurrentDemoQuestion(prev => prev + 1)
: undefined
}
nextLabel={
currentDemoQuestion < demoQuestions.length - 1
? 'Next'
: 'Finish'
}
/>
</div>
{/* Alternative Input Below Figma Question */}
<div className="mt-6 max-w-2xl mx-auto">
<Card className="p-6">
<Question
label={`Alternative Input for Q${currentDemoQuestion + 1}`}
description={`Specialized ${demoQuestions[currentDemoQuestion].type} input`}
>
<QuestionInput
question={demoQuestions[currentDemoQuestion]}
value={answers[demoQuestions[currentDemoQuestion].id] || ''}
onChange={(value) =>
handleAnswerChange(demoQuestions[currentDemoQuestion].id, value)
}
/>
</Question>
</Card>
</div>
</div>
</div>
{/* Current Answers Display */}
<div className="mt-8">
<Card className="p-6">
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">
Current Answers
</h3>
<div className="space-y-2">
{demoQuestions.map((question) => (
<div key={question.id} className="grid grid-cols-1 md:grid-cols-3 gap-2 py-2 border-b border-[--border-color]">
<div className="font-medium text-[--text-primary]">
{question.prompt}
</div>
<div className="text-[--text-secondary] text-sm">
{question.type}
</div>
<div className="text-[--text-primary]">
{answers[question.id] || <span className="text-[--text-secondary] italic">No answer</span>}
</div>
</div>
))}
</div>
</Card>
</div>
{/* Navigation Links */}
<div className="mt-8 text-center">
<div className="space-x-4">
<a
href="#/employee-questionnaire"
className="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Traditional Questionnaire
</a>
<a
href="#/employee-questionnaire-steps"
className="inline-block px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
>
Stepped Questionnaire
</a>
</div>
</div>
</div>
</div>
);
};
export default QuestionTypesDemo;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { CheckCircle, FileText, Sparkles } from 'lucide-react';
const QuestionnaireComplete: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const {
employeeName = 'Employee',
reportGenerated = false,
message = 'Thank you for completing the questionnaire!'
} = location.state || {};
return (
<div className="min-h-screen bg-[--background-primary] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[--background-secondary] rounded-lg shadow-lg p-8 text-center">
<div className="flex justify-center mb-6">
{reportGenerated ? (
<div className="relative">
<CheckCircle className="w-16 h-16 text-green-500" />
<Sparkles className="w-6 h-6 text-yellow-400 absolute -top-1 -right-1" />
</div>
) : (
<CheckCircle className="w-16 h-16 text-green-500" />
)}
</div>
<h1 className="text-2xl font-bold text-[--text-primary] mb-4">
Questionnaire Complete!
</h1>
<p className="text-[--text-secondary] mb-6">
{message}
</p>
{reportGenerated && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center mb-2">
<FileText className="w-5 h-5 text-blue-600 mr-2" />
<span className="text-sm font-medium text-blue-600">AI Report Generated</span>
</div>
<p className="text-sm text-blue-600 dark:text-blue-300">
Your personalized performance report has been created using AI analysis of your responses.
</p>
</div>
)}
<div className="space-y-3">
<button
onClick={() => navigate('/reports')}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
{reportGenerated ? 'View Your Report' : 'Go to Dashboard'}
</button>
<button
onClick={() => navigate('/')}
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Return to Home
</button>
</div>
</div>
</div>
);
};
export default QuestionnaireComplete;

210
pages/SubscriptionSetup.tsx Normal file
View File

@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useUserOrganizations } from '../contexts/UserOrganizationsContext';
const SubscriptionSetup: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user } = useAuth();
const { createCheckoutSession, getSubscriptionStatus } = useUserOrganizations();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [orgId, setOrgId] = useState<string | null>(null);
useEffect(() => {
const orgIdParam = searchParams.get('orgId');
const sessionId = searchParams.get('session_id');
const canceled = searchParams.get('canceled');
if (orgIdParam) {
setOrgId(orgIdParam);
}
if (sessionId) {
// Handle successful checkout
handleSuccessfulCheckout(sessionId);
} else if (canceled) {
setError('Subscription setup was canceled. You can try again or use the 14-day trial.');
}
}, [searchParams]);
const handleSuccessfulCheckout = async (sessionId: string) => {
if (!orgId) return;
try {
setLoading(true);
// Get updated subscription status
await getSubscriptionStatus(orgId);
// Redirect to onboarding to complete organization setup
setTimeout(() => {
navigate('/onboarding', { replace: true });
}, 2000);
} catch (error) {
console.error('Error handling successful checkout:', error);
setError('There was an issue verifying your subscription. Please contact support.');
} finally {
setLoading(false);
}
};
const handleStartSubscription = async () => {
if (!user || !orgId) return;
try {
setLoading(true);
setError(null);
const { sessionUrl } = await createCheckoutSession(orgId, user.email!);
// Redirect to Stripe Checkout
window.location.href = sessionUrl;
} catch (error) {
console.error('Failed to create checkout session:', error);
setError('Failed to start subscription setup. Please try again.');
} finally {
setLoading(false);
}
};
const handleSkipForNow = () => {
// Allow user to continue with trial - go to onboarding to complete setup
navigate('/onboarding', { replace: true });
};
if (searchParams.get('session_id')) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Subscription Active!</h2>
<p className="text-gray-600 mb-4">
Your subscription has been successfully set up. Redirecting to complete your organization setup...
</p>
{loading && (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
)}
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="text-center">
<h2 className="text-3xl font-extrabold text-gray-900">Complete Your Setup</h2>
<p className="mt-2 text-sm text-gray-600">
Set up your subscription to unlock all features
</p>
</div>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-800">{error}</p>
</div>
</div>
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">14-Day Free Trial</h3>
<p className="mt-2 text-sm text-blue-700">
Start with a free trial. No payment required until the trial ends.
</p>
</div>
</div>
</div>
<div className="border rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Auditly Standard Plan</h3>
<div className="space-y-2 mb-4">
<div className="flex items-center">
<svg className="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-600">Up to 50 employees</span>
</div>
<div className="flex items-center">
<svg className="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-600">AI-powered employee reports</span>
</div>
<div className="flex items-center">
<svg className="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-600">Company analytics & insights</span>
</div>
<div className="flex items-center">
<svg className="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-600">AI chat assistant</span>
</div>
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">$29<span className="text-sm font-normal text-gray-600">/month</span></div>
<p className="text-xs text-gray-500">Billed monthly, cancel anytime</p>
</div>
<div className="space-y-3">
<button
onClick={handleStartSubscription}
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
'Start Subscription'
)}
</button>
<button
onClick={handleSkipForNow}
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Continue with Trial
</button>
</div>
<p className="text-xs text-center text-gray-500">
Your trial will last 14 days. No payment required until the trial ends.
</p>
</div>
</div>
</div>
</div>
);
};
export default SubscriptionSetup;

3
postcss.config.mjs Normal file
View File

@@ -0,0 +1,3 @@
// Tailwind CSS v4: use the separate @tailwindcss/postcss plugin
export default { plugins: { "@tailwindcss/postcss": {} } }

1162
server/index.js Normal file

File diff suppressed because it is too large Load Diff

219
services/demoStorage.ts Normal file
View File

@@ -0,0 +1,219 @@
// Demo mode data persistence using localStorage
// This provides a more robust storage solution for demo mode without Firebase
export interface DemoUser {
uid: string;
email: string;
displayName: string;
passwordHash: string; // Simple hash for demo purposes
}
export interface DemoOrganization {
orgId: string;
name: string;
onboardingCompleted: boolean;
[key: string]: any; // Additional org data from onboarding
}
export interface DemoEmployee {
id: string;
name: string;
email: string;
role?: string;
department?: string;
orgId: string;
}
export interface DemoSubmission {
employeeId: string;
orgId: string;
answers: Record<string, string>;
createdAt: number;
}
export interface DemoInvite {
code: string;
employee: DemoEmployee;
used: boolean;
createdAt: number;
orgId: string;
}
class DemoStorageService {
private getKey(key: string): string {
return `auditly_demo_${key}`;
}
// User management
saveUser(user: DemoUser): void {
const users = this.getUsers();
users[user.email] = user;
localStorage.setItem(this.getKey('users'), JSON.stringify(users));
}
getUsers(): Record<string, DemoUser> {
const data = localStorage.getItem(this.getKey('users'));
return data ? JSON.parse(data) : {};
}
getUserByEmail(email: string): DemoUser | null {
const users = this.getUsers();
return users[email] || null;
}
getUserByUid(uid: string): DemoUser | null {
const users = this.getUsers();
return Object.values(users).find(user => user.uid === uid) || null;
}
// Simple password hashing for demo (not secure, just for demo purposes)
hashPassword(password: string): string {
return btoa(password).split('').reverse().join('');
}
verifyPassword(password: string, hash: string): boolean {
return this.hashPassword(password) === hash;
}
// Organization management
saveOrganization(org: DemoOrganization): void {
const orgs = this.getOrganizations();
orgs[org.orgId] = org;
localStorage.setItem(this.getKey('organizations'), JSON.stringify(orgs));
}
getOrganizations(): Record<string, DemoOrganization> {
const data = localStorage.getItem(this.getKey('organizations'));
return data ? JSON.parse(data) : {};
}
getOrganization(orgId: string): DemoOrganization | null {
const orgs = this.getOrganizations();
return orgs[orgId] || null;
}
// Employee management
saveEmployee(employee: DemoEmployee): void {
const employees = this.getEmployees();
const key = `${employee.orgId}_${employee.id}`;
employees[key] = employee;
localStorage.setItem(this.getKey('employees'), JSON.stringify(employees));
}
getEmployees(): Record<string, DemoEmployee> {
const data = localStorage.getItem(this.getKey('employees'));
return data ? JSON.parse(data) : {};
}
getEmployeesByOrg(orgId: string): DemoEmployee[] {
const employees = this.getEmployees();
return Object.values(employees).filter(emp => emp.orgId === orgId);
}
// Submission management
saveSubmission(submission: DemoSubmission): void {
const submissions = this.getSubmissions();
const key = `${submission.orgId}_${submission.employeeId}`;
submissions[key] = submission;
localStorage.setItem(this.getKey('submissions'), JSON.stringify(submissions));
}
getSubmissions(): Record<string, DemoSubmission> {
const data = localStorage.getItem(this.getKey('submissions'));
return data ? JSON.parse(data) : {};
}
getSubmissionsByOrg(orgId: string): Record<string, DemoSubmission> {
const submissions = this.getSubmissions();
const result: Record<string, DemoSubmission> = {};
Object.entries(submissions).forEach(([key, sub]) => {
if (sub.orgId === orgId) {
result[sub.employeeId] = sub;
}
});
return result;
}
// Invite management
saveInvite(invite: DemoInvite): void {
const invites = this.getInvites();
invites[invite.code] = invite;
localStorage.setItem(this.getKey('invites'), JSON.stringify(invites));
}
getInvites(): Record<string, DemoInvite> {
const data = localStorage.getItem(this.getKey('invites'));
return data ? JSON.parse(data) : {};
}
getInvite(code: string): DemoInvite | null {
const invites = this.getInvites();
return invites[code] || null;
}
markInviteUsed(code: string): boolean {
const invite = this.getInvite(code);
if (invite && !invite.used) {
invite.used = true;
this.saveInvite(invite);
return true;
}
return false;
}
// Company reports (simple storage)
saveCompanyReport(orgId: string, report: any): void {
const reports = this.getCompanyReports();
if (!reports[orgId]) reports[orgId] = [];
reports[orgId].push(report);
localStorage.setItem(this.getKey('company_reports'), JSON.stringify(reports));
}
getCompanyReports(): Record<string, any[]> {
const data = localStorage.getItem(this.getKey('company_reports'));
return data ? JSON.parse(data) : {};
}
getCompanyReportsByOrg(orgId: string): any[] {
const reports = this.getCompanyReports();
return reports[orgId] || [];
}
// Employee reports
saveEmployeeReport(orgId: string, employeeId: string, report: any): void {
const reports = this.getEmployeeReports();
const key = `${orgId}_${employeeId}`;
reports[key] = report;
localStorage.setItem(this.getKey('employee_reports'), JSON.stringify(reports));
}
getEmployeeReports(): Record<string, any> {
const data = localStorage.getItem(this.getKey('employee_reports'));
return data ? JSON.parse(data) : {};
}
getEmployeeReportsByOrg(orgId: string): Record<string, any> {
const reports = this.getEmployeeReports();
const result: Record<string, any> = {};
Object.entries(reports).forEach(([key, report]) => {
if (key.startsWith(`${orgId}_`)) {
const employeeId = key.substring(orgId.length + 1);
result[employeeId] = report;
}
});
return result;
}
// Clear all demo data (for testing)
clearAllData(): void {
const keys = [
'users', 'organizations', 'employees', 'submissions',
'invites', 'company_reports', 'employee_reports'
];
keys.forEach(key => {
localStorage.removeItem(this.getKey(key));
});
}
}
export const demoStorage = new DemoStorageService();

41
services/firebase.ts Normal file
View File

@@ -0,0 +1,41 @@
import { initializeApp, getApps } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
export const isFirebaseConfigured = Boolean(
firebaseConfig.apiKey &&
firebaseConfig.authDomain &&
firebaseConfig.projectId &&
firebaseConfig.apiKey !== 'undefined' &&
firebaseConfig.authDomain !== 'undefined' &&
firebaseConfig.projectId !== 'undefined' &&
// Force demo mode on localhost for testing
!window.location.hostname.includes('localhost')
);
let app;
let auth;
let db;
let googleProvider;
if (isFirebaseConfigured) {
app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
auth = getAuth(app);
db = getFirestore(app);
googleProvider = new GoogleAuthProvider();
} else {
auth = null;
db = null;
googleProvider = null;
}
export { auth, db, googleProvider };

BIN
services/geminiService.ts Normal file

Binary file not shown.

BIN
services/llmService.ts Normal file

Binary file not shown.

54
tailwind.config.js Normal file
View File

@@ -0,0 +1,54 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./**/*.{js,ts,jsx,tsx}",
"./*.{js,ts,jsx,tsx,html,css}"
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Brand colors
brand: {
main: '#5E48FC',
},
// Neutral Light colors
gray: {
1: '#A4A7AE',
2: '#D5D7DA',
3: '#E9EAEB',
4: '#F5F5F5',
5: '#FAFAFA',
6: '#FDFDFD',
},
// Neutral Dark colors
dark: {
1: '#A4A7AE',
2: '#717680',
3: '#535862',
4: '#414651',
5: '#252B37',
6: '#181D27',
7: '#0A0D12',
},
// Status colors
status: {
red: '#F63D68',
green: '#3CCB7F',
orange: '#FF4405',
'orange-light': '#F38744',
yellow: '#FEEE95',
},
// Base colors
base: {
white: '#FFFFFF',
},
},
fontFamily: {
'inter': ['Inter Display', 'Inter', 'sans-serif'],
},
},
},
plugins: [],
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true,
"types": [
"vite/client"
]
}
}

156
types.ts Normal file
View File

@@ -0,0 +1,156 @@
import React from 'react';
export enum Theme {
Light = 'light',
Dark = 'dark',
System = 'system',
}
export interface Employee {
id: string;
name: string;
initials: string;
email: string;
department?: string;
role?: string;
isOwner?: boolean; // Company owner/HR access
}
export interface Report {
employeeId: string;
department: string;
role: string;
roleAndOutput: {
responsibilities: string;
clarityOnRole: string;
selfRatedOutput: string;
recurringTasks: string;
};
insights: {
personalityTraits: string;
psychologicalIndicators: string[];
selfAwareness: string;
emotionalResponses: string;
growthDesire: string;
strengths?: string[];
weaknesses?: string[];
value?: number;
};
actionableItems?: { id: string; title: string; impact: 'High' | 'Medium' | 'Low'; effort: 'High' | 'Medium' | 'Low'; description: string; }[];
roleFitCandidates?: { employeeId: string; roles: string[]; rationale: string; score: number; }[];
potentialExits?: { employeeId: string; risk: 'Low' | 'Medium' | 'High'; reason: string; }[];
traitWeighting?: { trait: string; weight: number; rationale?: string; }[];
strengths: string[];
weaknesses: {
isCritical: boolean;
description: string;
}[];
opportunities: {
roleAdjustment: string;
accountabilitySupport: string;
};
risks: string[];
recommendation: {
action: 'Keep' | 'Restructure' | 'Terminate';
details: string[];
};
grading: {
department: string;
lead: string;
support: string;
grade: string;
comment: string;
scores: { subject: string; value: number; fullMark: number; }[];
}[];
suitabilityScore?: number;
retentionRisk?: 'Low' | 'Medium' | 'High';
costEffectiveness?: 'Underperforming' | 'Aligned' | 'High Value';
}
export interface Submission {
employeeId: string;
answers: {
question: string;
answer: string;
}[];
}
export interface FaqItem {
question: string;
answer: string;
}
export interface NavItem {
href: string;
label: string;
icon: (props: { className?: string }) => React.ReactNode;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
text: string;
isLoading?: boolean;
}
export interface CompanyReport {
id: string;
createdAt: number;
overview: {
totalEmployees: number;
departmentBreakdown: { department: string; count: number; }[];
submissionRate: number; // percentage of employees who submitted
lastUpdated: number;
// Optional aggregate metrics
averagePerformanceScore?: number;
riskLevel?: 'Low' | 'Medium' | 'High';
};
// Personnel lifecycle
personnelChanges: {
newHires: { name: string; department: string; role: string; impact?: string }[];
promotions: { name: string; fromRole: string; toRole: string; impact?: string }[];
departures: { name: string; department: string; reason: string; impact?: string }[];
};
// Backward-compatible flattened list (optional)
keyPersonnelChanges?: Array<{ employeeName: string; role: string; department: string; changeType: 'newHire' | 'promotion' | 'departure'; impact?: string }>;
// Hiring / talent gaps
immediateHiringNeeds: {
department: string;
role: string;
priority: 'High' | 'Medium' | 'Low';
reasoning: string;
urgency?: 'high' | 'medium' | 'low'; // UI alias
}[];
// Operating plan (dual naming for UI compatibility)
operatingPlan: {
nextQuarterGoals: string[];
keyInitiatives: string[];
resourceNeeds: string[];
riskMitigation: string[];
};
forwardOperatingPlan?: { // deprecated; kept for existing calls until phased out
quarterlyGoals: string[];
resourceNeeds: string[];
riskMitigation: string[];
};
// Strengths / risks
organizationalStrengths: Array<{ icon?: string; area: string; description: string }>;
organizationalRisks: string[];
organizationalImpactSummary?: string;
// Grading: array + map for UI
gradingBreakdown: {
category: string;
value: number; // 0-100
rationale?: string;
}[];
gradingOverview?: Record<string, number>; // legacy (0-5 scale expected by old UI)
executiveSummary: string;
}
export interface CompanyReportSummary {
id: string;
createdAt: number;
summary: string; // For backwards compatibility
}

52
utils/urls.ts Normal file
View File

@@ -0,0 +1,52 @@
// URL utilities for consistent URL management across the application
// Get site URL from environment variable or default
export const getSiteUrl = (): string => {
// For client-side (Vite)
if (typeof window !== 'undefined') {
return import.meta.env.VITE_SITE_URL || window.location.origin;
}
// For server-side (Node.js)
return process.env.SITE_URL || 'http://localhost:5173';
};
// Get API URL from environment variable or default
export const getApiUrl = (): string => {
// For client-side (Vite)
if (typeof window !== 'undefined') {
return import.meta.env.VITE_API_URL || 'http://localhost:5050';
}
// For server-side (Node.js)
return process.env.API_URL || 'http://localhost:5050';
};
// Build full API endpoint URL
export const buildApiUrl = (endpoint: string): string => {
const baseUrl = getApiUrl();
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${baseUrl}${cleanEndpoint}`;
};
// Build full site URL
export const buildSiteUrl = (path: string): string => {
const baseUrl = getSiteUrl();
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${baseUrl}${cleanPath}`;
};
// For hash-based routing (React Router with HashRouter)
export const buildHashUrl = (path: string): string => {
const baseUrl = getSiteUrl();
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${baseUrl}/#${cleanPath}`;
};
export default {
getSiteUrl,
getApiUrl,
buildApiUrl,
buildSiteUrl,
buildHashUrl
};

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite'
import autoprefixer from 'autoprefixer';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const API_URL = env.VITE_API_URL || 'http://localhost:5050';
return {
plugins: [tailwindcss(), react()],
server: {
proxy: {
'/api': {
target: API_URL,
changeOrigin: true,
}
}
}
}
});