import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions'; // ==============================|| Doormile AI — conversational create-customer ||============================== // // // Asks for one field at a time, then shows what it will send and waits for // Submit. // // ---- Why this lives here and not in the router ------------------------------ // // A first attempt at this shipped and broke immediately: the operator typed // "create customer", was asked for a phone number, replied "8494948494", and // got "I can't answer that one yet". // // The cause was architectural, not a typo. `answerQuestion` picks an intent by // MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was // routed to the fallback and discarded. Threading a partial draft through the // router's `context` didn't help, because the router had already failed to // choose an intent before the draft was ever consulted. // // So the conversation is owned by the PANEL, which checks for an active flow // BEFORE calling the router at all. A reply mid-flow is never routed. That is // the only arrangement where "8494948494" can't be misread as a question. // // Field set and validation mirror pages/nearle/clients/createCustomer.js: // name and a 10-digit phone are required, everything else is optional and // skippable. const PHONE_RE = /^\d{10}$/; const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/; // "skip", "none", "no", "-" all mean "leave it blank". Without this the // operator has no way past an optional field except inventing a value. const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i; export const isSkip = (text) => SKIP_RE.test(String(text || '').trim()); // Ordered. `ask` is the question; `apply` folds the answer into the draft; // `validate` returns an error string to re-ask with, or null to accept. export const CUSTOMER_STEPS = [ { field: 'name', ask: 'What’s the customer’s name?', required: true, apply: (draft, text) => { const [firstname, ...rest] = String(text).trim().split(/\s+/); return { ...draft, firstname, lastname: rest.join(' ') || undefined }; }, validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.') }, { field: 'phone', ask: 'And their 10-digit mobile number?', required: true, apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }), // Validated against the digits only, so "98765 43210" and "+91 9876543210" // are both accepted rather than rejected on formatting. validate: (text) => { const digits = String(text) .replace(/\D/g, '') .replace(/^91(?=\d{10}$)/, ''); return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.'; } }, { field: 'email', ask: 'Email address? (say “skip” if there isn’t one)', apply: (draft, text) => ({ ...draft, email: String(text).trim() }), validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.') }, { field: 'address', ask: 'Address? (or “skip”)', apply: (draft, text) => ({ ...draft, address: String(text).trim() }) }, { field: 'city', ask: 'City? (or “skip”)', apply: (draft, text) => ({ ...draft, city: String(text).trim() }) }, { field: 'postcode', ask: 'Postcode? (or “skip”)', apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') }) } ]; // Starts the flow, pre-filling anything already said in the opening message — // "create a customer Ramesh 9876543210" should not then ask for the name and // the phone it was just given. export const startCustomerFlow = (text) => { const draft = parseCustomerDraft(text); return advance({ kind: 'createCustomer', step: 0, draft }); }; // Moves to the next step that still needs an answer. Returns either a question // to ask, or the finished proposal. export const advance = (flow) => { let { step } = flow; const { draft } = flow; while (step < CUSTOMER_STEPS.length) { const s = CUSTOMER_STEPS[step]; const already = s.field === 'name' ? draft.firstname : draft[s.field]; if (already) { step += 1; continue; } return { flow: { ...flow, step }, ask: s.ask, done: false }; } // Every step visited. The required check is repeated here rather than // trusted from the walk above, so a skipped-but-required field can never // reach a proposal. const { ok, missing } = validateCustomerDraft(draft); if (!ok) { return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false }; } return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) }; }; // Applies one answer. Returns the next question, or the completed proposal, or // a re-ask when the answer didn't validate. export const answerStep = (flow, text) => { const s = CUSTOMER_STEPS[flow.step]; if (!s) return advance(flow); if (isSkip(text)) { if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false }; // Skipped optional field: step past it without writing anything, so the // payload builder drops it rather than sending an empty string. return advance({ ...flow, step: flow.step + 1 }); } const error = s.validate?.(text); if (error) return { flow, ask: error, done: false, retry: true }; return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) }); }; // Human-readable summary of what will be sent, for the confirm step. export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));