feat: wire frontend to platform data, migrate to Jotai + Vercel AI SDK

- Replace mock DataProvider with real GraphQL queries through sidecar
- Add queries.ts and transforms.ts for platform field name mapping
- Migrate SIP state from React Context to Jotai atoms (React 19 compat)
- Add singleton SIP manager to survive StrictMode remounts
- Remove hardcoded Olivia/Sienna accounts from nav menu
- Add password eye toggle, remember me checkbox, forgot password link
- Fix worklist hook to transform platform field names
- Add seed scripts for clinics, health packages, lab tests
- Update test harness for new doctor→clinic relation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 16:44:45 +05:30
parent e01a4d7747
commit 61901eb8fb
14 changed files with 1208 additions and 108 deletions

View File

@@ -1,13 +1,11 @@
import type { FC, HTMLAttributes } from "react";
import { useCallback, useEffect, useRef } from "react";
import type { Placement } from "@react-types/overlays";
import { BookOpen01, ChevronSelectorVertical, LogOut01, Plus, Settings01, User01 } from "@untitledui/icons";
import { ChevronSelectorVertical, LogOut01, Settings01, User01 } from "@untitledui/icons";
import { useFocusManager } from "react-aria";
import type { DialogProps as AriaDialogProps } from "react-aria-components";
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group";
import { Button } from "@/components/base/buttons/button";
import { RadioButtonBase } from "@/components/base/radio-buttons/radio-buttons";
import { useBreakpoint } from "@/hooks/use-breakpoint";
import { cx } from "@/utils/cx";
@@ -24,26 +22,9 @@ type NavAccountType = {
status: "online" | "offline";
};
const placeholderAccounts: NavAccountType[] = [
{
id: "olivia",
name: "Olivia Rhye",
email: "olivia@untitledui.com",
avatar: "https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80",
status: "online",
},
{
id: "sienna",
name: "Sienna Hewitt",
email: "sienna@untitledui.com",
avatar: "https://www.untitledui.com/images/avatars/transparent/sienna-hewitt?bg=%23E0E0E0",
status: "online",
},
];
export const NavAccountMenu = ({
className,
selectedAccountId = "olivia",
onSignOut,
...dialogProps
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void }) => {
@@ -87,31 +68,6 @@ export const NavAccountMenu = ({
<div className="flex flex-col gap-0.5 py-1.5">
<NavAccountCardMenuItem label="View profile" icon={User01} shortcut="⌘K->P" />
<NavAccountCardMenuItem label="Account settings" icon={Settings01} shortcut="⌘S" />
<NavAccountCardMenuItem label="Documentation" icon={BookOpen01} />
</div>
<div className="flex flex-col gap-0.5 border-t border-secondary py-1.5">
<div className="px-3 pt-1.5 pb-1 text-xs font-semibold text-tertiary">Switch account</div>
<div className="flex flex-col gap-0.5 px-1.5">
{placeholderAccounts.map((account) => (
<button
key={account.id}
className={cx(
"relative w-full cursor-pointer rounded-md px-2 py-1.5 text-left outline-focus-ring hover:bg-primary_hover focus:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
account.id === selectedAccountId && "bg-primary_hover",
)}
>
<AvatarLabelGroup status="online" size="md" src={account.avatar} title={account.name} subtitle={account.email} />
<RadioButtonBase isSelected={account.id === selectedAccountId} className="absolute top-2 right-2" />
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2 px-2 pt-0.5 pb-2">
<Button iconLeading={Plus} color="secondary" size="sm">
Add account
</Button>
</div>
</div>
@@ -155,8 +111,8 @@ const NavAccountCardMenuItem = ({
export const NavAccountCard = ({
popoverPlacement,
selectedAccountId = "olivia",
items = placeholderAccounts,
selectedAccountId,
items = [],
onSignOut,
}: {
popoverPlacement?: Placement;

View File

@@ -90,7 +90,32 @@ export const useWorklist = (): UseWorklistResult => {
if (response.ok) {
const json = await response.json();
setData(json);
// Transform platform field shapes to frontend types
const transformed: WorklistData = {
...json,
marketingLeads: (json.marketingLeads ?? []).map((lead: any) => ({
...lead,
leadSource: lead.source ?? lead.leadSource,
leadStatus: lead.status ?? lead.leadStatus,
contactPhone: lead.contactPhone?.primaryPhoneNumber
? [{ number: lead.contactPhone.primaryPhoneNumber, callingCode: lead.contactPhone.primaryPhoneCallingCode ?? '+91' }]
: lead.contactPhone,
contactEmail: lead.contactEmail?.primaryEmail
? [{ address: lead.contactEmail.primaryEmail }]
: lead.contactEmail,
})),
missedCalls: (json.missedCalls ?? []).map((call: any) => ({
...call,
callDirection: call.direction ?? call.callDirection,
durationSeconds: call.durationSec ?? call.durationSeconds ?? 0,
})),
followUps: (json.followUps ?? []).map((fu: any) => ({
...fu,
followUpType: fu.typeCustom ?? fu.followUpType,
followUpStatus: fu.status ?? fu.followUpStatus,
})),
};
setData(transformed);
setError(null);
} else {
setError(`Worklist API returned ${response.status}`);

74
src/lib/queries.ts Normal file
View File

@@ -0,0 +1,74 @@
// GraphQL queries for platform entities
// Platform remaps some SDK field names — queries use platform names
export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt
contactName { firstName lastName }
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
contactEmail { primaryEmail }
source status priority interestedService assignedAgent
utmSource utmMedium utmCampaign utmContent utmTerm landingPage referrerUrl
leadScore spamScore isSpam isDuplicate duplicateOfLeadId
firstContacted lastContacted contactAttempts convertedAt
patientId campaignId
aiSummary aiSuggestedAction
} } } }`;
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt
campaignName typeCustom status platform
startDate endDate
budget { amountMicros currencyCode }
amountSpent { amountMicros currencyCode }
impressions clicks targetCount contacted converted leadsGenerated
externalCampaignId platformUrl
} } } }`;
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt
adName externalAdId adStatus adFormat
headline adDescription destinationUrl previewUrl
impressions clicks conversions
spend { amountMicros currencyCode }
campaignId
} } } }`;
export const FOLLOW_UPS_QUERY = `{ followUps(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt
typeCustom status scheduledAt completedAt
priority assignedAgent
patientId callId
} } } }`;
export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id name createdAt
activityType summary occurredAt performedBy
previousValue newValue
channel durationSeconds outcome
leadId
} } } }`;
export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id name createdAt
direction callStatus callerNumber agentName
startedAt endedAt durationSec
recordingUrl disposition
patientId appointmentId leadId
} } } }`;
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
id name fullName { firstName lastName }
department specialty qualifications yearsOfExperience
visitingHours
consultationFeeNew { amountMicros currencyCode }
consultationFeeFollowUp { amountMicros currencyCode }
active registrationNumber
clinic { id name clinicName }
} } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } } }`;

152
src/lib/transforms.ts Normal file
View File

@@ -0,0 +1,152 @@
// Transform platform GraphQL responses → frontend entity types
// Platform remaps some field names during sync
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call } from '@/types/entities';
type PlatformNode = Record<string, any>;
function extractEdges(data: any, entityName: string): PlatformNode[] {
return data?.[entityName]?.edges?.map((e: any) => e.node) ?? [];
}
export function transformLeads(data: any): Lead[] {
return extractEdges(data, 'leads').map((n) => ({
id: n.id,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
contactName: n.contactName ?? { firstName: '', lastName: '' },
contactPhone: n.contactPhone?.primaryPhoneNumber
? [{ number: n.contactPhone.primaryPhoneNumber, callingCode: n.contactPhone.primaryPhoneCallingCode ?? '+91' }]
: [],
contactEmail: n.contactEmail?.primaryEmail
? [{ address: n.contactEmail.primaryEmail }]
: [],
leadSource: n.source,
leadStatus: n.status,
priority: n.priority ?? 'NORMAL',
interestedService: n.interestedService,
assignedAgent: n.assignedAgent,
utmSource: n.utmSource,
utmMedium: n.utmMedium,
utmCampaign: n.utmCampaign,
utmContent: n.utmContent,
utmTerm: n.utmTerm,
landingPageUrl: n.landingPage,
referrerUrl: n.referrerUrl,
leadScore: n.leadScore,
spamScore: n.spamScore ?? 0,
isSpam: n.isSpam ?? false,
isDuplicate: n.isDuplicate ?? false,
duplicateOfLeadId: n.duplicateOfLeadId,
firstContactedAt: n.firstContacted,
lastContactedAt: n.lastContacted,
contactAttempts: n.contactAttempts ?? 0,
convertedAt: n.convertedAt,
patientId: n.patientId,
campaignId: n.campaignId,
adId: null,
aiSummary: n.aiSummary,
aiSuggestedAction: n.aiSuggestedAction,
}));
}
export function transformCampaigns(data: any): Campaign[] {
return extractEdges(data, 'campaigns').map((n) => ({
id: n.id,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
campaignName: n.campaignName ?? n.name,
campaignType: n.typeCustom,
campaignStatus: n.status,
platform: n.platform,
startDate: n.startDate,
endDate: n.endDate,
budget: n.budget ? { amountMicros: n.budget.amountMicros, currencyCode: n.budget.currencyCode } : null,
amountSpent: n.amountSpent ? { amountMicros: n.amountSpent.amountMicros, currencyCode: n.amountSpent.currencyCode } : null,
impressionCount: n.impressions ?? 0,
clickCount: n.clicks ?? 0,
targetCount: n.targetCount ?? 0,
contactedCount: n.contacted ?? 0,
convertedCount: n.converted ?? 0,
leadCount: n.leadsGenerated ?? 0,
externalCampaignId: n.externalCampaignId,
platformUrl: n.platformUrl,
}));
}
export function transformAds(data: any): Ad[] {
return extractEdges(data, 'ads').map((n) => ({
id: n.id,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
adName: n.adName ?? n.name,
externalAdId: n.externalAdId,
adStatus: n.adStatus,
adFormat: n.adFormat,
headline: n.headline,
adDescription: n.adDescription,
destinationUrl: n.destinationUrl,
previewUrl: n.previewUrl,
impressions: n.impressions ?? 0,
clicks: n.clicks ?? 0,
conversions: n.conversions ?? 0,
spend: n.spend ? { amountMicros: n.spend.amountMicros, currencyCode: n.spend.currencyCode } : null,
campaignId: n.campaignId,
}));
}
export function transformFollowUps(data: any): FollowUp[] {
return extractEdges(data, 'followUps').map((n) => ({
id: n.id,
createdAt: n.createdAt,
followUpType: n.typeCustom,
followUpStatus: n.status,
scheduledAt: n.scheduledAt,
completedAt: n.completedAt,
priority: n.priority ?? 'NORMAL',
assignedAgent: n.assignedAgent,
patientId: n.patientId,
callId: n.callId,
patientName: undefined,
patientPhone: undefined,
description: n.name,
}));
}
export function transformLeadActivities(data: any): LeadActivity[] {
return extractEdges(data, 'leadActivities').map((n) => ({
id: n.id,
createdAt: n.createdAt,
activityType: n.activityType,
summary: n.summary,
occurredAt: n.occurredAt,
performedBy: n.performedBy,
previousValue: n.previousValue,
newValue: n.newValue,
channel: n.channel,
durationSeconds: n.durationSeconds,
outcome: n.outcome,
activityNotes: null,
leadId: n.leadId,
}));
}
export function transformCalls(data: any): Call[] {
return extractEdges(data, 'calls').map((n) => ({
id: n.id,
createdAt: n.createdAt,
callDirection: n.direction,
callStatus: n.callStatus,
callerNumber: n.callerNumber ? [{ number: n.callerNumber, callingCode: '+91' }] : [],
agentName: n.agentName,
startedAt: n.startedAt,
endedAt: n.endedAt,
durationSeconds: n.durationSec ?? 0,
recordingUrl: n.recordingUrl,
disposition: n.disposition,
callNotes: undefined,
patientId: n.patientId,
appointmentId: n.appointmentId,
leadId: n.leadId,
}));
}

View File

@@ -1,8 +1,11 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons';
import { useAuth } from '@/providers/auth-provider';
import { Button } from '@/components/base/buttons/button';
import { SocialButton } from '@/components/base/buttons/social-button';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Input } from '@/components/base/input/input';
const features = [
@@ -25,8 +28,13 @@ export const LoginPage = () => {
const { loginWithUser } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const saved = localStorage.getItem('helix_remember');
const savedCreds = saved ? JSON.parse(saved) : null;
const [email, setEmail] = useState(savedCreds?.email ?? '');
const [password, setPassword] = useState(savedCreds?.password ?? '');
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(!!savedCreds);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -51,6 +59,12 @@ export const LoginPage = () => {
const name = `${firstName} ${lastName}`.trim() || email;
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase() || email[0].toUpperCase();
if (rememberMe) {
localStorage.setItem('helix_remember', JSON.stringify({ email, password }));
} else {
localStorage.removeItem('helix_remember');
}
loginWithUser({
id: u?.id,
name,
@@ -181,16 +195,41 @@ export const LoginPage = () => {
/>
</div>
{/* Password input */}
<div className="mt-4">
{/* Password input with eye toggle */}
<div className="mt-4 relative">
<Input
label="Password"
type="password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
value={password}
onChange={(value) => setPassword(value)}
size="md"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-[38px] text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
</button>
</div>
{/* Remember me + Forgot password */}
<div className="mt-3 flex items-center justify-between">
<Checkbox
label="Remember me"
size="sm"
isSelected={rememberMe}
onChange={setRememberMe}
/>
<button
type="button"
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
>
Forgot password?
</button>
</div>
{/* Error message */}

View File

@@ -1,8 +1,24 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
import {
LEADS_QUERY,
CAMPAIGNS_QUERY,
ADS_QUERY,
FOLLOW_UPS_QUERY,
LEAD_ACTIVITIES_QUERY,
CALLS_QUERY,
} from '@/lib/queries';
import {
transformLeads,
transformCampaigns,
transformAds,
transformFollowUps,
transformLeadActivities,
transformCalls,
} from '@/lib/transforms';
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource } from '@/types/entities';
import { mockLeads, mockCampaigns, mockAds, mockFollowUps, mockLeadActivities, mockTemplates, mockAgents, mockCalls, mockIngestionSources } from '@/mocks';
type DataContextType = {
leads: Lead[];
@@ -14,8 +30,11 @@ type DataContextType = {
agents: Agent[];
calls: Call[];
ingestionSources: LeadIngestionSource[];
loading: boolean;
error: string | null;
updateLead: (id: string, updates: Partial<Lead>) => void;
addCall: (call: Call) => void;
refresh: () => void;
};
const DataContext = createContext<DataContextType | undefined>(undefined);
@@ -35,15 +54,56 @@ interface DataProviderProps {
}
export const DataProvider = ({ children }: DataProviderProps) => {
const [leads, setLeads] = useState<Lead[]>(mockLeads);
const [campaigns] = useState<Campaign[]>(mockCampaigns);
const [ads] = useState<Ad[]>(mockAds);
const [followUps] = useState<FollowUp[]>(mockFollowUps);
const [leadActivities] = useState<LeadActivity[]>(mockLeadActivities);
const [templates] = useState<WhatsAppTemplate[]>(mockTemplates);
const [agents] = useState<Agent[]>(mockAgents);
const [calls, setCalls] = useState<Call[]>(mockCalls);
const [ingestionSources] = useState<LeadIngestionSource[]>(mockIngestionSources);
const [leads, setLeads] = useState<Lead[]>([]);
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [ads, setAds] = useState<Ad[]>([]);
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
const [calls, setCalls] = useState<Call[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// These don't have platform entities yet — empty for now
const [templates] = useState<WhatsAppTemplate[]>([]);
const [agents] = useState<Agent[]>([]);
const [ingestionSources] = useState<LeadIngestionSource[]>([]);
const fetchData = useCallback(async () => {
if (!apiClient.isAuthenticated()) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData] = await Promise.all([
apiClient.graphql<any>(LEADS_QUERY),
apiClient.graphql<any>(CAMPAIGNS_QUERY),
apiClient.graphql<any>(ADS_QUERY),
apiClient.graphql<any>(FOLLOW_UPS_QUERY),
apiClient.graphql<any>(LEAD_ACTIVITIES_QUERY),
apiClient.graphql<any>(CALLS_QUERY),
]);
setLeads(transformLeads(leadsData));
setCampaigns(transformCampaigns(campaignsData));
setAds(transformAds(adsData));
setFollowUps(transformFollowUps(followUpsData));
setLeadActivities(transformLeadActivities(activitiesData));
setCalls(transformCalls(callsData));
} catch (err: any) {
console.error('Failed to fetch platform data:', err);
setError(err.message ?? 'Failed to load data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const updateLead = (id: string, updates: Partial<Lead>) => {
setLeads((prev) => prev.map((lead) => (lead.id === id ? { ...lead, ...updates } : lead)));
@@ -54,7 +114,11 @@ export const DataProvider = ({ children }: DataProviderProps) => {
};
return (
<DataContext.Provider value={{ leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, ingestionSources, updateLead, addCall }}>
<DataContext.Provider value={{
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, ingestionSources,
loading, error,
updateLead, addCall, refresh: fetchData,
}}>
{children}
</DataContext.Provider>
);

View File

@@ -1,43 +1,135 @@
import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react';
import { useSipPhone } from '@/hooks/use-sip-phone';
import { useEffect, useCallback, type PropsWithChildren } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import {
sipConnectionStatusAtom,
sipCallStateAtom,
sipCallerNumberAtom,
sipIsMutedAtom,
sipIsOnHoldAtom,
sipCallDurationAtom,
sipCallStartTimeAtom,
} from '@/state/sip-state';
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient } from '@/state/sip-manager';
import type { SIPConfig } from '@/types/sip';
type SipContextType = ReturnType<typeof useSipPhone> & {
ozonetelStatus: 'idle' | 'logging-in' | 'logged-in' | 'error';
ozonetelError: string | null;
const DEFAULT_CONFIG: SIPConfig = {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent',
uri: import.meta.env.VITE_SIP_URI ?? '',
password: import.meta.env.VITE_SIP_PASSWORD ?? '',
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '',
stunServers: 'stun:stun.l.google.com:19302',
};
const SipContext = createContext<SipContextType | null>(null);
// Module-level flag — survives React StrictMode double-mount
let sipConnectedGlobal = false;
export const SipProvider = ({ children }: PropsWithChildren) => {
const sipPhone = useSipPhone();
const [ozonetelStatus, setOzonetelStatus] = useState<'idle' | 'logging-in' | 'logged-in' | 'error'>('idle');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [ozonetelError, _setOzonetelError] = useState<string | null>(null);
const [, setConnectionStatus] = useAtom(sipConnectionStatusAtom);
const [callState, setCallState] = useAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallDuration = useSetAtom(sipCallDurationAtom);
const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
// Auto-connect SIP on mount — module-level guard prevents duplicate connections
// Register Jotai setters so the singleton SIP manager can update atoms
useEffect(() => {
if (!sipConnectedGlobal) {
sipConnectedGlobal = true;
sipPhone.connect();
setOzonetelStatus('logged-in');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
registerSipStateUpdater({
setConnectionStatus,
setCallState,
setCallerNumber,
});
}, [setConnectionStatus, setCallState, setCallerNumber]);
// Auto-connect SIP on mount
useEffect(() => {
connectSip(DEFAULT_CONFIG);
}, []);
return (
<SipContext.Provider value={{ ...sipPhone, ozonetelStatus, ozonetelError }}>
{children}
</SipContext.Provider>
);
// Call duration timer
useEffect(() => {
if (callState === 'active') {
const start = new Date();
setCallStartTime(start);
const interval = window.setInterval(() => {
setCallDuration(Math.floor((Date.now() - start.getTime()) / 1000));
}, 1000);
return () => clearInterval(interval);
}
setCallDuration(0);
setCallStartTime(null);
}, [callState, setCallDuration, setCallStartTime]);
// Auto-reset to idle after ended/failed
useEffect(() => {
if (callState === 'ended' || callState === 'failed') {
const timer = setTimeout(() => {
setCallState('idle');
setCallerNumber(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [callState, setCallState, setCallerNumber]);
// Cleanup on page unload
useEffect(() => {
const handleUnload = () => disconnectSip();
window.addEventListener('beforeunload', handleUnload);
return () => window.removeEventListener('beforeunload', handleUnload);
}, []);
return <>{children}</>;
};
export const useSip = (): SipContextType => {
const ctx = useContext(SipContext);
if (ctx === null) {
throw new Error('useSip must be used within a SipProvider');
}
return ctx;
// Hook for components to access SIP actions + state
export const useSip = () => {
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
const [callState] = useAtom(sipCallStateAtom);
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
const [callDuration] = useAtom(sipCallDurationAtom);
const makeCall = useCallback((phoneNumber: string) => {
getSipClient()?.call(phoneNumber);
setCallerNumber(phoneNumber);
}, [setCallerNumber]);
const answer = useCallback(() => getSipClient()?.answer(), []);
const reject = useCallback(() => getSipClient()?.reject(), []);
const hangup = useCallback(() => getSipClient()?.hangup(), []);
const toggleMute = useCallback(() => {
if (isMuted) {
getSipClient()?.unmute();
} else {
getSipClient()?.mute();
}
setIsMuted(!isMuted);
}, [isMuted, setIsMuted]);
const toggleHold = useCallback(() => {
if (isOnHold) {
getSipClient()?.unhold();
} else {
getSipClient()?.hold();
}
setIsOnHold(!isOnHold);
}, [isOnHold, setIsOnHold]);
return {
connectionStatus,
callState,
callerNumber,
isMuted,
isOnHold,
callDuration,
isRegistered: connectionStatus === 'registered',
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
ozonetelStatus: 'logged-in' as const,
ozonetelError: null as string | null,
connect: () => connectSip(DEFAULT_CONFIG),
disconnect: disconnectSip,
makeCall,
answer,
reject,
hangup,
toggleMute,
toggleHold,
};
};

58
src/state/sip-manager.ts Normal file
View File

@@ -0,0 +1,58 @@
import { SIPClient } from '@/lib/sip-client';
import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip';
// Singleton SIP client — survives React StrictMode remounts
let sipClient: SIPClient | null = null;
let connected = false;
type StateUpdater = {
setConnectionStatus: (status: ConnectionStatus) => void;
setCallState: (state: CallState) => void;
setCallerNumber: (number: string | null) => void;
};
let stateUpdater: StateUpdater | null = null;
export function registerSipStateUpdater(updater: StateUpdater) {
stateUpdater = updater;
}
export function connectSip(config: SIPConfig): void {
if (connected || sipClient?.isRegistered() || sipClient?.isConnected()) {
return;
}
if (!config.wsServer || !config.uri) {
console.warn('SIP config incomplete — wsServer and uri required');
return;
}
if (sipClient) {
sipClient.disconnect();
}
connected = true;
stateUpdater?.setConnectionStatus('connecting');
sipClient = new SIPClient(
config,
(status) => stateUpdater?.setConnectionStatus(status),
(state, number) => {
stateUpdater?.setCallState(state);
if (number !== undefined) stateUpdater?.setCallerNumber(number ?? null);
},
);
sipClient.connect();
}
export function disconnectSip(): void {
sipClient?.disconnect();
sipClient = null;
connected = false;
stateUpdater?.setConnectionStatus('disconnected');
}
export function getSipClient(): SIPClient | null {
return sipClient;
}

10
src/state/sip-state.ts Normal file
View File

@@ -0,0 +1,10 @@
import { atom } from 'jotai';
import type { ConnectionStatus, CallState } from '@/types/sip';
export const sipConnectionStatusAtom = atom<ConnectionStatus>('disconnected');
export const sipCallStateAtom = atom<CallState>('idle');
export const sipCallerNumberAtom = atom<string | null>(null);
export const sipIsMutedAtom = atom<boolean>(false);
export const sipIsOnHoldAtom = atom<boolean>(false);
export const sipCallDurationAtom = atom<number>(0);
export const sipCallStartTimeAtom = atom<Date | null>(null);