mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
feat: call desk redesign — 2-panel layout, collapsible sidebar, inline AI, ringtone
- Collapsible sidebar with Jotai atom (icon-only mode, persisted to localStorage) - 2-panel call desk: worklist (60%) + context panel (40%) with AI + Lead 360 tabs - Inline AI call prep card — known lead summary or unknown caller script - Active call card with compact Answer/Decline buttons - Worklist panel with human-readable labels, priority badges, click-to-select - Context panel auto-switches to Lead 360 when lead selected or call incoming - Browser ringtone via Web Audio API on incoming calls - Sonner + Untitled UI IconNotification for toast system - apiClient pattern: centralized post/get/graphql with auto-toast on errors - Remove duplicate avatar from top bar, hide floating widget on call desk - Fix Link routing in collapsed sidebar (was using <a> causing full page reload) - Fix GraphQL field names: adStatus→status, platformUrl needs subfield selection - Silent mode for DataProvider queries to prevent toast spam Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { notify } from './toast';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
class AuthError extends Error {
|
||||
@@ -20,20 +22,37 @@ const clearTokens = () => {
|
||||
localStorage.removeItem('helix_refresh_token');
|
||||
};
|
||||
|
||||
// Shared auth headers
|
||||
const authHeaders = (): Record<string, string> => {
|
||||
const token = getStoredToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
// Shared response handler — extracts error message, handles 401, toasts on failure
|
||||
const handleResponse = async <T>(response: Response, silent = false): Promise<T> => {
|
||||
if (response.status === 401) {
|
||||
clearTokens();
|
||||
if (!silent) notify.error('Session expired. Please log in again.');
|
||||
throw new AuthError();
|
||||
}
|
||||
|
||||
const json = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
|
||||
if (!silent) notify.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return json as T;
|
||||
};
|
||||
|
||||
export const apiClient = {
|
||||
async login(email: string, password: string): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user?: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
avatarUrl?: string;
|
||||
role?: string;
|
||||
platformRoles?: string[];
|
||||
};
|
||||
}> {
|
||||
// Auth — no token needed, no toast on failure (login page shows inline errors)
|
||||
async login(email: string, password: string) {
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -47,36 +66,67 @@ export const apiClient = {
|
||||
|
||||
const data = await response.json();
|
||||
storeTokens(data.accessToken, data.refreshToken);
|
||||
return data;
|
||||
return data as {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user?: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
avatarUrl?: string;
|
||||
role?: string;
|
||||
platformRoles?: string[];
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
async graphql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
// GraphQL — all platform data queries go through this
|
||||
async graphql<T>(query: string, variables?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
||||
const token = getStoredToken();
|
||||
if (!token) throw new AuthError();
|
||||
|
||||
const response = await fetch(`${API_URL}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
clearTokens();
|
||||
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
||||
throw new AuthError();
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (json.errors) {
|
||||
console.error('GraphQL errors:', json.errors);
|
||||
throw new Error(json.errors[0]?.message ?? 'GraphQL error');
|
||||
const message = json.errors[0]?.message ?? 'GraphQL error';
|
||||
if (!options?.silent) notify.error('Query failed', message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
|
||||
// REST — all sidecar API calls go through these
|
||||
async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(response, options?.silent);
|
||||
},
|
||||
|
||||
async get<T>(path: string, options?: { silent?: boolean }): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
return handleResponse<T>(response, options?.silent);
|
||||
},
|
||||
|
||||
// Health check — silent, no toasts
|
||||
async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
|
||||
|
||||
@@ -21,12 +21,12 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
|
||||
budget { amountMicros currencyCode }
|
||||
amountSpent { amountMicros currencyCode }
|
||||
impressions clicks targetCount contacted converted leadsGenerated
|
||||
externalCampaignId platformUrl
|
||||
externalCampaignId platformUrl { primaryLinkUrl }
|
||||
} } } }`;
|
||||
|
||||
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt updatedAt
|
||||
adName externalAdId adStatus adFormat
|
||||
adName externalAdId status adFormat
|
||||
headline adDescription destinationUrl previewUrl
|
||||
impressions clicks conversions
|
||||
spend { amountMicros currencyCode }
|
||||
|
||||
40
src/lib/ringtone.ts
Normal file
40
src/lib/ringtone.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Browser ringtone using Web Audio API — no external file needed
|
||||
let audioContext: AudioContext | null = null;
|
||||
let ringtoneInterval: number | null = null;
|
||||
|
||||
function playTone(ctx: AudioContext, frequency: number, duration: number, startTime: number) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = frequency;
|
||||
gain.gain.setValueAtTime(0.3, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + duration);
|
||||
}
|
||||
|
||||
function playRingBurst() {
|
||||
if (!audioContext) audioContext = new AudioContext();
|
||||
const now = audioContext.currentTime;
|
||||
// Two-tone ring: 440Hz + 480Hz (standard phone ring)
|
||||
playTone(audioContext, 440, 0.4, now);
|
||||
playTone(audioContext, 480, 0.4, now);
|
||||
playTone(audioContext, 440, 0.4, now + 0.5);
|
||||
playTone(audioContext, 480, 0.4, now + 0.5);
|
||||
}
|
||||
|
||||
export function startRingtone() {
|
||||
stopRingtone();
|
||||
playRingBurst();
|
||||
// Repeat every 3 seconds (ring-pause-ring pattern)
|
||||
ringtoneInterval = window.setInterval(playRingBurst, 3000);
|
||||
}
|
||||
|
||||
export function stopRingtone() {
|
||||
if (ringtoneInterval !== null) {
|
||||
clearInterval(ringtoneInterval);
|
||||
ringtoneInterval = null;
|
||||
}
|
||||
}
|
||||
53
src/lib/toast.ts
Normal file
53
src/lib/toast.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { toast } from 'sonner';
|
||||
import { createElement } from 'react';
|
||||
import { IconNotification } from '@/components/application/notifications/notifications';
|
||||
|
||||
export const notify = {
|
||||
success: (title: string, description?: string) => {
|
||||
toast.custom((t) =>
|
||||
createElement(IconNotification, {
|
||||
title,
|
||||
description: description ?? '',
|
||||
color: 'success',
|
||||
hideDismissLabel: true,
|
||||
onClose: () => toast.dismiss(t),
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
error: (title: string, description?: string) => {
|
||||
toast.custom((t) =>
|
||||
createElement(IconNotification, {
|
||||
title,
|
||||
description: description ?? '',
|
||||
color: 'error',
|
||||
hideDismissLabel: true,
|
||||
onClose: () => toast.dismiss(t),
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
warning: (title: string, description?: string) => {
|
||||
toast.custom((t) =>
|
||||
createElement(IconNotification, {
|
||||
title,
|
||||
description: description ?? '',
|
||||
color: 'warning',
|
||||
hideDismissLabel: true,
|
||||
onClose: () => toast.dismiss(t),
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
info: (title: string, description?: string) => {
|
||||
toast.custom((t) =>
|
||||
createElement(IconNotification, {
|
||||
title,
|
||||
description: description ?? '',
|
||||
color: 'default',
|
||||
hideDismissLabel: true,
|
||||
onClose: () => toast.dismiss(t),
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -70,7 +70,7 @@ export function transformCampaigns(data: any): Campaign[] {
|
||||
convertedCount: n.converted ?? 0,
|
||||
leadCount: n.leadsGenerated ?? 0,
|
||||
externalCampaignId: n.externalCampaignId,
|
||||
platformUrl: n.platformUrl,
|
||||
platformUrl: n.platformUrl?.primaryLinkUrl ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function transformAds(data: any): Ad[] {
|
||||
updatedAt: n.updatedAt,
|
||||
adName: n.adName ?? n.name,
|
||||
externalAdId: n.externalAdId,
|
||||
adStatus: n.adStatus,
|
||||
adStatus: n.status,
|
||||
adFormat: n.adFormat,
|
||||
headline: n.headline,
|
||||
adDescription: n.adDescription,
|
||||
|
||||
Reference in New Issue
Block a user