ev-sales-desk-console/src/pages/PlaceCall.tsx
Yashas 80a85e6764 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.
2026-08-26 23:36:19 +05:30

185 lines
6.2 KiB
TypeScript

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowRight, PhoneForwarded, PhoneOutgoing, User } from 'lucide-react'
import { useZino } from '../zino-sdk'
import { BRAND, MODELS } from '../config'
import { isPlausiblePhone, placeCall, toE164 } from '../api'
export default function PlaceCall() {
const { client } = useZino()
const navigate = useNavigate()
const [leadName, setLeadName] = useState('')
const [modelInterest, setModelInterest] = useState<string>(MODELS[0])
const [leadPhone, setLeadPhone] = useState('')
const [handoverNumber, setHandoverNumber] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Both numbers joining the same conference would put the tester on hold with
// themselves — catch it here rather than on a live call.
const sameNumber =
leadPhone && handoverNumber && toE164(leadPhone) === toE164(handoverNumber)
const canSubmit =
leadName.trim() &&
isPlausiblePhone(leadPhone) &&
isPlausiblePhone(handoverNumber) &&
!sameNumber &&
!busy
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
setBusy(true)
setError('')
try {
await placeCall(client, {
leadName: leadName.trim(),
leadPhone: toE164(leadPhone),
modelInterest,
handoverNumber: toE164(handoverNumber),
})
navigate('/monitor')
} catch (err: any) {
setError(err?.message || 'Could not place the call')
setBusy(false)
}
}
return (
<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">
{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
onSubmit={submit}
className="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-slate-600">
<User size={13} /> Lead name
</label>
<input
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-red-500 focus:ring-2 focus:ring-red-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
Model of interest
</label>
<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-red-500 focus:ring-2 focus:ring-red-100"
>
{MODELS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
</div>
<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 — ${BRAND.agentName} calls this`}
value={leadPhone}
onChange={setLeadPhone}
accent="red"
/>
<div className="hidden self-center pt-6 text-slate-400 sm:block">
<ArrowRight size={18} />
</div>
<PhoneBox
icon={<PhoneForwarded size={13} />}
label="Colleague — transfer bridges to this"
value={handoverNumber}
onChange={setHandoverNumber}
accent="indigo"
/>
</div>
{sameNumber && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
Both numbers are the same the transfer would conference you with yourself.
Use two different phones.
</p>
)}
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700">{error}</p>
)}
<div className="flex items-center gap-3 pt-1">
<button
type="submit"
disabled={!canSubmit}
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'}
</button>
<span className="text-xs text-slate-500">
The customer's phone rings within a few seconds.
</span>
</div>
</form>
</div>
)
}
function PhoneBox({
icon,
label,
value,
onChange,
accent,
}: {
icon: React.ReactNode
label: string
value: string
onChange: (v: string) => void
accent: 'red' | 'indigo'
}) {
const normalised = toE164(value)
const valid = isPlausiblePhone(value)
const ring =
accent === 'red'
? 'focus:border-red-500 focus:ring-red-100'
: 'focus:border-indigo-500 focus:ring-indigo-100'
return (
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-slate-600">
{icon} {label}
</label>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="+91 98765 43210"
inputMode="tel"
className={`w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm outline-none focus:ring-2 ${ring}`}
/>
{/* Show what will actually be dialled — the operator types loosely, the
carrier needs E.164, and a silent rewrite is worth surfacing. */}
<p className="mt-1 h-4 text-[11px] text-slate-500">
{value && (valid ? `Dials as ${normalised}` : 'Needs 10 digits, or +country code')}
</p>
</div>
)
}