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
|
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`.
|
scaffold — Vite + Tailwind + React Router + TanStack Query + `src/zino-sdk`.
|
||||||
|
|
||||||
## The two screens
|
## 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
|
dials, and the **colleague** the transfer bridges to. Submitting starts a
|
||||||
`Lead Outreach` workflow instance; its trigger places the call server-side.
|
`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
|
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
|
`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
|
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 { NavLink } from 'react-router-dom'
|
||||||
import { LogOut, PhoneOutgoing, Radio, Zap } from 'lucide-react'
|
import { LogOut, PhoneOutgoing, Radio, Zap } from 'lucide-react'
|
||||||
import { useZino } from '../zino-sdk'
|
import { useZino } from '../zino-sdk'
|
||||||
|
import { BRAND } from '../config'
|
||||||
|
|
||||||
const navItem =
|
const navItem =
|
||||||
'inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors'
|
'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">
|
<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="mx-auto flex max-w-6xl items-center gap-6 px-6 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<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} />
|
<Zap size={17} />
|
||||||
</span>
|
</span>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="text-sm font-semibold">Verta Motors</div>
|
<div className="text-sm font-semibold">{BRAND.name}</div>
|
||||||
<div className="text-xs text-slate-500">EV Sales Desk</div>
|
<div className="text-xs text-slate-500">{BRAND.product}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`${navItem} ${
|
`${navItem} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-emerald-50 text-emerald-700'
|
? 'bg-red-50 text-red-700'
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@ -59,7 +60,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`${navItem} ${
|
`${navItem} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-emerald-50 text-emerald-700'
|
? 'bg-red-50 text-red-700'
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,5 +37,22 @@ export const FIELDS = {
|
|||||||
*/
|
*/
|
||||||
export const END_REASON_HANDOVER = 'handover'
|
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') {
|
if (status === 'dialled') {
|
||||||
return (
|
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
|
<CircleDashed size={12} className="animate-spin" /> On call
|
||||||
</Pill>
|
</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">
|
<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="text-xs font-semibold text-indigo-900">Transferred</p>
|
||||||
<p className="mt-1 text-xs text-indigo-800">
|
<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>{' '}
|
<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
|
into the call and stayed on as a silent listener, briefing them as the
|
||||||
conversation continued.
|
conversation continued.
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Zap } from 'lucide-react'
|
import { Zap } from 'lucide-react'
|
||||||
import { useZino } from '../zino-sdk'
|
import { useZino } from '../zino-sdk'
|
||||||
|
import { BRAND, DEMO_LOGIN } from '../config'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { auth, setUser } = useZino()
|
const { auth, setUser } = useZino()
|
||||||
const [email, setEmail] = useState('')
|
// Prefilled: this is a demo tenant on dev, and typing credentials in front of
|
||||||
const [password, setPassword] = useState('')
|
// 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 [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
@ -29,15 +32,16 @@ export default function Login() {
|
|||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="grid h-9 w-9 place-items-center rounded-lg bg-emerald-600 text-white">
|
<span className="grid h-9 w-9 place-items-center rounded-lg bg-red-600 text-white">
|
||||||
<Zap size={18} />
|
<Zap size={18} />
|
||||||
</span>
|
</span>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="font-semibold">Verta Motors</div>
|
<div className="font-semibold">{BRAND.name}</div>
|
||||||
<div className="text-xs text-slate-500">EV Sales Desk</div>
|
<div className="text-xs text-slate-500">{BRAND.product}</div>
|
||||||
</div>
|
</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>
|
<label className="mb-1 block text-xs font-medium text-slate-600">Email</label>
|
||||||
<input
|
<input
|
||||||
@ -45,8 +49,7 @@ export default function Login() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus
|
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"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-600">Password</label>
|
<label className="mb-1 block text-xs font-medium text-slate-600">Password</label>
|
||||||
@ -55,7 +58,7 @@ export default function Login() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
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 && (
|
{error && (
|
||||||
@ -65,7 +68,8 @@ export default function Login() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
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'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ArrowRight, PhoneForwarded, PhoneOutgoing, User } from 'lucide-react'
|
import { ArrowRight, PhoneForwarded, PhoneOutgoing, User } from 'lucide-react'
|
||||||
import { useZino } from '../zino-sdk'
|
import { useZino } from '../zino-sdk'
|
||||||
import { MODELS } from '../config'
|
import { BRAND, MODELS } from '../config'
|
||||||
import { isPlausiblePhone, placeCall, toE164 } from '../api'
|
import { isPlausiblePhone, placeCall, toE164 } from '../api'
|
||||||
|
|
||||||
export default function PlaceCall() {
|
export default function PlaceCall() {
|
||||||
@ -51,9 +51,9 @@ export default function PlaceCall() {
|
|||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
<h1 className="text-xl font-semibold">Place a call</h1>
|
<h1 className="text-xl font-semibold">Place a call</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
Aria calls the customer and qualifies the lead. When the caller asks for a person —
|
{BRAND.agentName} calls the customer and qualifies the {BRAND.name} EV lead. When the
|
||||||
or pushes on price — she announces a transfer and bridges the colleague below into
|
caller asks for a person — or pushes on price — she announces a transfer and bridges
|
||||||
the same call, then keeps listening to brief them.
|
the colleague below into the same call, then keeps listening to brief them.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@ -69,7 +69,7 @@ export default function PlaceCall() {
|
|||||||
value={leadName}
|
value={leadName}
|
||||||
onChange={(e) => setLeadName(e.target.value)}
|
onChange={(e) => setLeadName(e.target.value)}
|
||||||
placeholder="Yashas"
|
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>
|
</div>
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ export default function PlaceCall() {
|
|||||||
<select
|
<select
|
||||||
value={modelInterest}
|
value={modelInterest}
|
||||||
onChange={(e) => setModelInterest(e.target.value)}
|
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) => (
|
{MODELS.map((m) => (
|
||||||
<option key={m} value={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]">
|
<div className="grid items-start gap-4 rounded-xl bg-slate-50 p-4 sm:grid-cols-[1fr_auto_1fr]">
|
||||||
<PhoneBox
|
<PhoneBox
|
||||||
icon={<PhoneOutgoing size={13} />}
|
icon={<PhoneOutgoing size={13} />}
|
||||||
label="Customer — Aria calls this"
|
label={`Customer — ${BRAND.agentName} calls this`}
|
||||||
value={leadPhone}
|
value={leadPhone}
|
||||||
onChange={setLeadPhone}
|
onChange={setLeadPhone}
|
||||||
accent="emerald"
|
accent="red"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="hidden self-center pt-6 text-slate-400 sm:block">
|
<div className="hidden self-center pt-6 text-slate-400 sm:block">
|
||||||
@ -128,7 +128,7 @@ export default function PlaceCall() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSubmit}
|
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} />
|
<PhoneOutgoing size={16} />
|
||||||
{busy ? 'Placing call…' : 'Place call'}
|
{busy ? 'Placing call…' : 'Place call'}
|
||||||
@ -153,13 +153,13 @@ function PhoneBox({
|
|||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
accent: 'emerald' | 'indigo'
|
accent: 'red' | 'indigo'
|
||||||
}) {
|
}) {
|
||||||
const normalised = toE164(value)
|
const normalised = toE164(value)
|
||||||
const valid = isPlausiblePhone(value)
|
const valid = isPlausiblePhone(value)
|
||||||
const ring =
|
const ring =
|
||||||
accent === 'emerald'
|
accent === 'red'
|
||||||
? 'focus:border-emerald-500 focus:ring-emerald-100'
|
? 'focus:border-red-500 focus:ring-red-100'
|
||||||
: 'focus:border-indigo-500 focus:ring-indigo-100'
|
: 'focus:border-indigo-500 focus:ring-indigo-100'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user