Files
doormilxpress_astryx/src/components/assistant/customerFlow.js

143 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }));