update ui design for all pages

This commit is contained in:
2026-08-12 18:56:56 +05:30
parent 25c4ee4a6a
commit 3beb66059b
58 changed files with 7892 additions and 1891 deletions

View File

@@ -0,0 +1,455 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
import {
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
extractLocation, extractPay, extractRole,
} from './actions';
/**
* Creating a position, as a conversation.
*
* Owliver used to answer "create a bartender position" by opening the Create
* Position form with three fields filled in — which left the person who had just
* described the job in one sentence to type the rest of it into a drawer. The
* form was doing the asking, and the assistant was doing the paperwork.
*
* This module turns that round. The skill file lists the questions; this decides
* what each field name means — how an answer is read, whether it is already
* settled, and how it reads back in the summary. Same split as `actions.js`:
* Markdown declares, code interprets, and a field the code does not know is
* simply not asked about rather than being handled arbitrarily.
*
* The flow is a plain serializable object. It survives being written to
* sessionStorage between turns, and it holds no component state, so the
* conversation can be resumed exactly where it stopped.
*
* Nothing here writes. The last step returns a draft, and the panel creates the
* position with the same mutation the form uses.
*/
/* ── Reading answers ────────────────────────────────────────────────────── */
/** Title Case, for the free-text answers that name a proper noun. */
const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
/** A short free-text answer, stripped of the words around it. */
function cleanPhrase(answer, max = 60) {
const value = String(answer)
.trim()
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
.replace(/[.!?,;]+$/, '')
.replace(/\s+/g, ' ');
return value && value.length <= max ? value : null;
}
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
/** A chip that means "let me type it" rather than an answer in itself. */
const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
/**
* What each field means: when it is already settled, how an answer is read, and
* how it reads back.
*
* `parse` returns a patch for the draft, or `null` when the answer was not
* understood — which re-asks with `retry` rather than storing a guess.
*/
const FIELDS = {
role_category: {
label: 'Role',
settled: (draft) => Boolean(draft.title),
retry: 'Name the role — "bartender", "line cook", "event staff".',
parse: (answer, { roles }) => {
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
if (!role || role.length < 2) return null;
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
/* A known category files the position; anything else becomes the title and
leaves the category at its default, exactly as the form behaves. */
return known
? { role_category: known, title: known }
: { title: titleCase(role) };
},
summary: (draft) => (draft.role_category && draft.role_category !== draft.title
? `${draft.title} · ${draft.role_category}`
: draft.title),
},
location: {
label: 'Location',
settled: (draft) => Boolean(draft.location),
retry: 'Type the city or area this role is based in.',
parse: (answer) => {
if (FREE_TEXT.test(String(answer).trim())) return null;
const value = extractLocation(answer) || cleanPhrase(answer);
return value ? { location: titleCase(value) } : null;
},
summary: (draft) => draft.location,
},
pay: {
label: 'Pay',
settled: (draft) => Number(draft.pay_range_min) > 0 || Number(draft.pay_range_max) > 0,
retry: 'Give a range like "$28–$36/hr", or a single rate.',
parse: (answer) => {
const pay = extractPay(answer);
return pay ? { pay_range_min: String(pay.min), pay_range_max: String(pay.max) } : null;
},
summary: (draft) => payLabel(draft),
},
min_experience_years: {
label: 'Experience',
settled: (draft) => draft.min_experience_years !== undefined && draft.min_experience_years !== null,
retry: 'How many years — or "no minimum".',
parse: (answer) => {
const years = extractExperience(answer);
if (years !== null) return { min_experience_years: years };
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
return bare ? { min_experience_years: Number(bare[1]) } : null;
},
summary: (draft) => (draft.min_experience_years
? `${draft.min_experience_years}+ years experience`
: 'No minimum experience'),
},
english_required: {
label: 'English',
settled: (draft) => Boolean(draft.english_required),
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
parse: (answer) => {
const level = extractEnglish(answer)
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
return level ? { english_required: level } : null;
},
summary: (draft) => {
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_required);
return level ? `English: ${level.label}` : null;
},
},
certifications_required: {
label: 'Certifications',
settled: (draft) => Array.isArray(draft.certifications_required),
retry: 'Name a certification, or "none".',
parse: (answer) => {
const found = extractCertifications(answer);
if (found) return { certifications_required: found };
if (/^(?:none|no|no certifications?)$/i.test(String(answer).trim())) {
return { certifications_required: [] };
}
return null;
},
summary: (draft) => (draft.certifications_required?.length
? `Certifications: ${draft.certifications_required.join(', ')}`
: null),
},
};
/* ── Suggestions ────────────────────────────────────────────────────────── */
/**
* A step's chips.
*
* `@`-prefixed entries in the skill file resolve to the lists the application
* already owns, so the roles offered here are the roles the form offers and a
* certification added to the record's options appears in the conversation
* without this file or the skill file changing.
*/
function suggestionsFor(step, { roles }) {
const resolved = step.options.flatMap((option) => {
if (option === '@roles') return roles;
if (option === '@english') return ENGLISH_LEVELS.map((l) => l.label);
if (option === '@certifications') return CERT_OPTIONS;
return [option];
});
const capped = [...new Set(resolved)].slice(0, 6);
/* An optional question needs a way past it that is not a typed sentence. */
if (!step.required) capped.push('Skip');
return capped.map((label) => ({ label, prompt: label }));
}
/* ── Flow state ─────────────────────────────────────────────────────────── */
/** The steps a skill declares, keeping only the fields this module understands. */
const stepsOf = (skill) => (skill?.conversation || []).filter((s) => FIELDS[s.field]);
/** Is this field answered, or deliberately passed over? */
const isSettled = (flow, field) => FIELDS[field].settled(flow.draft) || flow.skipped.includes(field);
/** The next question, or `null` when there is nothing left to ask. */
function nextStep(flow, steps) {
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
return steps.find((s) => !isSettled(flow, s.field)) || null;
}
/** Everything decided so far, one line each, in the order it is asked. */
function summaryLines(flow, steps) {
return steps
.filter((s) => isSettled(flow, s.field))
.map((s) => FIELDS[s.field].summary(flow.draft))
.filter(Boolean);
}
/** The required fields a position cannot be created without. */
const missingRequired = (flow, steps) => steps.filter((s) => s.required && !FIELDS[s.field].settled(flow.draft));
/* ── Replies ────────────────────────────────────────────────────────────── */
/** Ask the next question, or read the position back when there is none left. */
function ask(flow, steps, { roles, preamble = null, retry = null }) {
const step = nextStep(flow, steps);
if (!step) return review(flow, steps);
return {
flow: { ...flow, stage: 'collect', step: step.field },
doc: doc(
preamble ? text(preamble) : null,
preamble && summaryLines(flow, steps).length ? list(summaryLines(flow, steps)) : null,
text(step.question),
retry ? note(retry) : null
),
followUp: suggestionsFor(step, { roles }),
};
}
/** The whole position, before anything is written. */
function review(flow, steps) {
const missing = missingRequired(flow, steps);
/* Only reachable if a required answer was cleared — ask for it rather than
offering to create something incomplete. */
if (missing.length) {
return {
flow: { ...flow, stage: 'collect', step: missing[0].field, editing: null },
doc: doc(text(missing[0].question)),
followUp: [],
};
}
return {
flow: { ...flow, stage: 'review', step: null, editing: null },
doc: doc(
text('Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Nothing is saved until you choose Create position.')
),
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
],
};
}
/** Which detail to change — the answers already given, as chips. */
function changeMenu(flow, steps) {
const settled = steps.filter((s) => isSettled(flow, s.field));
return {
flow: { ...flow, stage: 'change', step: null },
doc: doc(text('What should I change?')),
followUp: settled.map((s) => ({ label: FIELDS[s.field].label, prompt: FIELDS[s.field].label })),
};
}
/* ── Entry points ───────────────────────────────────────────────────────── */
/**
* Start collecting, from whatever the request already said.
*
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
* questions before the first one is asked, and those are not asked again.
*/
export function beginPositionFlow({ question, skill, roles = [] }) {
const { prefill } = buildPositionPrefill(question, roles);
const steps = stepsOf(skill);
const flow = {
skillId: skill.id,
draft: prefill,
skipped: [],
editing: null,
stage: 'collect',
step: null,
};
const known = summaryLines(flow, steps);
return ask(flow, steps, {
roles,
preamble: known.length ? `Got it — here is what I have so far:` : null,
});
}
/**
* One answer, and whatever it makes the next thing to say.
*
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
* `create: { draft }` — the panel writes it, this module does not.
*/
export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
const steps = stepsOf(skill);
const said = String(answer).trim();
/* A way out that does not require finishing. `flow: null` ends it, and the
next question is answered by the page as usual. */
if (/^(?:cancel|stop|never ?mind|nevermind|forget it|quit|exit)$/i.test(said)) {
return {
flow: null,
doc: doc(
text('Stopped — nothing was created.'),
note('Ask me to create a position whenever you are ready.')
),
followUp: [],
};
}
/* The confirmation step. "Create position" is the only path to a record. */
if (flow.stage === 'review') {
if (/^(?:create position|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft } };
}
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
/* Anything else at the confirmation step is a correction stated outright —
"make it Bengaluru", "$32–$40". Read it against every field and apply
what it settles, rather than making the user find the menu. */
const revised = applyStatement(flow, steps, said, roles);
if (revised) return review(revised, steps);
return {
...review(flow, steps),
doc: doc(
text('I did not catch that. Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Choose Create position, or tell me what to change.')
),
};
}
/* Choosing which detail to revisit. */
if (flow.stage === 'change') {
const target = steps.find((s) => FIELDS[s.field].label.toLowerCase() === said.toLowerCase());
if (!target) {
const revised = applyStatement(flow, steps, said, roles);
if (revised) return review(revised, steps);
return changeMenu(flow, steps);
}
return ask({ ...flow, editing: target.field }, steps, { roles });
}
/* Answering the question that was asked. */
const step = steps.find((s) => s.field === flow.step) || nextStep(flow, steps);
if (!step) return review(flow, steps);
const field = FIELDS[step.field];
if (SKIP.test(said) && !step.required) {
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
return ask(next, steps, { roles });
}
const patch = field.parse(said, { roles });
if (!patch) {
/* Not an answer to this question — but it may still be a fact about the
position ("in Chennai" while being asked for pay). Take it if so. */
const revised = applyStatement(flow, steps, said, roles);
if (revised) return ask(revised, steps, { roles });
return ask(flow, steps, { roles, retry: field.retry });
}
const next = {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => f !== step.field),
editing: null,
};
/* A detail revisited from the change menu goes straight back to the summary
rather than walking the rest of the questions again. */
if (flow.editing) return review(next, steps);
return ask(next, steps, { roles });
}
/**
* A sentence read against every field at once.
*
* This is what makes the conversation forgiving: an answer that arrives out of
* order, or a correction stated rather than chosen from a menu, lands in the
* right field instead of being rejected for not answering the question asked.
* Returns an updated flow, or `null` when the sentence settles nothing.
*/
function applyStatement(flow, steps, said, roles) {
const { prefill } = buildPositionPrefill(said, roles);
const fields = new Set(steps.map((s) => s.field));
const patch = {};
/* Step names, not record fields — a step the sentence settled must come off
the skipped list so the summary shows it. */
const touched = new Set();
if (fields.has('role_category') && prefill.title) {
if (prefill.role_category) patch.role_category = prefill.role_category;
patch.title = prefill.title;
touched.add('role_category');
}
if (fields.has('location') && prefill.location) {
patch.location = prefill.location;
touched.add('location');
}
if (fields.has('pay') && prefill.pay_range_min) {
patch.pay_range_min = prefill.pay_range_min;
patch.pay_range_max = prefill.pay_range_max;
touched.add('pay');
}
if (fields.has('min_experience_years') && prefill.min_experience_years !== undefined) {
patch.min_experience_years = prefill.min_experience_years;
touched.add('min_experience_years');
}
if (fields.has('english_required') && prefill.english_required) {
patch.english_required = prefill.english_required;
touched.add('english_required');
}
if (fields.has('certifications_required') && prefill.certifications_required) {
patch.certifications_required = prefill.certifications_required;
touched.add('certifications_required');
}
if (!touched.size) return null;
return {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => !touched.has(f)),
editing: null,
};
}
/* ── Outcomes ───────────────────────────────────────────────────────────── */
/** The position exists. Said plainly, with what was created. */
export function positionCreatedReply(position) {
return doc(
text('Position created successfully.'),
list([
position.title,
position.location,
payLabel(position),
].filter(Boolean)),
note('It is on the Positions list now — applications will start appearing against it.')
);
}
/** The write failed. The draft is kept, so the answers are not lost. */
export function positionFailedReply() {
return doc(
text('I could not create that position.'),
note('Nothing was saved. Choose Create position to try again.')
);
}
/** What the panel offers after a position is created. */
export const createdFollowUp = (position) => [
{ label: 'View position', route: `/admin/positions/${position.id}` },
];