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 (focus trap + Esc for free) as a centered modal or a right-hand drawer. +import { X } from 'lucide-react'; import { useEffect, useRef, type ReactNode } from 'react'; import { Button } from './ui.tsx'; @@ -43,7 +44,7 @@ export function Dialog({ {title}
{children}
diff --git a/apps/web/src/components/SoundPicker.tsx b/apps/web/src/components/SoundPicker.tsx index a02b0fc..57cca16 100644 --- a/apps/web/src/components/SoundPicker.tsx +++ b/apps/web/src/components/SoundPicker.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // SoundPicker (05 §3): pick a recorded prompt, play it, or upload a new one right here. +import { Play } from 'lucide-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useId, useRef } from 'react'; import { soundsApi } from '../api/client.ts'; @@ -63,7 +64,7 @@ export function SoundPicker({ void a.play().catch(() => undefined); }} > - ▶ + ); } diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 7671fe7..3a97d9c 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -3,7 +3,8 @@ import { useId, type ComponentProps, type ReactNode } from 'react'; import { APP_NAME } from '@selfphoneng/shared'; -const focus = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus'; +export const focusRing = + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus'; const variants = { primary: 'bg-primary text-white hover:bg-primary-hover disabled:bg-primary/50', @@ -30,30 +31,40 @@ export function Button({ return (
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' && ( <> - - - - + /> + 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