mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: design tokens — multi-hospital theming system
Backend (sidecar): - ThemeService: read/write/backup/reset theme.json with versioning - ThemeController: GET/PUT/POST /api/config/theme endpoints - ConfigThemeModule registered in app Frontend: - ThemeTokenProvider: fetches theme, injects CSS variables on <html> - Login page: logo, title, subtitle, Google/forgot toggles from tokens - Sidebar: title, subtitle, active highlight from brand color scale - AI chat: quick actions from tokens - Branding settings page: 2-column layout, file upload for logo/favicon, single color picker with palette generation, font dropdowns, presets, pinned footer, versioning Theme CSS: - Sidebar active/hover text now references --color-brand-400 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
397
src/pages/branding-settings.tsx
Normal file
397
src/pages/branding-settings.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUpload } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||
import type { ThemeTokens } from '@/providers/theme-token-provider';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
const THEME_API_URL = import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
const COLOR_STOPS = ['25', '50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'];
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
{ id: "'Satoshi', 'Inter', -apple-system, sans-serif", label: 'Satoshi' },
|
||||
{ id: "'General Sans', 'Inter', -apple-system, sans-serif", label: 'General Sans' },
|
||||
{ id: "'Inter', -apple-system, sans-serif", label: 'Inter' },
|
||||
{ id: "'DM Sans', 'Inter', sans-serif", label: 'DM Sans' },
|
||||
{ id: "'Plus Jakarta Sans', 'Inter', sans-serif", label: 'Plus Jakarta Sans' },
|
||||
{ id: "'Nunito Sans', 'Inter', sans-serif", label: 'Nunito Sans' },
|
||||
{ id: "'Source Sans 3', 'Inter', sans-serif", label: 'Source Sans 3' },
|
||||
{ id: "'Poppins', 'Inter', sans-serif", label: 'Poppins' },
|
||||
{ id: "'Lato', 'Inter', sans-serif", label: 'Lato' },
|
||||
{ id: "'Open Sans', 'Inter', sans-serif", label: 'Open Sans' },
|
||||
{ id: "'Roboto', 'Inter', sans-serif", label: 'Roboto' },
|
||||
{ id: "'Noto Sans', 'Inter', sans-serif", label: 'Noto Sans' },
|
||||
];
|
||||
|
||||
const COLOR_PRESETS: Record<string, { label: string; colors: Record<string, string> }> = {
|
||||
blue: {
|
||||
label: 'Blue',
|
||||
colors: { '25': 'rgb(239 246 255)', '50': 'rgb(219 234 254)', '100': 'rgb(191 219 254)', '200': 'rgb(147 197 253)', '300': 'rgb(96 165 250)', '400': 'rgb(59 130 246)', '500': 'rgb(37 99 235)', '600': 'rgb(29 78 216)', '700': 'rgb(30 64 175)', '800': 'rgb(30 58 138)', '900': 'rgb(23 37 84)', '950': 'rgb(15 23 42)' },
|
||||
},
|
||||
teal: {
|
||||
label: 'Teal',
|
||||
colors: { '25': 'rgb(240 253 250)', '50': 'rgb(204 251 241)', '100': 'rgb(153 246 228)', '200': 'rgb(94 234 212)', '300': 'rgb(45 212 191)', '400': 'rgb(20 184 166)', '500': 'rgb(13 148 136)', '600': 'rgb(15 118 110)', '700': 'rgb(17 94 89)', '800': 'rgb(19 78 74)', '900': 'rgb(17 63 61)', '950': 'rgb(4 47 46)' },
|
||||
},
|
||||
violet: {
|
||||
label: 'Violet',
|
||||
colors: { '25': 'rgb(250 245 255)', '50': 'rgb(245 235 255)', '100': 'rgb(235 215 254)', '200': 'rgb(214 187 251)', '300': 'rgb(182 146 246)', '400': 'rgb(158 119 237)', '500': 'rgb(127 86 217)', '600': 'rgb(105 65 198)', '700': 'rgb(83 56 158)', '800': 'rgb(66 48 125)', '900': 'rgb(53 40 100)', '950': 'rgb(44 28 95)' },
|
||||
},
|
||||
rose: {
|
||||
label: 'Rose',
|
||||
colors: { '25': 'rgb(255 241 242)', '50': 'rgb(255 228 230)', '100': 'rgb(254 205 211)', '200': 'rgb(253 164 175)', '300': 'rgb(251 113 133)', '400': 'rgb(244 63 94)', '500': 'rgb(225 29 72)', '600': 'rgb(190 18 60)', '700': 'rgb(159 18 57)', '800': 'rgb(136 19 55)', '900': 'rgb(112 26 53)', '950': 'rgb(76 5 25)' },
|
||||
},
|
||||
emerald: {
|
||||
label: 'Emerald',
|
||||
colors: { '25': 'rgb(236 253 245)', '50': 'rgb(209 250 229)', '100': 'rgb(167 243 208)', '200': 'rgb(110 231 183)', '300': 'rgb(52 211 153)', '400': 'rgb(16 185 129)', '500': 'rgb(5 150 105)', '600': 'rgb(4 120 87)', '700': 'rgb(6 95 70)', '800': 'rgb(6 78 59)', '900': 'rgb(6 62 48)', '950': 'rgb(2 44 34)' },
|
||||
},
|
||||
amber: {
|
||||
label: 'Amber',
|
||||
colors: { '25': 'rgb(255 251 235)', '50': 'rgb(254 243 199)', '100': 'rgb(253 230 138)', '200': 'rgb(252 211 77)', '300': 'rgb(251 191 36)', '400': 'rgb(245 158 11)', '500': 'rgb(217 119 6)', '600': 'rgb(180 83 9)', '700': 'rgb(146 64 14)', '800': 'rgb(120 53 15)', '900': 'rgb(99 49 18)', '950': 'rgb(69 26 3)' },
|
||||
},
|
||||
slate: {
|
||||
label: 'Slate',
|
||||
colors: { '25': 'rgb(248 250 252)', '50': 'rgb(241 245 249)', '100': 'rgb(226 232 240)', '200': 'rgb(203 213 225)', '300': 'rgb(148 163 184)', '400': 'rgb(100 116 139)', '500': 'rgb(71 85 105)', '600': 'rgb(47 64 89)', '700': 'rgb(37 49 72)', '800': 'rgb(30 41 59)', '900': 'rgb(15 23 42)', '950': 'rgb(2 6 23)' },
|
||||
},
|
||||
};
|
||||
|
||||
// Generate a full color scale from a single hex color
|
||||
const hexToHsl = (hex: string): [number, number, number] => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0, s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
return [h * 360, s * 100, l * 100];
|
||||
};
|
||||
|
||||
const hslToRgb = (h: number, s: number, l: number): string => {
|
||||
h /= 360; s /= 100; l /= 100;
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) { r = g = b = l; } else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1; if (t > 1) t -= 1;
|
||||
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||
if (t < 1/2) return q;
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1/3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
|
||||
};
|
||||
|
||||
const generatePalette = (hex: string): Record<string, string> => {
|
||||
const [h, s] = hexToHsl(hex);
|
||||
// Map each stop to a lightness value
|
||||
const stops: Record<string, number> = {
|
||||
'25': 97, '50': 94, '100': 89, '200': 80, '300': 68,
|
||||
'400': 56, '500': 46, '600': 38, '700': 31, '800': 26,
|
||||
'900': 20, '950': 13,
|
||||
};
|
||||
const result: Record<string, string> = {};
|
||||
for (const [stop, lightness] of Object.entries(stops)) {
|
||||
// Desaturate lighter stops slightly for natural feel
|
||||
const satAdj = lightness > 80 ? s * 0.6 : lightness > 60 ? s * 0.85 : s;
|
||||
result[stop] = hslToRgb(h, satAdj, lightness);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const rgbToHex = (rgb: string): string => {
|
||||
const match = rgb.match(/(\d+)\s+(\d+)\s+(\d+)/);
|
||||
if (!match) return '#3b82f6';
|
||||
const [, r, g, b] = match;
|
||||
return `#${[r, g, b].map(c => parseInt(c).toString(16).padStart(2, '0')).join('')}`;
|
||||
};
|
||||
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-5">
|
||||
<h3 className="text-sm font-semibold text-primary mb-3">{title}</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FileUploadField = ({ label, value, onChange, accept }: { label: string; value: string; onChange: (v: string) => void; accept: string }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-secondary">{label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{value && <img src={value} alt={label} className="size-10 rounded-lg border border-secondary object-contain" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex items-center gap-2 rounded-lg border border-secondary px-3 py-2 text-xs font-medium text-secondary hover:bg-secondary transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUpload} className="size-3" />
|
||||
{value ? 'Change' : 'Upload'}
|
||||
</button>
|
||||
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={handleFile} />
|
||||
{value && <span className="text-xs text-tertiary truncate max-w-[200px]">{value.startsWith('data:') ? 'Uploaded file' : value}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BrandingSettingsPage = () => {
|
||||
const { tokens, refresh } = useThemeTokens();
|
||||
const [form, setForm] = useState<ThemeTokens>(tokens);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
useEffect(() => { setForm(tokens); }, [tokens]);
|
||||
|
||||
const updateBrand = (key: keyof ThemeTokens['brand'], value: string) => {
|
||||
setForm(prev => ({ ...prev, brand: { ...prev.brand, [key]: value } }));
|
||||
};
|
||||
|
||||
const updateColor = (stop: string, value: string) => {
|
||||
setForm(prev => ({ ...prev, colors: { ...prev.colors, brand: { ...prev.colors.brand, [stop]: value } } }));
|
||||
};
|
||||
|
||||
const updateTypography = (key: keyof ThemeTokens['typography'], value: string) => {
|
||||
setForm(prev => ({ ...prev, typography: { ...prev.typography, [key]: value } }));
|
||||
};
|
||||
|
||||
const updateLogin = (key: string, value: any) => {
|
||||
if (key === 'poweredBy.label') {
|
||||
setForm(prev => ({ ...prev, login: { ...prev.login, poweredBy: { ...prev.login.poweredBy, label: value } } }));
|
||||
} else if (key === 'poweredBy.url') {
|
||||
setForm(prev => ({ ...prev, login: { ...prev.login, poweredBy: { ...prev.login.poweredBy, url: value } } }));
|
||||
} else {
|
||||
setForm(prev => ({ ...prev, login: { ...prev.login, [key]: value } }));
|
||||
}
|
||||
};
|
||||
|
||||
const updateSidebar = (key: keyof ThemeTokens['sidebar'], value: string) => {
|
||||
setForm(prev => ({ ...prev, sidebar: { ...prev.sidebar, [key]: value } }));
|
||||
};
|
||||
|
||||
const updateQuickAction = (index: number, key: 'label' | 'prompt', value: string) => {
|
||||
setForm(prev => {
|
||||
const actions = [...prev.ai.quickActions];
|
||||
actions[index] = { ...actions[index], [key]: value };
|
||||
return { ...prev, ai: { quickActions: actions } };
|
||||
});
|
||||
};
|
||||
|
||||
const addQuickAction = () => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
ai: { quickActions: [...prev.ai.quickActions, { label: '', prompt: '' }] },
|
||||
}));
|
||||
};
|
||||
|
||||
const removeQuickAction = (index: number) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
ai: { quickActions: prev.ai.quickActions.filter((_, i) => i !== index) },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`${THEME_API_URL}/api/config/theme`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
|
||||
await refresh();
|
||||
notify.success('Branding Saved', 'Theme updated successfully');
|
||||
} catch {
|
||||
notify.error('Save Failed', 'Could not update theme');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await fetch(`${THEME_API_URL}/api/config/theme/reset`, { method: 'POST' });
|
||||
await refresh();
|
||||
notify.success('Branding Reset', 'Theme restored to defaults');
|
||||
} catch {
|
||||
notify.error('Reset Failed', 'Could not reset theme');
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Branding" subtitle="Customize the look and feel of your application" />
|
||||
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
|
||||
{/* LEFT COLUMN */}
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Brand Identity */}
|
||||
<Section title="Brand Identity">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input size="sm" label="App Name" value={form.brand.name} onChange={(v) => updateBrand('name', v)} />
|
||||
<Input size="sm" label="Hospital Name" value={form.brand.hospitalName} onChange={(v) => updateBrand('hospitalName', v)} />
|
||||
</div>
|
||||
<FileUploadField label="Logo" value={form.brand.logo} onChange={(v) => updateBrand('logo', v)} accept="image/*" />
|
||||
<FileUploadField label="Favicon" value={form.brand.favicon} onChange={(v) => updateBrand('favicon', v)} accept="image/*,.ico" />
|
||||
</Section>
|
||||
|
||||
{/* Typography */}
|
||||
<Section title="Typography">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select size="sm" label="Body Font" items={FONT_OPTIONS} selectedKey={form.typography.body || null}
|
||||
onSelectionChange={(key) => updateTypography('body', key as string)} placeholder="Select body font">
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
<Select size="sm" label="Display Font" items={FONT_OPTIONS} selectedKey={form.typography.display || null}
|
||||
onSelectionChange={(key) => updateTypography('display', key as string)} placeholder="Select display font">
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Login Page */}
|
||||
<Section title="Login Page">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input size="sm" label="Title" value={form.login.title} onChange={(v) => updateLogin('title', v)} />
|
||||
<Input size="sm" label="Subtitle" value={form.login.subtitle} onChange={(v) => updateLogin('subtitle', v)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<Checkbox isSelected={form.login.showGoogleSignIn} onChange={(v) => updateLogin('showGoogleSignIn', v)} label="Google Sign-in" />
|
||||
<Checkbox isSelected={form.login.showForgotPassword} onChange={(v) => updateLogin('showForgotPassword', v)} label="Forgot Password" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input size="sm" label="Powered By Label" value={form.login.poweredBy.label} onChange={(v) => updateLogin('poweredBy.label', v)} />
|
||||
<Input size="sm" label="Powered By URL" value={form.login.poweredBy.url} onChange={(v) => updateLogin('poweredBy.url', v)} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Section title="Sidebar">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input size="sm" label="Title" value={form.sidebar.title} onChange={(v) => updateSidebar('title', v)} />
|
||||
<Input size="sm" label="Subtitle" value={form.sidebar.subtitle} onChange={(v) => updateSidebar('subtitle', v)} hint="{role} = user role" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Brand Colors */}
|
||||
<Section title="Brand Colors">
|
||||
<p className="text-xs text-tertiary -mt-1">Pick a base color or preset — the full palette generates automatically.</p>
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative size-10 rounded-lg overflow-hidden border border-secondary cursor-pointer" style={{ backgroundColor: rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)') }}>
|
||||
<input
|
||||
type="color"
|
||||
value={rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, colors: { brand: generatePalette(e.target.value) } }))}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-medium text-secondary">Base Color</span>
|
||||
<p className="text-xs text-tertiary">{rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(COLOR_PRESETS).map(([key, preset]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setForm(prev => ({ ...prev, colors: { brand: { ...preset.colors } } }))}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-secondary px-2.5 py-1.5 text-xs font-medium text-secondary hover:border-brand hover:text-brand-secondary transition duration-100 ease-linear"
|
||||
>
|
||||
<div className="flex h-3.5 w-10 rounded overflow-hidden">
|
||||
{['300', '500', '700'].map(s => (
|
||||
<div key={s} className="flex-1" style={{ backgroundColor: preset.colors[s] }} />
|
||||
))}
|
||||
</div>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Generated palette preview */}
|
||||
<div className="flex h-8 rounded-lg overflow-hidden border border-secondary">
|
||||
{COLOR_STOPS.map(stop => (
|
||||
<div
|
||||
key={stop}
|
||||
className="flex-1 flex items-end justify-center pb-0.5"
|
||||
style={{ backgroundColor: form.colors.brand[stop] ?? '#ccc' }}
|
||||
title={`${stop}: ${form.colors.brand[stop] ?? ''}`}
|
||||
>
|
||||
<span className="text-[8px] font-bold" style={{ color: parseInt(stop) < 400 ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)' }}>{stop}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* AI Quick Actions */}
|
||||
<Section title="AI Quick Actions">
|
||||
<div className="space-y-2">
|
||||
{form.ai.quickActions.map((action, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<Input size="sm" placeholder="Label" value={action.label} onChange={(v) => updateQuickAction(i, 'label', v)} />
|
||||
<Input size="sm" placeholder="Prompt" value={action.prompt} onChange={(v) => updateQuickAction(i, 'prompt', v)} />
|
||||
</div>
|
||||
<Button size="sm" color="tertiary" onClick={() => removeQuickAction(i)} className="mt-1">Remove</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" color="secondary" onClick={addQuickAction}>Add Quick Action</Button>
|
||||
</Section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer — pinned */}
|
||||
<div className="shrink-0 flex items-center justify-between px-6 py-4 border-t border-secondary">
|
||||
<Button size="sm" color="secondary" onClick={handleReset} isLoading={resetting}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<Button size="md" color="primary" onClick={handleSave} isLoading={saving} showTextWhileLoading>
|
||||
{saving ? 'Saving...' : 'Save Branding'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -10,12 +10,14 @@ import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { loginWithUser } = useAuth();
|
||||
const { refresh } = useData();
|
||||
const navigate = useNavigate();
|
||||
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||
const { tokens } = useThemeTokens();
|
||||
|
||||
const saved = localStorage.getItem('helix_remember');
|
||||
const savedCreds = saved ? JSON.parse(saved) : null;
|
||||
@@ -89,13 +91,13 @@ export const LoginPage = () => {
|
||||
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img src="/helix-logo.png" alt="Helix Engage" className="size-12 rounded-xl mb-3" />
|
||||
<h1 className="text-display-xs font-bold text-primary font-display">Sign in to Helix Engage</h1>
|
||||
<p className="text-sm text-tertiary mt-1">Global Hospital</p>
|
||||
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" />
|
||||
<h1 className="text-display-xs font-bold text-primary font-display">{tokens.login.title}</h1>
|
||||
<p className="text-sm text-tertiary mt-1">{tokens.login.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Google sign-in */}
|
||||
<SocialButton
|
||||
{tokens.login.showGoogleSignIn && <SocialButton
|
||||
social="google"
|
||||
size="lg"
|
||||
theme="gray"
|
||||
@@ -104,14 +106,14 @@ export const LoginPage = () => {
|
||||
className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
>
|
||||
Sign in with Google
|
||||
</SocialButton>
|
||||
</SocialButton>}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mt-5 mb-5 flex items-center gap-3">
|
||||
{tokens.login.showGoogleSignIn && <div className="mt-5 mb-5 flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-secondary" />
|
||||
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
||||
<div className="flex-1 h-px bg-secondary" />
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||
@@ -156,13 +158,13 @@ export const LoginPage = () => {
|
||||
isSelected={rememberMe}
|
||||
onChange={setRememberMe}
|
||||
/>
|
||||
<button
|
||||
{tokens.login.showForgotPassword && <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>
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -178,7 +180,7 @@ export const LoginPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<a href="https://f0rty2.ai" target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">Powered by F0rty2.ai</a>
|
||||
<a href={tokens.login.poweredBy.url} target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">{tokens.login.poweredBy.label}</a>
|
||||
|
||||
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ const DateFilter = ({ value, onChange }: { value: DateRange; onChange: (v: DateR
|
||||
);
|
||||
|
||||
const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => (
|
||||
<div className="flex flex-1 items-center gap-3 rounded-xl border border-secondary bg-primary p-4">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-secondary bg-primary p-4 min-w-0">
|
||||
<div className={cx('flex size-10 items-center justify-center rounded-lg', color ?? 'bg-brand-secondary')}>
|
||||
<FontAwesomeIcon icon={icon} className="size-4 text-fg-white" />
|
||||
</div>
|
||||
@@ -210,8 +210,8 @@ export const TeamPerformancePage = () => {
|
||||
const days = Object.keys(dayMap);
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['Inbound', 'Outbound'], bottom: 0 },
|
||||
grid: { top: 10, right: 10, bottom: 30, left: 40 },
|
||||
legend: { data: ['Inbound', 'Outbound'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||
grid: { top: 10, right: 10, bottom: 50, left: 40 },
|
||||
xAxis: { type: 'category', data: days },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
@@ -292,7 +292,7 @@ export const TeamPerformancePage = () => {
|
||||
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
||||
<DateFilter value={range} onChange={setRange} />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
||||
<KpiCard icon={faPhoneVolume} value={totalCalls} label="Total Calls" color="bg-brand-solid" />
|
||||
<KpiCard icon={faCalendarCheck} value={totalAppts} label="Appointments" color="bg-success-solid" />
|
||||
|
||||
Reference in New Issue
Block a user