diff --git a/apps/web/package.json b/apps/web/package.json
index 6330667..213b13b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -13,6 +13,7 @@
"@hookform/resolvers": "^5.9.1",
"@selfphoneng/shared": "workspace:*",
"@tanstack/react-query": "^5.104.3",
+ "lucide-react": "^1.56.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.90.0",
diff --git a/apps/web/src/components/Dialog.tsx b/apps/web/src/components/Dialog.tsx
index f9b7bd8..e056a97 100644
--- a/apps/web/src/components/Dialog.tsx
+++ b/apps/web/src/components/Dialog.tsx
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Native
+ {children}
+
);
}
-export const inputClass = `w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm ${focus} aria-invalid:border-red-500`;
+export const inputClass = `w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm ${focusRing} aria-invalid:border-red-500`;
/** Label + control + error text. `children` gets the id and aria props to spread on the control. */
export function Field({
diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx
index a129cb0..5083274 100644
--- a/apps/web/src/pages/SetupPage.tsx
+++ b/apps/web/src/pages/SetupPage.tsx
@@ -2,6 +2,7 @@
// First-run setup wizard (05 §2.1). Every step saves before moving on; some can be skipped.
// M3 also brings steps 7 (users) and 8 (main number routing) because "calls work using only the
// wizard" needs them; M6 adds "Ring everyone" to step 8 (an IVR needs a recorded greeting first).
+import { Check } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
@@ -127,7 +128,10 @@ function PasswordStep({ next }: { next: () => void }) {
// Signing in with the initial password already forced a change (RequireAuth).
return (
<>
- ✓ The initial admin password has been changed.
+
+
+ The initial admin password has been changed.
+
>
);
@@ -465,7 +469,8 @@ function NumbersStep({ next }: { next: () => void }) {
{dids.data.map((d) => (
-
- ✓ {formatNumber(d.number)} {d.label && {d.label}}
+
+ {formatNumber(d.number)} {d.label && {d.label}}
))}
@@ -562,7 +567,8 @@ function UsersStep({ next }: { next: () => void }) {
{withExt.map((u) => (
-
- ✓ {u.extension!.number} {u.displayName}
+
+ {u.extension!.number} {u.displayName}
))}
diff --git a/apps/web/src/pages/admin/AdminLayout.tsx b/apps/web/src/pages/admin/AdminLayout.tsx
index 24171ae..29e6abb 100644
--- a/apps/web/src/pages/admin/AdminLayout.tsx
+++ b/apps/web/src/pages/admin/AdminLayout.tsx
@@ -1,36 +1,97 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
+// Admin shell (05 §2): sidebar grouped by section; unbuilt pages say which milestone brings them.
import { useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+ Activity,
+ AudioLines,
+ Ban,
+ BellRing,
+ BookUser,
+ Cable,
+ ChartColumn,
+ Clock,
+ Hash,
+ Headset,
+ HistoryIcon,
+ LayoutDashboard,
+ Menu,
+ ListOrdered,
+ Megaphone,
+ MessageSquare,
+ MonitorSmartphone,
+ Music,
+ PhoneOutgoing,
+ Settings,
+ Split,
+ Users,
+} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
import { signOut, systemApi } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
-import { Brand, Button, MenuIcon } from '../../components/ui.tsx';
+import { Brand, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
+// Grouped by job rather than spec order, so the 21 pages scan as six short lists.
export const ADMIN_PAGES = [
- { path: '', label: 'Dashboard', milestone: null },
- { path: 'users', label: 'Users & Extensions', milestone: null },
- { path: 'trunks', label: 'Trunks', milestone: null },
- { path: 'numbers', label: 'Phone numbers', milestone: null },
- { path: 'outbound', label: 'Outbound rules', milestone: null },
- { path: 'ring-groups', label: 'Ring groups', milestone: null },
- { path: 'queues', label: 'Queues', milestone: null },
- { path: 'ivrs', label: 'Digital receptionist', milestone: null },
- { path: 'hours', label: 'Office hours', milestone: null },
- { path: 'conferences', label: 'Conferences', milestone: null },
- { path: 'paging', label: 'Paging', milestone: null },
- { path: 'sounds', label: 'Sounds & music', milestone: null },
- { path: 'devices', label: 'Phones', milestone: null },
- { path: 'contacts', label: 'Contacts', milestone: null },
- { path: 'calls', label: 'Call history', milestone: null },
- { path: 'recordings', label: 'Recordings', milestone: null },
- { path: 'reports', label: 'Reports', milestone: null },
- { path: 'sms', label: 'SMS', milestone: 'M9' },
- { path: 'blocked', label: 'Blocked numbers', milestone: null },
- { path: 'settings', label: 'Settings', milestone: null },
- { path: 'system', label: 'System', milestone: null },
+ { path: '', label: 'Dashboard', milestone: null, section: '', icon: LayoutDashboard },
+ { path: 'trunks', label: 'Trunks', milestone: null, section: 'Call routing', icon: Cable },
+ { path: 'numbers', label: 'Phone numbers', milestone: null, section: 'Call routing', icon: Hash },
+ {
+ path: 'outbound',
+ label: 'Outbound rules',
+ milestone: null,
+ section: 'Call routing',
+ icon: PhoneOutgoing,
+ },
+ {
+ path: 'ring-groups',
+ label: 'Ring groups',
+ milestone: null,
+ section: 'Call routing',
+ icon: BellRing,
+ },
+ { path: 'queues', label: 'Queues', milestone: null, section: 'Call routing', icon: ListOrdered },
+ {
+ path: 'ivrs',
+ label: 'Digital receptionist',
+ milestone: null,
+ section: 'Call routing',
+ icon: Split,
+ },
+ { path: 'hours', label: 'Office hours', milestone: null, section: 'Call routing', icon: Clock },
+ {
+ path: 'blocked',
+ label: 'Blocked numbers',
+ milestone: null,
+ section: 'Call routing',
+ icon: Ban,
+ },
+ { path: 'users', label: 'Users & Extensions', milestone: null, section: 'People', icon: Users },
+ { path: 'devices', label: 'Phones', milestone: null, section: 'People', icon: MonitorSmartphone },
+ { path: 'contacts', label: 'Contacts', milestone: null, section: 'People', icon: BookUser },
+ {
+ path: 'conferences',
+ label: 'Conferences',
+ milestone: null,
+ section: 'Features',
+ icon: Headset,
+ },
+ { path: 'paging', label: 'Paging', milestone: null, section: 'Features', icon: Megaphone },
+ { path: 'sounds', label: 'Sounds & music', milestone: null, section: 'Features', icon: Music },
+ { path: 'sms', label: 'SMS', milestone: 'M9', section: 'Features', icon: MessageSquare },
+ { path: 'calls', label: 'Call history', milestone: null, section: 'History', icon: HistoryIcon },
+ {
+ path: 'recordings',
+ label: 'Recordings',
+ milestone: null,
+ section: 'History',
+ icon: AudioLines,
+ },
+ { path: 'reports', label: 'Reports', milestone: null, section: 'History', icon: ChartColumn },
+ { path: 'settings', label: 'Settings', milestone: null, section: 'System', icon: Settings },
+ { path: 'system', label: 'System', milestone: null, section: 'System', icon: Activity },
] as const;
export function AdminLayout({ user }: { user: AuthUser }) {
@@ -52,23 +113,33 @@ export function AdminLayout({ user }: { user: AuthUser }) {
const links = (
);
@@ -91,7 +162,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
aria-label="Menu"
onClick={() => setNavOpen((o) => !o)}
>
-
+
diff --git a/apps/web/src/pages/admin/OfficeHoursPage.tsx b/apps/web/src/pages/admin/OfficeHoursPage.tsx
index c0bf08b..37a83d2 100644
--- a/apps/web/src/pages/admin/OfficeHoursPage.tsx
+++ b/apps/web/src/pages/admin/OfficeHoursPage.tsx
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Office hours (05 §2, 03 §6.10): when calls go one way or another. A weekly grid (click and drag
// half hours), the exact ranges as a list, holidays, and force open / closed / automatic.
+import { X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -254,7 +255,7 @@ function RangeList({
aria-label={`Remove ${rangeText(r)}`}
onClick={() => onChange(ranges.filter((_, j) => j !== i))}
>
- ✕
+
))}
diff --git a/apps/web/src/pages/admin/OutboundPage.tsx b/apps/web/src/pages/admin/OutboundPage.tsx
index 9f00bab..7472282 100644
--- a/apps/web/src/pages/admin/OutboundPage.tsx
+++ b/apps/web/src/pages/admin/OutboundPage.tsx
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound rules (05 §2, 03 §6.12): ordered list (first match wins), form, and a test box.
// ponytail: ↑/↓ buttons instead of drag-to-reorder; keyboard-friendly, no drag library.
+import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
@@ -80,7 +81,7 @@ export function OutboundPage() {
disabled={i === 0 || move.isPending}
onClick={() => move.mutate({ from: i, to: i - 1 })}
>
- ↑
+
{i + 1}.
@@ -340,7 +341,7 @@ function RouteDrawer({
onClick={() => patterns.remove(i)}
disabled={patterns.fields.length === 1}
>
- ✕
+
))}
@@ -385,14 +386,14 @@ function RouteDrawer({
setTrunks(ids);
}}
>
- ↑
+
))}
diff --git a/apps/web/src/pages/admin/QueuesPage.tsx b/apps/web/src/pages/admin/QueuesPage.tsx
index 875e531..7814952 100644
--- a/apps/web/src/pages/admin/QueuesPage.tsx
+++ b/apps/web/src/pages/admin/QueuesPage.tsx
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queues (05 §2, 03 §6.8): callers wait with music until an agent is free. The list shows live
// counts; a queue's page shows who is waiting, the agents (pause/unpause) and today's numbers.
+import { ArrowLeft, X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useParams } from 'react-router';
@@ -152,8 +153,9 @@ function QueueDetail({ id }: { id: string }) {
return (
-
- ← Queues
+
+
+ Queues
{q.number} {q.name}
@@ -449,7 +451,7 @@ function QueueForm({
setMembers((ms) => ms.filter((x) => x.extensionId !== m.extensionId))
}
>
- ✕
+
))}
diff --git a/apps/web/src/pages/admin/RingGroupsPage.tsx b/apps/web/src/pages/admin/RingGroupsPage.tsx
index 65eb876..754cd4c 100644
--- a/apps/web/src/pages/admin/RingGroupsPage.tsx
+++ b/apps/web/src/pages/admin/RingGroupsPage.tsx
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Ring groups (05 §2, 03 §6.7): one number that rings several people, everyone at once or one at
// a time, then goes somewhere else when nobody answers.
+import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -246,7 +247,7 @@ function RingGroupForm({
disabled={i === 0}
onClick={() => move(i, -1)}
>
- ↑
+
))}
diff --git a/apps/web/src/pages/admin/SoundsPage.tsx b/apps/web/src/pages/admin/SoundsPage.tsx
index 8a69364..d7e2331 100644
--- a/apps/web/src/pages/admin/SoundsPage.tsx
+++ b/apps/web/src/pages/admin/SoundsPage.tsx
@@ -2,6 +2,7 @@
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements.
// Upload by picking or dropping a file; play, rename, delete. Music-on-hold classes and their
// files (03 §8) and announcements live here too.
+import { ArrowDown, ArrowUp } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -337,7 +338,7 @@ function MohClassCard({
disabled={i === 0 || update.isPending}
onClick={() => move(i, -1)}
>
- ↑
+
)}
diff --git a/apps/web/src/pages/user/ContactsPage.tsx b/apps/web/src/pages/user/ContactsPage.tsx
index 9445827..efdbbfa 100644
--- a/apps/web/src/pages/user/ContactsPage.tsx
+++ b/apps/web/src/pages/user/ContactsPage.tsx
@@ -2,6 +2,7 @@
// Contacts (spec 11): my book with the company's (read-only) at /me/contacts, the company's at
// /admin/contacts. Add, edit, import (.vcf, .csv), share (an offer the colleague accepts or
// denies), click to call. useContactName() gives every page names for numbers.
+import { X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import {
@@ -378,7 +379,7 @@ function ContactDrawer(props: {
setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) })
}
>
- ✕
+
))}
diff --git a/apps/web/src/pages/user/MePage.tsx b/apps/web/src/pages/user/MePage.tsx
index 1d3d41c..4330a55 100644
--- a/apps/web/src/pages/user/MePage.tsx
+++ b/apps/web/src/pages/user/MePage.tsx
@@ -1,12 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
+import {
+ BookUser,
+ House,
+ Info,
+ LogOut,
+ Menu,
+ Phone,
+ PhoneMissed,
+ Settings,
+ Shield,
+ Users,
+ Voicemail,
+ X,
+} from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useEffect, useState } from 'react';
+import { useEffect, useState, type ReactNode } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
import { meApi, signOut } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
-import { Alert, Brand, Button, MenuIcon } from '../../components/ui.tsx';
+import { Alert, Brand, Button, Panel } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
@@ -45,7 +59,8 @@ export function MePage({ user }: { user: AuthUser }) {
}
const tab = ({ isActive }: { isActive: boolean }) =>
- `rounded-md px-3 py-2 text-sm font-medium pointer-coarse:py-3 ${isActive ? 'bg-subtle' : 'hover:bg-subtle'}`;
+ `inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium pointer-coarse:py-3 ${isActive ? 'bg-primary-soft text-primary-ink' : 'hover:bg-subtle'}`;
+ const icon = 'size-4 shrink-0';
const row = tab({ isActive: false });
return (
@@ -75,7 +90,7 @@ export function MePage({ user }: { user: AuthUser }) {
aria-label={offers > 0 ? `Menu, ${offers} shared with you` : 'Menu'}
onClick={() => setMenu((o) => !o)}
>
-
+
{offers > 0 && (
)}
@@ -88,37 +103,45 @@ export function MePage({ user }: { user: AuthUser }) {
className={`${menu ? 'flex' : 'hidden'} flex-col gap-1 border-b border-line pb-2 md:flex md:flex-row`}
>
+
Home
+
Calls
+
Voicemail
+
Contacts
{offers > 0 && (
-
+
, shared with you:
{offers}
)}
+
Settings
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
`${tab(s)} lg:hidden`} onClick={close}>
+
People
{user.role === 'admin' && (
+
Admin
)}
+
About
@@ -126,6 +149,7 @@ export function MePage({ user }: { user: AuthUser }) {
@@ -145,7 +169,7 @@ export function MePage({ user }: { user: AuthUser }) {
useLive.setState((s) => ({ toasts: s.toasts.filter((x) => x.id !== t.id) }))
}
>
- ✕
+
))}
@@ -169,39 +193,50 @@ export function MePage({ user }: { user: AuthUser }) {
);
}
+const seeAll = 'text-sm font-medium text-link hover:underline';
+const when = (iso: string) =>
+ new Date(iso).toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+
+/** One line in a Home card: a round icon, two lines of text, and an optional button. */
+function Row({
+ icon,
+ title,
+ detail,
+ children,
+}: {
+ icon: ReactNode;
+ title: string;
+ detail: string;
+ children?: ReactNode;
+}) {
+ return (
+
+ {icon}
+
+ {title}
+ {detail}
+
+ {children}
+
+ );
+}
+
function Home({ user }: { user: AuthUser }) {
- const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
- const fresh = list.data?.filter(isNew) ?? [];
- const nameOf = useContactName();
return (
-
Hi {user.displayName}.
+
Hi {user.displayName}
{user.extension &&
}
- {user.extension &&
}
+
+ {user.extension && }
+
+
{user.extension &&
}
-
-
- New voicemails
-
- {list.error && {list.error.message}}
- {list.data && fresh.length === 0 && None.
}
-
- {fresh.slice(0, 5).map((m) => (
- -
- {nameOf(m.callerNumber) ?? callerOf(m)}{' '}
-
- {new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
-
-
- ))}
-
- {list.data && list.data.length > 0 && (
-
- Open voicemail
-
- )}
-
Change password
@@ -211,6 +246,42 @@ function Home({ user }: { user: AuthUser }) {
);
}
+function NewVoicemails() {
+ const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
+ const fresh = list.data?.filter(isNew) ?? [];
+ const nameOf = useContactName();
+ return (
+ 0 && (
+
+ Open voicemail
+
+ )
+ }
+ >
+ {list.error && {list.error.message}}
+ {list.data && fresh.length === 0 && (
+ You're all caught up.
+ )}
+ {fresh.length > 0 && (
+
+ {fresh.slice(0, 5).map((m) => (
+
}
+ title={nameOf(m.callerNumber) ?? callerOf(m)}
+ detail={`${when(m.receivedAt)} · ${mmss(m.durationS)}`}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
/** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
function SharedNotice() {
const offers = useContactShares().data ?? [];
@@ -236,19 +307,20 @@ function StatusCard({ ext }: { ext: string }) {
me.data.cfna && `no answer → ${showTarget(me.data.cfna)}`,
].filter(Boolean);
return (
-
+ Change
+
+ }
>
- {fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}{' '}
-
- Change
-
+ {fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}
-
+
);
}
@@ -307,44 +379,49 @@ function MissedCalls() {
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
const nameOf = useContactName();
return (
-
-
- Missed calls
-
- {calls.data && missed.length === 0 && None.
}
+ 0 && (
+
+ All my calls
+
+ )
+ }
+ >
+ {calls.data && missed.length === 0 && No missed calls.
}
{note && (
{note}
)}
-
- {missed.map((c) => {
- const o = otherParty(c);
- return (
- -
-
- {nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
- {' '}
- {new Date(c.startedAt).toLocaleString()}{' '}
- {o.number && (
-
- )}
-
- );
- })}
-
- {calls.data && calls.data.items.length > 0 && (
-
- All my calls
-
+ {missed.length > 0 && (
+
+ {missed.map((c) => {
+ const o = otherParty(c);
+ return (
+
}
+ title={nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
+ detail={when(c.startedAt)}
+ >
+ {o.number && (
+
+ )}
+
+ );
+ })}
+
)}
-
+
);
}
diff --git a/apps/web/src/softphone/SoftphoneDock.tsx b/apps/web/src/softphone/SoftphoneDock.tsx
index 7b6a85c..b7c1410 100644
--- a/apps/web/src/softphone/SoftphoneDock.tsx
+++ b/apps/web/src/softphone/SoftphoneDock.tsx
@@ -3,11 +3,22 @@
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
import { useQuery } from '@tanstack/react-query';
+import {
+ ArrowRightLeft,
+ Grid3x3,
+ Mic,
+ MicOff,
+ Pause,
+ Phone,
+ PhoneOff,
+ Power,
+ type LucideIcon,
+} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts';
import { SpamNote } from '../components/SpamNote.tsx';
-import { Button, inputClass } from '../components/ui.tsx';
+import { Button, focusRing, inputClass } from '../components/ui.tsx';
import { useLive } from '../live/store.ts';
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
@@ -64,6 +75,24 @@ const STATUS = {
error: 'Error',
elsewhere: 'Active in another tab',
};
+const DOT = {
+ off: 'bg-line-strong',
+ connecting: 'bg-amber-500 animate-pulse motion-reduce:animate-none',
+ registered: 'bg-green-500',
+ error: 'bg-red-500',
+ elsewhere: 'bg-line-strong',
+};
+// The letters on a phone's keys, as a hint only (screen readers get the digit).
+const LETTERS: Record = {
+ '2': 'ABC',
+ '3': 'DEF',
+ '4': 'GHI',
+ '5': 'JKL',
+ '6': 'MNO',
+ '7': 'PQRS',
+ '8': 'TUV',
+ '9': 'WXYZ',
+};
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
const clean = (s: string) => s.replace(/[^0-9*#+]/g, '');
@@ -195,8 +224,12 @@ export function SoftphoneDock() {
className="sticky bottom-0 mt-auto flex flex-col gap-2 border-t border-line bg-page py-3"
>
-
- Softphone: {STATUS[reg]}
+
+
+ Softphone:
+
+ {STATUS[reg]}
+
{reg === 'off' && (
@@ -216,6 +249,7 @@ export function SoftphoneDock() {
aria-controls="sp-panel"
onClick={() => setOpen((o) => !o)}
>
+
{open ? 'Hide keypad' : 'Keypad'}
>
@@ -258,19 +293,15 @@ export function SoftphoneDock() {
);
}
-// Feather's phone; turned 135° it's the hang-up handset.
-const PHONE =
- 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z';
-
function RoundButton({
label,
color,
- hangUp = false,
+ icon: Icon,
onClick,
}: {
label: string;
color: string;
- hangUp?: boolean;
+ icon: LucideIcon;
onClick: () => void;
}) {
return (
@@ -282,18 +313,7 @@ function RoundButton({
-
+
{label}
@@ -341,21 +361,64 @@ function IncomingCall({
-
-
+
+
);
}
+const TONE = {
+ normal: 'border border-line bg-surface hover:bg-subtle',
+ pressed: 'bg-primary text-white hover:bg-primary-hover',
+ answer: 'bg-emerald-700 text-white hover:bg-emerald-800',
+ danger: 'bg-red-600 text-white hover:bg-red-700',
+};
+
+/** A call control: icon over a short label, phone style. `pressed` makes it a toggle. */
+function Control({
+ icon: Icon,
+ label,
+ pressed,
+ tone = 'normal',
+ onClick,
+}: {
+ icon: LucideIcon;
+ label: string;
+ pressed?: boolean;
+ tone?: 'normal' | 'answer' | 'danger';
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
function Keypad({ onKey, label }: { onKey: (k: string) => void; label: string }) {
return (
-
+
{KEYS.map((k) => (
-
+
))}
);
@@ -436,67 +499,90 @@ function CallCard({
e.dataTransfer.setData(CALL_DRAG, call.id)}
>
-
-
- {name} · {label}
- {call.answeredAt && <> · {elapsed(call.answeredAt, now)}>}
+
+
+ {name}
+
+ {label}
+ {call.answeredAt && (
+ · {elapsed(call.answeredAt, now)}
+ )}
+
{call.poorQuality && (
- Poor connection
+ Poor connection
)}
-
-
+
+
+
+
{call.state === 'incoming' ? (
<>
-
-
+ run(softphone.answer(call.id))}
+ />
+ run(softphone.decline(call.id))}
+ />
>
) : (
<>
- {call.held && other && !consultId && (
-
- )}
{call.state === 'active' && (
<>
-
-
+ run(softphone.hold(call.id, !call.held))}
- >
- {call.held ? 'Resume' : 'Hold'}
-
-
-
+ />
+ setKeypad((k) => !k)}
+ />
+ setTransfer((t) => !t)}
+ />
>
)}
-
+ run(softphone.hangup(call.id))}
+ />
>
)}
+ {call.held && other && !consultId && (
+
+ )}
{keypad && call.state === 'active' && (
softphone.dtmf(call.id, k)} />
)}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5a9287c..893aea8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -117,6 +117,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.104.3
version: 5.104.3(react@19.3.1)
+ lucide-react:
+ specifier: ^1.56.0
+ version: 1.56.0(@types/react@19.3.0)(react@19.3.1)
react:
specifier: ^19.3.1
version: 19.3.1
@@ -2156,6 +2159,15 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lucide-react@1.56.0:
+ resolution: {integrity: sha512-b+hqj30/trHBma5r1zlKb6yLewQp6tUtnz9loc3xdrYerPmvLYhlEbyPVCJ8DOg2/blBf6lwPLznftFVACka6A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4288,6 +4300,12 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lucide-react@1.56.0(@types/react@19.3.0)(react@19.3.1):
+ dependencies:
+ react: 19.3.1
+ optionalDependencies:
+ '@types/react': 19.3.0
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0