Two screens over Zino app 583:
Place a call — lead name, model, and the two numbers that matter: the
customer Aria dials, and the colleague the transfer bridges to. Submits the
Add Lead workflow activity as the signed-in user; the workflow's trigger
places the call server-side, so the zvk_ agent key never reaches the browser.
Live monitor — polls the leads record view every 3s. "Transferred to human"
is call_end_reason == handover, called out on its own because it is the thing
the demo exists to show.
src/api.ts deliberately bypasses two SDK methods; both are SDK bugs:
- workflows.startWorkflow() posts workflow_id, but core's StartRequest reads
workflow_uuid and rejects the call.
- views.getTabularView() does GET /view/recordview, the legacy unscoped route;
the app-scoped one is POST /app/{id}/view/recordview with the query in the
body.
Auth still goes through the SDK, so there is one token and one session.
Base URL is the root, not an app path: login is /usr/login while the app APIs
are under /app/583, so they sit at different depths.
Frontgen scaffold generated by the service's own ScaffoldReactProject, so the
build pipeline conventions (relative base, BASE_HREF placeholder, runtime
config.js) are byte-identical rather than reimplemented.
vite build + tsc -b both clean.
89 lines
2.9 KiB
TypeScript
89 lines
2.9 KiB
TypeScript
import type { ReactNode } from 'react'
|
|
import { NavLink } from 'react-router-dom'
|
|
import { LogOut, PhoneOutgoing, Radio, Zap } from 'lucide-react'
|
|
import { useZino } from '../zino-sdk'
|
|
|
|
const navItem =
|
|
'inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors'
|
|
|
|
export default function Layout({ children }: { children: ReactNode }) {
|
|
const { auth, client } = useZino()
|
|
|
|
const signOut = () => {
|
|
auth.logout()
|
|
// The router is inside the auth gate in App, so a reload is the simplest
|
|
// way back to the login screen without threading state through context.
|
|
window.location.reload()
|
|
}
|
|
|
|
const email = (() => {
|
|
try {
|
|
const t = client.getToken()
|
|
if (!t) return ''
|
|
return JSON.parse(atob(t.split('.')[1])).email ?? ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
})()
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
|
<header className="sticky top-0 z-10 border-b border-slate-200 bg-white">
|
|
<div className="mx-auto flex max-w-6xl items-center gap-6 px-6 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-emerald-600 text-white">
|
|
<Zap size={17} />
|
|
</span>
|
|
<div className="leading-tight">
|
|
<div className="text-sm font-semibold">Verta Motors</div>
|
|
<div className="text-xs text-slate-500">EV Sales Desk</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="flex items-center gap-1">
|
|
<NavLink
|
|
to="/place-call"
|
|
className={({ isActive }) =>
|
|
`${navItem} ${
|
|
isActive
|
|
? 'bg-emerald-50 text-emerald-700'
|
|
: 'text-slate-600 hover:bg-slate-100'
|
|
}`
|
|
}
|
|
>
|
|
<PhoneOutgoing size={16} />
|
|
Place a call
|
|
</NavLink>
|
|
<NavLink
|
|
to="/monitor"
|
|
className={({ isActive }) =>
|
|
`${navItem} ${
|
|
isActive
|
|
? 'bg-emerald-50 text-emerald-700'
|
|
: 'text-slate-600 hover:bg-slate-100'
|
|
}`
|
|
}
|
|
>
|
|
<Radio size={16} />
|
|
Live monitor
|
|
</NavLink>
|
|
</nav>
|
|
|
|
<div className="ml-auto flex items-center gap-3">
|
|
{email && <span className="text-xs text-slate-500">{email}</span>}
|
|
<button
|
|
onClick={signOut}
|
|
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-100"
|
|
>
|
|
<LogOut size={14} />
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto max-w-6xl px-6 py-8">{children}</main>
|
|
</div>
|
|
)
|
|
}
|