feat: rebrand to Mahindra, and ground the models in the real line-up
Demo is for Mahindra, so the console carries their branding and their cars: BE 6, XEV 9e and XEV 9S replace the placeholder models, in step with the voice agent's own brief — a model offered in the dropdown that the agent has never heard of is a call where she has to improvise. Brand, persona (Meera) and the demo credentials move into config.ts rather than being scattered through the pages. Accent colour follows. Login is simplified: credentials are prefilled and the button takes focus, so signing in during a demo is one keystroke. Both fields stay editable — this is a dev demo tenant, not a real account. useState is explicitly typed <string>: DEMO_LOGIN is `as const`, so inferring from it narrowed the state to a literal type and setEmail/setPassword refused any other value. tsc -b and vite build both clean.
This commit is contained in:
parent
213f3e49ce
commit
80a85e6764
10
README.md
10
README.md
@ -1,14 +1,14 @@
|
||||
# EV Sales Desk Console
|
||||
# Mahindra EV Sales Desk Console
|
||||
|
||||
Two-screen operator console for the **live call-transfer demo**: place an
|
||||
outbound AI sales call, and watch it transfer to a human colleague mid-call.
|
||||
outbound Mahindra EV sales call, and watch it transfer to a human colleague mid-call.
|
||||
|
||||
Backs onto Zino app **583** (org 900000104, `dev`). Built on the frontgen React
|
||||
Backs onto Zino app **583** (org 900000104, `dev`). Line-up: **BE 6**, **XEV 9e**, **XEV 9S**. Built on the frontgen React
|
||||
scaffold — Vite + Tailwind + React Router + TanStack Query + `src/zino-sdk`.
|
||||
|
||||
## The two screens
|
||||
|
||||
**Place a call** — lead name, model, and the two numbers: the **customer** Aria
|
||||
**Place a call** — lead name, model, and the two numbers: the **customer** Meera
|
||||
dials, and the **colleague** the transfer bridges to. Submitting starts a
|
||||
`Lead Outreach` workflow instance; its trigger places the call server-side.
|
||||
|
||||
@ -23,7 +23,7 @@ echo 'VITE_ZINO_API_URL=https://dev-apps.getzino.in' >> .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Sign in with an app user of app 583 (e.g. `salesops@verta-motors.demo`).
|
||||
Sign in with an app user of app 583 (e.g. `salesops@mahindra-ev.demo` / `Mahindra@2026` (prefilled on the login screen)).
|
||||
|
||||
`VITE_ZINO_API_URL` is only read from `.env` in **dev**. A production build
|
||||
requires the `config.js` the server writes at placement time, and throws if it
|
||||
|
||||
@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { LogOut, PhoneOutgoing, Radio, Zap } from 'lucide-react'
|
||||
import { useZino } from '../zino-sdk'
|
||||
import { BRAND } from '../config'
|
||||
|
||||
const navItem =
|
||||
'inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors'
|
||||
@ -31,12 +32,12 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
<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">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-red-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 className="text-sm font-semibold">{BRAND.name}</div>
|
||||
<div className="text-xs text-slate-500">{BRAND.product}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -46,7 +47,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
className={({ isActive }) =>
|
||||
`${navItem} ${
|
||||
isActive
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
@ -59,7 +60,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
className={({ isActive }) =>
|
||||
`${navItem} ${
|
||||
isActive
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
|
||||
@ -37,5 +37,22 @@ export const FIELDS = {
|
||||
*/
|
||||
export const END_REASON_HANDOVER = 'handover'
|
||||
|
||||
/** Models the agent is briefed on. */
|
||||
export const MODELS = ['Verta One', 'Verta SUV', 'Verta Basic'] as const
|
||||
/**
|
||||
* Mahindra's born-electric SUVs, matching the line-up in Meera's brief. Keep
|
||||
* this list and the agent's `goal` in step — a model offered here that the agent
|
||||
* has never heard of is a call where she has to improvise.
|
||||
*/
|
||||
export const MODELS = ['BE 6', 'XEV 9e', 'XEV 9S'] as const
|
||||
|
||||
export const BRAND = {
|
||||
name: 'Mahindra',
|
||||
product: 'EV Sales Desk',
|
||||
/** The voice agent's persona, as the customer hears it. */
|
||||
agentName: 'Meera',
|
||||
} as const
|
||||
|
||||
/** Prefilled on the login screen — this is a demo tenant, not a real account. */
|
||||
export const DEMO_LOGIN = {
|
||||
email: 'salesops@mahindra-ev.demo',
|
||||
password: 'Mahindra@2026',
|
||||
} as const
|
||||
|
||||
@ -153,7 +153,7 @@ function OutcomePill({ row }: { row: LeadRow }) {
|
||||
}
|
||||
if (status === 'dialled') {
|
||||
return (
|
||||
<Pill className="bg-emerald-50 text-emerald-700">
|
||||
<Pill className="bg-red-50 text-red-700">
|
||||
<CircleDashed size={12} className="animate-spin" /> On call
|
||||
</Pill>
|
||||
)
|
||||
@ -197,7 +197,7 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
|
||||
<div className="mt-4 rounded-xl border border-indigo-100 bg-indigo-50 p-3">
|
||||
<p className="text-xs font-semibold text-indigo-900">Transferred</p>
|
||||
<p className="mt-1 text-xs text-indigo-800">
|
||||
Aria bridged{' '}
|
||||
Meera bridged{' '}
|
||||
<span className="font-mono">{str(row[FIELDS.HANDOVER_NUMBER]) || 'the colleague'}</span>{' '}
|
||||
into the call and stayed on as a silent listener, briefing them as the
|
||||
conversation continued.
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { useZino } from '../zino-sdk'
|
||||
import { BRAND, DEMO_LOGIN } from '../config'
|
||||
|
||||
export default function Login() {
|
||||
const { auth, setUser } = useZino()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
// Prefilled: this is a demo tenant on dev, and typing credentials in front of
|
||||
// an audience is friction with no upside. Both fields stay editable.
|
||||
const [email, setEmail] = useState<string>(DEMO_LOGIN.email)
|
||||
const [password, setPassword] = useState<string>(DEMO_LOGIN.password)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@ -29,15 +32,16 @@ export default function Login() {
|
||||
onSubmit={submit}
|
||||
className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-lg bg-emerald-600 text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-lg bg-red-600 text-white">
|
||||
<Zap size={18} />
|
||||
</span>
|
||||
<div className="leading-tight">
|
||||
<div className="font-semibold">Verta Motors</div>
|
||||
<div className="text-xs text-slate-500">EV Sales Desk</div>
|
||||
<div className="font-semibold">{BRAND.name}</div>
|
||||
<div className="text-xs text-slate-500">{BRAND.product}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-6 mt-3 text-xs text-slate-500">Sign in to place and monitor calls.</p>
|
||||
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">Email</label>
|
||||
<input
|
||||
@ -45,8 +49,7 @@ export default function Login() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="mb-4 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
|
||||
className="mb-4 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-red-500 focus:ring-2 focus:ring-red-100"
|
||||
/>
|
||||
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">Password</label>
|
||||
@ -55,7 +58,7 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="mb-5 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
|
||||
className="mb-5 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-red-500 focus:ring-2 focus:ring-red-100"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
@ -65,7 +68,8 @@ export default function Login() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded-lg bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-emerald-700 disabled:opacity-50"
|
||||
autoFocus
|
||||
className="w-full rounded-lg bg-red-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ArrowRight, PhoneForwarded, PhoneOutgoing, User } from 'lucide-react'
|
||||
import { useZino } from '../zino-sdk'
|
||||
import { MODELS } from '../config'
|
||||
import { BRAND, MODELS } from '../config'
|
||||
import { isPlausiblePhone, placeCall, toE164 } from '../api'
|
||||
|
||||
export default function PlaceCall() {
|
||||
@ -51,9 +51,9 @@ export default function PlaceCall() {
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h1 className="text-xl font-semibold">Place a call</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Aria calls the customer and qualifies the lead. When the caller asks for a person —
|
||||
or pushes on price — she announces a transfer and bridges the colleague below into
|
||||
the same call, then keeps listening to brief them.
|
||||
{BRAND.agentName} calls the customer and qualifies the {BRAND.name} EV lead. When the
|
||||
caller asks for a person — or pushes on price — she announces a transfer and bridges
|
||||
the colleague below into the same call, then keeps listening to brief them.
|
||||
</p>
|
||||
|
||||
<form
|
||||
@ -69,7 +69,7 @@ export default function PlaceCall() {
|
||||
value={leadName}
|
||||
onChange={(e) => setLeadName(e.target.value)}
|
||||
placeholder="Yashas"
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-red-500 focus:ring-2 focus:ring-red-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -80,7 +80,7 @@ export default function PlaceCall() {
|
||||
<select
|
||||
value={modelInterest}
|
||||
onChange={(e) => setModelInterest(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm outline-none focus:border-red-500 focus:ring-2 focus:ring-red-100"
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
@ -94,10 +94,10 @@ export default function PlaceCall() {
|
||||
<div className="grid items-start gap-4 rounded-xl bg-slate-50 p-4 sm:grid-cols-[1fr_auto_1fr]">
|
||||
<PhoneBox
|
||||
icon={<PhoneOutgoing size={13} />}
|
||||
label="Customer — Aria calls this"
|
||||
label={`Customer — ${BRAND.agentName} calls this`}
|
||||
value={leadPhone}
|
||||
onChange={setLeadPhone}
|
||||
accent="emerald"
|
||||
accent="red"
|
||||
/>
|
||||
|
||||
<div className="hidden self-center pt-6 text-slate-400 sm:block">
|
||||
@ -128,7 +128,7 @@ export default function PlaceCall() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<PhoneOutgoing size={16} />
|
||||
{busy ? 'Placing call…' : 'Place call'}
|
||||
@ -153,13 +153,13 @@ function PhoneBox({
|
||||
label: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
accent: 'emerald' | 'indigo'
|
||||
accent: 'red' | 'indigo'
|
||||
}) {
|
||||
const normalised = toE164(value)
|
||||
const valid = isPlausiblePhone(value)
|
||||
const ring =
|
||||
accent === 'emerald'
|
||||
? 'focus:border-emerald-500 focus:ring-emerald-100'
|
||||
accent === 'red'
|
||||
? 'focus:border-red-500 focus:ring-red-100'
|
||||
: 'focus:border-indigo-500 focus:ring-indigo-100'
|
||||
|
||||
return (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user