@doany-ai/forms
The business's forms on a Doany site: contact, inquiry, subscription, application, quiz. The business defines a form (its questions, which services it asks about, what to show after); the site renders it and submits what the visitor fills in. A submission lands in the business's Inbox. Nothing here creates forms.
import { Form } from "@doany-ai/forms";
// /contact — the form by its id, as the business defined it
<Form formId="…" context={{ page_path: "/contact" }} />
List the page in doany/features.jsonc under forms.
Records
- FormView (
useForm):id,type(contact|inquiry|subscription|promotion|application|quiz|custom…),definition_hash(identifies what the visitor saw; sent back with the answers),definition(title,description,fields[],settings),availability(can_submit,reason:preview_onlywhere the platform takes no submission (an address that is not the site's),example_onlyfor the package's examples,no_service_optionswhen a required service question has nothing to offer),livemode(falsein the website editor: a submission there is a test). - FormField:
key,type,label,help,required,control(radio|dropdownfor a choice;hiddenon aservicequestion, which is then not shown and answered with the services it offers),validation(min_length,max_length,min,max,after_field),options[](value,label),selectionandresolved_options[](servicequestions: the services on offer,service_modebooking|order|inquiry),consent(modecheckbox|on_submit). - Field types:
text,textarea,email,phone,number,date,select,multiselect,checkbox,scale,agreement,consent,hidden,statement,service. - FormSubmitReceipt (
useSubmitForm):id,submitted_at,scoreandoutcome(a quiz),completion(the form'sthank_you_message,redirect_url,cta).
Form
Renders the questions, submits the answers, shows the confirmation the form sets (or a quiz's outcome), redirects when the form says so.
| prop | |
|---|---|
formId | required |
context | { page_path?, referrer?, utm?, language?, timezone?, source_service_id? }, kept with the submission |
defaultAnswers | answers to start with, by field key (a hidden value, a preselected service) |
submitLabel | wins over the submit label |
renderSuccess | (receipt) => node: replaces the confirmation |
showTitle | the form's title and description above the questions; default true |
variant | "single" (default): every question on one page. "steps": the questions in pages with a progress bar and Back / Next — a survey. The pages are steps when given, else one page per statement question (the statement heads its page), else one page. Each page's required questions gate Next |
steps | for variant="steps": the field keys of each page, in order; keys left out go on the last page |
frame | "plain" (default): just the questions. "card": on a raised card, the way a form embeds on a page |
onSubmitted | (receipt) => void |
labels, className |
Answers go to the platform by field key: text, date and single choices as strings, several choices as arrays, checkbox / agreement / consent as booleans, numbers as numbers; blanks are left out. A consent with mode: "on_submit" is sent as agreed when the form is submitted.
For two date questions, set validation: { "after_field": "arrival" } on the departure question in the form definition. When both are answered, departure must be strictly later than arrival. Form checks the pair before submission, including when the questions are on different steps, and shows dateAfterField(earlierLabel) under the later-date question. The platform checks the same rule for all submissions. Use each question's required flag if both answers are mandatory.
A question that may be left blank says Optional at the end of its label row; required questions carry nothing (the merchant app's convention). A required text, textarea, email or phone question that is blank once trimmed shows answerRequired under the question and takes focus. Invalid phone and email formats show phoneFormat and emailFormat the same way. Next in a stepped form checks its page before advancing. Email still uses the browser's email-format check, and the browser checks the remaining native constraints. The fields are @doany-ai/app-core/form's, shared with the booking form: an email question shows an envelope and an example address; a phone question is typed as a local number behind a country, shown as its flag and calling code (the chip opens a searchable list, the United States first and to start with; a number typed with its own + or 00 moves the flag), and is sent in E.164.
In the website editor a submission is saved as a test (docs/specs/preview-test-records.md): it shows in the business's Inbox with a Test tag, signs no one up and reaches no client. The form itself looks and sends exactly as on the published site (the receipt's livemode is false); the editor says what was saved above the page.
Where the platform takes no submission (availability.reason preview_only, or the examples' example_only), a notice appears before the questions. Visitors can fill in their answers, validate them and move through every page of a stepped form. The final action checks locally, keeps the answers and sends no submission; it never calls onSubmitted, renders a success receipt or follows a completion redirect. A backend INVALID_STATE with details.reason="preview_only" is handled the same way if the page had older availability. A required service question with no options still blocks submission.
Failures: VERSION_CONFLICT (the questions changed meanwhile) re-reads the form and says so; RATE_LIMITED asks to wait; a VALIDATION_ERROR whose details.errors[].field names a question the visitor is asked (answers.<key>) is said under it (answerRequired or answerInvalid), on its page; anything else shows the site's submitFailed label when set, else the platform's message.
To put the form in a popup, a sidebar or a slide-in panel, wrap it in Embed from @doany-ai/app-core/ui; inside, useEmbed().markSubmitted() and close() tell the shell the visitor is done.
Hooks
| hook | returns |
|---|---|
useForm(formId) | query of the FormView |
useSubmitForm(formId) | mutation: mutate({ definition_hash, answers, context? }) → FormSubmitReceipt |
Labels
submit, submitting, thanks, score(score), loading, loadError, submitFailed, previewOnly, previewChecked, noServiceOptions, definitionChanged, rateLimited, tryAgain, notFound, choose, optional, emailPlaceholder, emailFormat, dateAfterField(earlierLabel), country, searchCountry, noCountry, phoneFormat, answerRequired, answerInvalid (required is kept for sites that set it, unused). loading appears with the skeleton while the form loads. The questions' own words come from the form's definition.
Customizing
- Theme: the site's variables style the inputs, buttons and the confirmation.
- Props:
labels,submitLabel,renderSuccess,defaultAnswers,className. - Hooks: draw the form yourself on
useFormanduseSubmitForm.
Inside the form, each question is its label, its field, its help text and its error message (a <p> under the field, shown once an answer is refused); choices are <button>s. A site rule on those elements, or on them under a wrapper (.contact p), restyles them too: see Customizing in @doany-ai/app-core.
The package ships no CSS: the site's Tailwind build scans node_modules/@doany-ai/forms/dist.
Four runnable usages
The official examples live with this package, under @doany-ai/forms/examples:
import { FormExample } from "@doany-ai/forms/examples";
<FormExample mode="default" />
<FormExample mode="css" />
<FormExample mode="props" />
<FormExample mode="hook" />
mode defaults to default. Each mounts a private DoanyProvider and query
cache, reads the same three-field contact fixture, and keeps its own answers.
It never uses an enclosing provider's connected client.
- Default: the actual
Formand its local preview validation. - CSS: identical
Formprops; only scoped theme variables change. No generated stylesheet or copied field styles. These examples have no portals; a real site's global theme belongs on:root. - Props: a card, two steps and custom button/progress labels using
Form. - Hook: explicit name/email/question markup using
useForm,useSubmitFormmutation state and app-core controls. Native fields check email syntax and lengths; the example checks trimmed required values. It supports only this fixture, and never callsmutate. General question rendering, survey navigation and connected submission remain inForm.
formExampleView and createFormExampleClient() are also exported. The client
returns a fresh copy for its one local form ID, refuses submissions with
INVALID_STATE / example_only, and rejects unrelated account/upload operations
with EXAMPLE_ONLY. It makes no network request or real submission, never
produces a receipt, and does not follow a completion redirect. Use the site's
SDK client and the main package entry for a connected form.
Develop
npm install
npm run typecheck # includes tests/sdk-contract.types.ts
npm test
npm run build
Native dropdowns use app-core’s NativeSelect, with left-aligned text and an inset arrow. Date fields keep the browser’s date picker at the right edge. These controls require @doany-ai/app-core 0.2.3 or later in the 0.2 series.