Same change as the console's masthead, in the two places this app draws the brand: the app bar and the drawer head. The circled "Z" and the letter-spaced wordmark were type imitating a logo; the asset is the logo, and it carries both lines of the wordmark on its own plate, so nothing is set beside it. 26px on a 56px bar leaves the burger a thumb's width to its left and the avatar to its right at 390px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
8.6 KiB
JavaScript
185 lines
8.6 KiB
JavaScript
import { useState } from 'react'
|
||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||
import { ASK_DESK_ROLES, NAV_GROUPS, STAGES } from '../api/config.js'
|
||
import { rolesOf, visibleStages } from '../api/permissions.js'
|
||
import { useStageCounts } from '../api/portfolio.jsx'
|
||
import { useZino } from '../api/provider.jsx'
|
||
import AskDesk from '../components/AskDesk.jsx'
|
||
import logo from '../assets/brand/zurich_logo.webp'
|
||
|
||
|
||
/**
|
||
* The frame: a navy bar that stays put, and a drawer behind the hamburger.
|
||
*
|
||
* On a phone the queues cannot live beside the content, and stacked above it
|
||
* they cost a screenful before the first lead. So they go in a drawer, and the
|
||
* bar carries the only two things worth permanent space: where you are, and
|
||
* who you are.
|
||
*/
|
||
|
||
/** Two letters for the avatar: a name if we have one, else the local part. */
|
||
function initials(user) {
|
||
const from = user?.name || user?.email || ''
|
||
const parts = from.replace(/@.*$/, '').split(/[\s._-]+/).filter(Boolean)
|
||
if (!parts.length) return '?'
|
||
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||
}
|
||
|
||
/** The badge colour is the colour of whoever is being waited on. */
|
||
function tone(stage) {
|
||
if (stage.kind === 'needs') return 'red'
|
||
if (stage.kind === 'customer') return 'amber'
|
||
return 'grey'
|
||
}
|
||
|
||
/** A queue's icon, chosen for what it is waiting on rather than decoration. */
|
||
function NavIcon({ kind }) {
|
||
if (kind === 'needs') {
|
||
return <svg viewBox="0 0 20 20" fill="none"><path d="M6 2.5h5.5L15 6v11.5H6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="M11 2.5V6h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
||
}
|
||
if (kind === 'customer') {
|
||
return <svg viewBox="0 0 20 20" fill="none"><path d="M3 4.5h14v9h-8l-3.5 3v-3H3z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
||
}
|
||
if (kind === 'waiting') {
|
||
return <svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" strokeWidth="1.5"/><path d="M10 6v4.2l2.6 1.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
||
}
|
||
return <svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5 8 14l8-8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
}
|
||
|
||
export default function Shell() {
|
||
const { user, signOut } = useZino()
|
||
const navigate = useNavigate()
|
||
const roles = rolesOf(user)
|
||
const stages = visibleStages(roles)
|
||
const { counts, ready } = useStageCounts()
|
||
const [open, setOpen] = useState(false)
|
||
const [me, setMe] = useState(false)
|
||
// The support desk reads the WHOLE book — every partner, every premium — so
|
||
// it is an ops tool rather than something a partner agent may open. The
|
||
// platform enforces that too (the desk is assigned per user); this only
|
||
// decides whether the entry is drawn.
|
||
const [desk, setDesk] = useState(false)
|
||
const canAskDesk = ASK_DESK_ROLES.some((r) => roles.includes(r))
|
||
|
||
const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
|
||
const openTotal = STAGES.filter((s) => s.kind !== 'end').reduce((t, s) => t + count(s).total, 0)
|
||
|
||
const row = (s) => {
|
||
const c = count(s)
|
||
const n = s.kind === 'end' ? c.total : c.waiting
|
||
const t = tone(s)
|
||
return (
|
||
<NavLink
|
||
key={s.uid} to={`/stage/${s.uid}`} onClick={() => setOpen(false)}
|
||
className={({ isActive }) => 'nav nav--' + (s.kind === 'customer' ? 'teal' : s.kind === 'needs' ? 'red' : 'blue') + (isActive ? ' is-on' : '')}
|
||
>
|
||
<span className="nav__i" aria-hidden="true"><NavIcon kind={s.kind} /></span>
|
||
<span className="nav__t">{s.need ?? s.name}</span>
|
||
{ready ? <span className={`nav__n nav__n--${n ? t : 'grey'}`}>{n}</span> : null}
|
||
</NavLink>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="shell">
|
||
<header className="appbar">
|
||
<button type="button" className="appbar__burger" aria-label="Open the menu" onClick={() => setOpen(true)}>
|
||
<svg viewBox="0 0 22 22" fill="none"><path d="M3 6h16M3 11h16M3 16h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>
|
||
</button>
|
||
{/* The real mark rather than type set to look like it — see the
|
||
console's masthead. Bundled from src/assets, so the URL is
|
||
rewritten relative and resolves under the sub-path mount. */}
|
||
<img className="appbar__logo" src={logo} alt="Zurich Kotak General Insurance" />
|
||
<button type="button" className="appbar__me" aria-label="Your account" onClick={() => setMe(true)}>
|
||
{initials(user)}
|
||
</button>
|
||
</header>
|
||
|
||
<main>
|
||
<Outlet />
|
||
</main>
|
||
|
||
{open ? (
|
||
<>
|
||
<div className="scrim" role="presentation" onClick={() => setOpen(false)} />
|
||
<nav className="drawer" aria-label="Queues">
|
||
<div className="drawer__head">
|
||
<button type="button" className="drawer__x" aria-label="Close the menu" onClick={() => setOpen(false)}>
|
||
<svg viewBox="0 0 20 20" fill="none"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round"/></svg>
|
||
</button>
|
||
<img className="appbar__logo" src={logo} alt="Zurich Kotak General Insurance" />
|
||
</div>
|
||
<div className="drawer__body">
|
||
<NavLink to="/" end onClick={() => setOpen(false)} className={({ isActive }) => 'nav' + (isActive ? ' is-on' : '')}>
|
||
<span className="nav__i" aria-hidden="true">
|
||
<svg viewBox="0 0 20 20" fill="none"><path d="M3.5 3.5h6v6h-6zM10.5 3.5h6v6h-6zM3.5 10.5h6v6h-6zM10.5 10.5h6v6h-6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
||
</span>
|
||
<span className="nav__t">Overview</span>
|
||
</NavLink>
|
||
<NavLink to="/stage/all" onClick={() => setOpen(false)} className={({ isActive }) => 'nav' + (isActive ? ' is-on' : '')}>
|
||
<span className="nav__i" aria-hidden="true">
|
||
<svg viewBox="0 0 20 20" fill="none"><circle cx="7" cy="7" r="2.6" stroke="currentColor" strokeWidth="1.5"/><path d="M2.5 16a4.5 4.5 0 0 1 9 0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><path d="M13 5.2a2.6 2.6 0 0 1 0 5M14.5 16a4.4 4.4 0 0 0-2-3.7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
||
</span>
|
||
<span className="nav__t">All leads</span>
|
||
{ready ? <span className="nav__n">{openTotal}</span> : null}
|
||
</NavLink>
|
||
|
||
{NAV_GROUPS.map((group) => {
|
||
const inGroup = stages.filter((s) => group.kinds.includes(s.kind))
|
||
if (!inGroup.length) return null
|
||
return (
|
||
<div key={group.label}>
|
||
<h6 className="drawer__h">{group.label}</h6>
|
||
{inGroup.map(row)}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{canAskDesk ? (
|
||
<div>
|
||
<h6 className="drawer__h">Ask</h6>
|
||
<button
|
||
type="button" className="nav nav--desk"
|
||
onClick={() => { setOpen(false); setDesk(true) }}
|
||
>
|
||
<span className="nav__i" aria-hidden="true">
|
||
<svg viewBox="0 0 20 20" fill="none"><path d="M3 4.5h14v9h-8l-3.5 3v-3H3z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
||
</span>
|
||
<span className="nav__t">Ask the desk</span>
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</nav>
|
||
</>
|
||
) : null}
|
||
|
||
{me ? (
|
||
<>
|
||
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setMe(false)} />
|
||
<div className="sheet" role="dialog" aria-label="Your account">
|
||
<span className="sheet__grab" aria-hidden="true" />
|
||
<div className="sheet__head">
|
||
<div>
|
||
<h2>{user?.name || user?.email || 'Signed in'}</h2>
|
||
<p>{user?.email}</p>
|
||
</div>
|
||
<button type="button" className="sheet__x" aria-label="Close" onClick={() => setMe(false)}>×</button>
|
||
</div>
|
||
<div className="sheet__body">
|
||
<button
|
||
type="button" className="btn btn--ghost btn--block"
|
||
onClick={() => { setMe(false); signOut(); navigate('/login') }}
|
||
>
|
||
Sign out
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
{desk ? <AskDesk onClose={() => setDesk(false)} /> : null}
|
||
</div>
|
||
)
|
||
}
|