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.
185 lines
6.2 KiB
TypeScript
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>
|
|
)
|
|
}
|