diff --git a/src/components/campaigns/campaign-edit-slideout.tsx b/src/components/campaigns/campaign-edit-slideout.tsx new file mode 100644 index 0000000..9bd9f69 --- /dev/null +++ b/src/components/campaigns/campaign-edit-slideout.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons'; +import type { FC, HTMLAttributes } from 'react'; +import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu'; +import { Input } from '@/components/base/input/input'; +import { Select } from '@/components/base/select/select'; +import { Button } from '@/components/base/buttons/button'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import type { Campaign, CampaignStatus } from '@/types/entities'; + +const PenIcon: FC> = ({ className }) => ( + +); + +type CampaignEditSlideoutProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + campaign: Campaign; + onSaved?: () => void; +}; + +const statusItems = [ + { id: 'DRAFT' as const, label: 'Draft' }, + { id: 'ACTIVE' as const, label: 'Active' }, + { id: 'PAUSED' as const, label: 'Paused' }, + { id: 'COMPLETED' as const, label: 'Completed' }, +]; + +const formatDateForInput = (dateStr: string | null): string => { + if (!dateStr) return ''; + try { + return new Date(dateStr).toISOString().slice(0, 10); + } catch { + return ''; + } +}; + +const budgetToDisplay = (campaign: Campaign): string => { + if (!campaign.budget) return ''; + return String(Math.round(campaign.budget.amountMicros / 1_000_000)); +}; + +export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }: CampaignEditSlideoutProps) => { + const [campaignName, setCampaignName] = useState(campaign.campaignName ?? ''); + const [status, setStatus] = useState(campaign.campaignStatus); + const [budget, setBudget] = useState(budgetToDisplay(campaign)); + const [startDate, setStartDate] = useState(formatDateForInput(campaign.startDate)); + const [endDate, setEndDate] = useState(formatDateForInput(campaign.endDate)); + const [isSaving, setIsSaving] = useState(false); + + const handleSave = async (close: () => void) => { + setIsSaving(true); + try { + const budgetMicros = budget ? Number(budget) * 1_000_000 : null; + + await apiClient.graphql( + `mutation UpdateCampaign($id: ID!, $data: CampaignUpdateInput!) { + updateCampaign(id: $id, data: $data) { id } + }`, + { + id: campaign.id, + data: { + campaignName: campaignName || null, + campaignStatus: status, + ...(budgetMicros !== null + ? { + budget: { + amountMicros: budgetMicros, + currencyCode: campaign.budget?.currencyCode ?? 'INR', + }, + } + : {}), + startDate: startDate ? new Date(startDate).toISOString() : null, + endDate: endDate ? new Date(endDate).toISOString() : null, + }, + }, + ); + + notify.success('Campaign updated', `${campaignName || 'Campaign'} has been updated successfully.`); + onSaved?.(); + close(); + } catch (err) { + // apiClient.graphql already toasts on error + console.error('Failed to update campaign:', err); + } finally { + setIsSaving(false); + } + }; + + return ( + + {({ close }) => ( + <> + +
+
+ +
+
+

Edit Campaign

+

Update campaign details

+
+
+
+ + +
+ + + + + + +
+ + +
+
+
+ + +
+ + +
+
+ + )} +
+ ); +}; diff --git a/src/components/dashboard/agent-table.tsx b/src/components/dashboard/agent-table.tsx index 220f59f..6c4d4ca 100644 --- a/src/components/dashboard/agent-table.tsx +++ b/src/components/dashboard/agent-table.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { Link } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUserHeadset } from '@fortawesome/pro-duotone-svg-icons'; import { Avatar } from '@/components/base/avatar/avatar'; @@ -81,10 +82,12 @@ export const AgentTable = ({ calls }: AgentTableProps) => { {(agent) => ( -
- - {agent.name} -
+ +
+ + {agent.name} +
+
{agent.inbound} {agent.outbound} diff --git a/src/components/integrations/integration-edit-slideout.tsx b/src/components/integrations/integration-edit-slideout.tsx new file mode 100644 index 0000000..6262e6f --- /dev/null +++ b/src/components/integrations/integration-edit-slideout.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faGear, faCopy, faLink } from '@fortawesome/pro-duotone-svg-icons'; +import type { FC, HTMLAttributes } from 'react'; +import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu'; +import { Input } from '@/components/base/input/input'; +import { Button } from '@/components/base/buttons/button'; +import { notify } from '@/lib/toast'; + +const GearIcon: FC> = ({ className }) => ( + +); + +type IntegrationType = 'ozonetel' | 'whatsapp' | 'facebook' | 'google' | 'instagram' | 'website' | 'email'; + +type IntegrationConfig = { + type: IntegrationType; + name: string; + details: { label: string; value: string }[]; + webhookUrl?: string; +}; + +type IntegrationEditSlideoutProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + integration: IntegrationConfig; +}; + +// Field definitions per integration type +type FieldDef = { + key: string; + label: string; + placeholder: string; + type?: string; + readOnly?: boolean; + copyable?: boolean; +}; + +const getFieldsForType = (integration: IntegrationConfig): FieldDef[] => { + switch (integration.type) { + case 'ozonetel': + return [ + { key: 'account', label: 'Account ID', placeholder: 'e.g. global_healthx' }, + { key: 'apiKey', label: 'API Key', placeholder: 'Enter API key', type: 'password' }, + { key: 'agentId', label: 'Agent ID', placeholder: 'e.g. global' }, + { key: 'sipId', label: 'SIP ID / Extension', placeholder: 'e.g. 523590' }, + { key: 'campaign', label: 'Campaign Name', placeholder: 'e.g. Inbound_918041763265' }, + ]; + case 'whatsapp': + return [ + { key: 'apiKey', label: 'API Key', placeholder: 'Enter WhatsApp API key', type: 'password' }, + { key: 'phoneNumberId', label: 'Phone Number ID', placeholder: 'e.g. 123456789012345' }, + ]; + case 'facebook': + case 'google': + case 'instagram': + return []; + case 'website': + return [ + { + key: 'webhookUrl', + label: 'Webhook URL', + placeholder: '', + readOnly: true, + copyable: true, + }, + ]; + case 'email': + return [ + { key: 'smtpHost', label: 'SMTP Host', placeholder: 'e.g. smtp.gmail.com' }, + { key: 'smtpPort', label: 'Port', placeholder: 'e.g. 587' }, + { key: 'smtpUser', label: 'Username', placeholder: 'e.g. noreply@clinic.com' }, + { key: 'smtpPassword', label: 'Password', placeholder: 'Enter SMTP password', type: 'password' }, + ]; + default: + return []; + } +}; + +const getInitialValues = (integration: IntegrationConfig): Record => { + const values: Record = {}; + const detailMap = new Map(integration.details.map((d) => [d.label, d.value])); + + switch (integration.type) { + case 'ozonetel': + values.account = detailMap.get('Account') ?? ''; + values.apiKey = ''; + values.agentId = detailMap.get('Agent ID') ?? ''; + values.sipId = detailMap.get('SIP Extension') ?? ''; + values.campaign = detailMap.get('Inbound Campaign') ?? ''; + break; + case 'whatsapp': + values.apiKey = ''; + values.phoneNumberId = ''; + break; + case 'website': + values.webhookUrl = integration.webhookUrl ?? ''; + break; + case 'email': + values.smtpHost = ''; + values.smtpPort = '587'; + values.smtpUser = ''; + values.smtpPassword = ''; + break; + default: + break; + } + + return values; +}; + +const isOAuthType = (type: IntegrationType): boolean => + type === 'facebook' || type === 'google' || type === 'instagram'; + +export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: IntegrationEditSlideoutProps) => { + const fields = getFieldsForType(integration); + const [values, setValues] = useState>(() => getInitialValues(integration)); + + const updateValue = (key: string, value: string) => { + setValues((prev) => ({ ...prev, [key]: value })); + }; + + const handleCopy = (value: string) => { + navigator.clipboard.writeText(value); + notify.success('Copied', 'Value copied to clipboard'); + }; + + const handleSave = (close: () => void) => { + notify.success('Configuration saved', `${integration.name} configuration has been saved.`); + close(); + }; + + const handleOAuthConnect = () => { + notify.info('OAuth Connect', `${integration.name} OAuth flow is not yet implemented. This is a placeholder.`); + }; + + return ( + + {({ close }) => ( + <> + +
+
+ +
+
+

Configure {integration.name}

+

Edit integration settings

+
+
+
+ + +
+ {isOAuthType(integration.type) ? ( +
+
+ +
+
+

Connect {integration.name}

+

+ Authorize Helix Engage to access your {integration.name} account to import leads automatically. +

+
+ +
+ ) : ( + fields.map((field) => ( +
+ {field.readOnly && field.copyable ? ( +
+ +
+ + {values[field.key] || '\u2014'} + + +
+
+ ) : ( + updateValue(field.key, value)} + isDisabled={field.readOnly} + /> + )} +
+ )) + )} +
+
+ + +
+ + {!isOAuthType(integration.type) && ( + + )} +
+
+ + )} +
+ ); +}; diff --git a/src/main.tsx b/src/main.tsx index 6be276c..0d202cb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -18,6 +18,7 @@ import { ReportsPage } from "@/pages/reports"; import { PatientsPage } from "@/pages/patients"; import { TeamDashboardPage } from "@/pages/team-dashboard"; import { IntegrationsPage } from "@/pages/integrations"; +import { AgentDetailPage } from "@/pages/agent-detail"; import { SettingsPage } from "@/pages/settings"; import { AuthProvider } from "@/providers/auth-provider"; import { DataProvider } from "@/providers/data-provider"; @@ -56,6 +57,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> } /> diff --git a/src/pages/agent-detail.tsx b/src/pages/agent-detail.tsx new file mode 100644 index 0000000..ce3c909 --- /dev/null +++ b/src/pages/agent-detail.tsx @@ -0,0 +1,271 @@ +import { useMemo } from 'react'; +import { Link, useParams } from 'react-router'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faArrowLeft, + faPhone, + faPhoneArrowDownLeft, + faPhoneArrowUpRight, + faPhoneMissed, + faClock, + faPercent, + faPhoneArrowDown, + faPhoneArrowUp, + faPhoneXmark, + faUserHeadset, +} from '@fortawesome/pro-duotone-svg-icons'; +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { Avatar } from '@/components/base/avatar/avatar'; +import { Badge } from '@/components/base/badges/badges'; +import { Button } from '@/components/base/buttons/button'; +import { Table, TableCard } from '@/components/application/table/table'; +import { TopBar } from '@/components/layout/top-bar'; +import { formatShortDate, formatPhone, getInitials } from '@/lib/format'; +import { useData } from '@/providers/data-provider'; +import type { Call, CallDirection, CallDisposition } from '@/types/entities'; + +type KpiCardProps = { + label: string; + value: number | string; + icon: IconDefinition; + iconColor: string; + iconBg: string; +}; + +const KpiCard = ({ label, value, icon, iconColor, iconBg }: KpiCardProps) => ( +
+
+ +
+
+ {label} + {value} +
+
+); + +const formatDuration = (seconds: number): string => { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +}; + +const formatPercent = (value: number): string => { + if (isNaN(value) || !isFinite(value)) return '0%'; + return `${Math.round(value)}%`; +}; + +const formatPhoneDisplay = (call: Call): string => { + if (call.callerNumber && call.callerNumber.length > 0) { + return formatPhone(call.callerNumber[0]); + } + return '\u2014'; +}; + +const dispositionConfig: Record = { + APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' }, + FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' }, + INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' }, + NO_ANSWER: { label: 'No Answer', color: 'warning' }, + WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' }, + CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' }, +}; + +const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => { + if (status === 'MISSED') { + return ; + } + if (direction === 'OUTBOUND') { + return ; + } + return ; +}; + +export const AgentDetailPage = () => { + const { id } = useParams<{ id: string }>(); + const { calls, leads, loading } = useData(); + + const agentName = id ? decodeURIComponent(id) : ''; + + const agentCalls = useMemo( + () => + calls + .filter((c) => c.agentName === agentName) + .sort((a, b) => { + const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; + const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0; + return dateB - dateA; + }), + [calls, agentName], + ); + + // Build lead name map for enrichment + const leadNameMap = useMemo(() => { + const map = new Map(); + for (const lead of leads) { + if (lead.id && lead.contactName) { + const name = `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim(); + if (name) map.set(lead.id, name); + } + } + return map; + }, [leads]); + + // KPI calculations + const totalCalls = agentCalls.length; + const inboundCalls = agentCalls.filter((c) => c.callDirection === 'INBOUND').length; + const outboundCalls = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length; + const missedCalls = agentCalls.filter((c) => c.callStatus === 'MISSED').length; + + const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0); + const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0); + const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0; + + const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length; + const conversion = totalCalls > 0 ? (booked / totalCalls) * 100 : 0; + + const nameParts = agentName.split(' '); + const initials = getInitials(nameParts[0] ?? '', nameParts[1] ?? ''); + + if (loading) { + return ( +
+ +
+

Loading...

+
+
+ ); + } + + if (totalCalls === 0 && !loading) { + return ( +
+ +
+ +

No data found for "{agentName}"

+

This agent has no call records.

+ + + +
+
+ ); + } + + return ( +
+ + +
+ {/* Agent header + back button */} +
+
+ + + +
+ +
+

{agentName}

+

Agent

+
+
+
+ + {/* KPI row */} +
+ + + + + + +
+ + {/* Call log table */} + + + + {agentCalls.length === 0 ? ( +
+

No calls found.

+
+ ) : ( + + + + + + + + + + + {(call) => { + const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown'; + const phoneDisplay = formatPhoneDisplay(call); + const durationStr = call.durationSeconds !== null && call.durationSeconds > 0 + ? formatDuration(call.durationSeconds) + : '\u2014'; + const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null; + + return ( + + + + + + + {patientName} + + + + + {phoneDisplay} + + + + + {durationStr} + + + + {dispositionCfg ? ( + + {dispositionCfg.label} + + ) : ( + {'\u2014'} + )} + + + + {call.startedAt ? formatShortDate(call.startedAt) : '\u2014'} + + + + ); + }} + +
+ )} +
+
+
+ ); +}; diff --git a/src/pages/campaigns.tsx b/src/pages/campaigns.tsx index 7a5fc23..a585a97 100644 --- a/src/pages/campaigns.tsx +++ b/src/pages/campaigns.tsx @@ -1,13 +1,18 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons'; import { TopBar } from '@/components/layout/top-bar'; import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs'; import { CampaignCard } from '@/components/campaigns/campaign-card'; +import { CampaignEditSlideout } from '@/components/campaigns/campaign-edit-slideout'; +import { Button } from '@/components/base/buttons/button'; import { useCampaigns } from '@/hooks/use-campaigns'; import { useLeads } from '@/hooks/use-leads'; +import { useData } from '@/providers/data-provider'; import { formatCurrency } from '@/lib/format'; -import type { CampaignStatus } from '@/types/entities'; +import type { Campaign, CampaignStatus } from '@/types/entities'; type TabConfig = { id: string; @@ -25,7 +30,9 @@ const tabs: TabConfig[] = [ export const CampaignsPage = () => { const [activeTab, setActiveTab] = useState('all'); + const [editCampaign, setEditCampaign] = useState(null); + const { refresh } = useData(); const selectedTab = tabs.find((t) => t.id === activeTab) ?? tabs[0]; const { campaigns, ads } = useCampaigns({ status: selectedTab.status }); const { campaigns: allCampaigns } = useCampaigns(); @@ -100,13 +107,30 @@ export const CampaignsPage = () => {
{campaigns.map((campaign) => ( - - - +
+ + + +
+
+
))} {campaigns.length === 0 && (

@@ -118,6 +142,15 @@ export const CampaignsPage = () => { ))}

+ + {editCampaign && ( + { if (!open) setEditCampaign(null); }} + campaign={editCampaign} + onSaved={refresh} + /> + )}
); }; diff --git a/src/pages/integrations.tsx b/src/pages/integrations.tsx index d89398a..895acd9 100644 --- a/src/pages/integrations.tsx +++ b/src/pages/integrations.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPhone, @@ -10,12 +11,23 @@ import { faCopy, faCircleCheck, faCircleXmark, + faGear, } from '@fortawesome/pro-duotone-svg-icons'; import { Badge } from '@/components/base/badges/badges'; import { Button } from '@/components/base/buttons/button'; import { TopBar } from '@/components/layout/top-bar'; +import { IntegrationEditSlideout } from '@/components/integrations/integration-edit-slideout'; import { notify } from '@/lib/toast'; +type IntegrationType = 'ozonetel' | 'whatsapp' | 'facebook' | 'google' | 'instagram' | 'website' | 'email'; + +type IntegrationConfig = { + type: IntegrationType; + name: string; + details: { label: string; value: string }[]; + webhookUrl?: string; +}; + type IntegrationStatus = 'connected' | 'disconnected' | 'configured'; type IntegrationCardProps = { @@ -26,6 +38,7 @@ type IntegrationCardProps = { status: IntegrationStatus; details: { label: string; value: string }[]; webhookUrl?: string; + onConfigure?: () => void; }; const statusConfig: Record = { @@ -34,7 +47,7 @@ const statusConfig: Record { +const IntegrationCard = ({ name, description, icon, iconColor, status, details, webhookUrl, onConfigure }: IntegrationCardProps) => { const statusCfg = statusConfig[status]; const copyWebhook = () => { @@ -56,10 +69,24 @@ const IntegrationCard = ({ name, description, icon, iconColor, status, details,

{description}

- - - {statusCfg.label} - +
+ {onConfigure && ( + + )} + + + {statusCfg.label} + +
{details.length > 0 && ( @@ -89,6 +116,9 @@ const IntegrationCard = ({ name, description, icon, iconColor, status, details, export const IntegrationsPage = () => { const webhookBase = 'https://engage-api.srv1477139.hstgr.cloud'; + const [editIntegration, setEditIntegration] = useState(null); + + const openEdit = (config: IntegrationConfig) => setEditIntegration(config); return (
@@ -110,6 +140,16 @@ export const IntegrationsPage = () => { { label: 'DID Number', value: '+91 804 176 3265' }, ]} webhookUrl={`${webhookBase}/webhooks/ozonetel/missed-call`} + onConfigure={() => openEdit({ + type: 'ozonetel', + name: 'Ozonetel CloudAgent', + details: [ + { label: 'Account', value: 'global_healthx' }, + { label: 'Agent ID', value: 'global' }, + { label: 'SIP Extension', value: '523590' }, + { label: 'Inbound Campaign', value: 'Inbound_918041763265' }, + ], + })} /> {/* WhatsApp */} @@ -120,6 +160,7 @@ export const IntegrationsPage = () => { iconColor="text-green-600" status="disconnected" details={[]} + onConfigure={() => openEdit({ type: 'whatsapp', name: 'WhatsApp Business', details: [] })} /> {/* Facebook Lead Ads */} @@ -130,6 +171,7 @@ export const IntegrationsPage = () => { iconColor="text-blue-600" status="disconnected" details={[]} + onConfigure={() => openEdit({ type: 'facebook', name: 'Facebook Lead Ads', details: [] })} /> {/* Google Ads */} @@ -140,6 +182,7 @@ export const IntegrationsPage = () => { iconColor="text-red-500" status="disconnected" details={[]} + onConfigure={() => openEdit({ type: 'google', name: 'Google Ads', details: [] })} /> {/* Instagram */} @@ -150,6 +193,7 @@ export const IntegrationsPage = () => { iconColor="text-pink-600" status="disconnected" details={[]} + onConfigure={() => openEdit({ type: 'instagram', name: 'Instagram Lead Ads', details: [] })} /> {/* Website */} @@ -163,6 +207,12 @@ export const IntegrationsPage = () => { { label: 'Method', value: 'POST webhook' }, ]} webhookUrl={`${webhookBase}/webhooks/website/lead`} + onConfigure={() => openEdit({ + type: 'website', + name: 'Website Lead Forms', + details: [{ label: 'Method', value: 'POST webhook' }], + webhookUrl: `${webhookBase}/webhooks/website/lead`, + })} /> {/* Email */} @@ -173,8 +223,17 @@ export const IntegrationsPage = () => { iconColor="text-fg-quaternary" status="disconnected" details={[]} + onConfigure={() => openEdit({ type: 'email', name: 'Email (SMTP)', details: [] })} />
+ + {editIntegration && ( + { if (!open) setEditIntegration(null); }} + integration={editIntegration} + /> + )} ); }; diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx index c878af1..62ba492 100644 --- a/src/providers/auth-provider.tsx +++ b/src/providers/auth-provider.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { createContext, useCallback, useContext, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; export type Role = 'executive' | 'admin' | 'cc-agent'; @@ -18,6 +18,7 @@ type AuthContextType = { isAdmin: boolean; isCCAgent: boolean; isAuthenticated: boolean; + loading: boolean; loginWithUser: (userData: User) => void; login: () => void; logout: () => void; @@ -34,6 +35,21 @@ const DEFAULT_USER: User = { const getInitials = (firstName: string, lastName: string): string => `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase(); +const STORAGE_KEY = 'helix_user'; + +const loadPersistedUser = (): User | null => { + try { + const token = localStorage.getItem('helix_access_token'); + const stored = localStorage.getItem(STORAGE_KEY); + if (token && stored) { + return JSON.parse(stored) as User; + } + } catch { + // corrupt data + } + return null; +}; + const AuthContext = createContext(undefined); export const useAuth = (): AuthContextType => { @@ -49,19 +65,32 @@ interface AuthProviderProps { } export const AuthProvider = ({ children }: AuthProviderProps) => { - const [user, setUser] = useState(DEFAULT_USER); - const [isAuthenticated, setIsAuthenticated] = useState(false); + const persisted = loadPersistedUser(); + const [user, setUser] = useState(persisted ?? DEFAULT_USER); + const [isAuthenticated, setIsAuthenticated] = useState(!!persisted); + const [loading, setLoading] = useState(!persisted && !!localStorage.getItem('helix_access_token')); + + // If we have a token but no persisted user, try to restore session + useEffect(() => { + if (!isAuthenticated && localStorage.getItem('helix_access_token') && !persisted) { + // Token exists but no user data — could re-fetch profile here + // For now, just clear stale token + localStorage.removeItem('helix_access_token'); + localStorage.removeItem('helix_refresh_token'); + localStorage.removeItem(STORAGE_KEY); + setLoading(false); + } + }, [isAuthenticated, persisted]); const isAdmin = user.role === 'admin'; const isCCAgent = user.role === 'cc-agent'; - // Real login — receives user profile from sidecar auth response const loginWithUser = useCallback((userData: User) => { setUser(userData); setIsAuthenticated(true); + localStorage.setItem(STORAGE_KEY, JSON.stringify(userData)); }, []); - // Simple login (for backward compat) const login = useCallback(() => { setIsAuthenticated(true); }, []); @@ -71,14 +100,19 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { setIsAuthenticated(false); localStorage.removeItem('helix_access_token'); localStorage.removeItem('helix_refresh_token'); + localStorage.removeItem(STORAGE_KEY); }, []); const setRole = useCallback((role: Role) => { - setUser(prev => ({ ...prev, role })); + setUser(prev => { + const updated = { ...prev, role }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); }, []); return ( - + {children} );