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:
Yashas 2026-08-26 23:36:11 +05:30
parent 213f3e49ce
commit 80a85e6764
6 changed files with 58 additions and 36 deletions

View File

@ -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

View File

@ -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'
}`
}

View File

@ -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

View File

@ -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.

View File

@ -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>

View File

@ -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 (