@doany-ai/forms 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # @doany-ai/forms
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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.
4
+
5
+ ```jsx
6
+ import { Form } from "@doany-ai/forms";
7
+
8
+ // /contact — the form by its id, as the business defined it
9
+ <Form formId="…" context={{ page_path: "/contact" }} />
10
+ ```
11
+
12
+ List the page in `doany/features.jsonc` under `forms`.
13
+
14
+ ## Records
15
+
16
+ - **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_only` on a preview address, `no_service_options` when a required service question has nothing to offer).
17
+ - **FormField**: `key`, `type`, `label`, `help`, `required`, `control` (`radio` | `dropdown` for a choice), `validation` (`min_length`, `max_length`, `min`, `max`), `options[]` (`value`, `label`), `selection` and `resolved_options[]` (`service` questions: the services on offer, `service_mode` `booking` | `inquiry`), `consent` (`mode` `checkbox` | `on_submit`).
18
+ - Field types: `text`, `textarea`, `email`, `phone`, `number`, `date`, `select`, `multiselect`, `checkbox`, `scale`, `agreement`, `consent`, `hidden`, `statement`, `service`.
19
+ - **FormSubmitReceipt** (`useSubmitForm`): `id`, `submitted_at`, `score` and `outcome` (a quiz), `completion` (the form's `thank_you_message`, `redirect_url`, `cta`).
20
+
21
+ ## `Form`
22
+
23
+ Renders the questions, submits the answers, shows the confirmation the form sets (or a quiz's outcome), redirects when the form says so.
24
+
25
+ | prop | |
26
+ | --- | --- |
27
+ | `formId` | required |
28
+ | `context` | `{ page_path?, referrer?, utm?, language?, timezone?, source_service_id? }`, kept with the submission |
29
+ | `defaultAnswers` | answers to start with, by field key (a `hidden` value, a preselected service) |
30
+ | `submitLabel` | wins over the `submit` label |
31
+ | `renderSuccess` | `(receipt) => node`: replaces the confirmation |
32
+ | `showTitle` | the form's title and description above the questions; default `true` |
33
+ | `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 |
34
+ | `steps` | for `variant="steps"`: the field keys of each page, in order; keys left out go on the last page |
35
+ | `frame` | `"plain"` (default): just the questions. `"card"`: on a raised card, the way a form embeds on a page |
36
+ | `onSubmitted` | `(receipt) => void` |
37
+ | `labels`, `className` | |
38
+
39
+ 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.
40
+
41
+ Failures: `VERSION_CONFLICT` (the questions changed meanwhile) re-reads the form and says so; `RATE_LIMITED` asks to wait; anything else shows the site's `submitFailed` label when set, else the platform's message.
42
+
43
+ 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.
44
+
45
+ ## Hooks
46
+
47
+ | hook | returns |
48
+ | --- | --- |
49
+ | `useForm(formId)` | query of the `FormView` |
50
+ | `useSubmitForm(formId)` | mutation: `mutate({ definition_hash, answers, context? })` → `FormSubmitReceipt` |
51
+
52
+ ## Labels
53
+
54
+ `submit`, `submitting`, `thanks`, `score(score)`, `loadError`, `submitFailed`, `previewOnly`, `noServiceOptions`, `definitionChanged`, `rateLimited`, `tryAgain`, `notFound`, `choose`, `required`. The questions' own words come from the form's definition.
55
+
56
+ ## Customizing
57
+
58
+ 1. **Theme**: the site's variables style the inputs, buttons and the confirmation.
59
+ 2. **Props**: `labels`, `submitLabel`, `renderSuccess`, `defaultAnswers`, `className`.
60
+ 3. **Hooks**: draw the form yourself on `useForm` and `useSubmitForm`.
61
+
62
+ The package ships no CSS: the site's Tailwind build scans `node_modules/@doany-ai/forms/dist`.
63
+
64
+ ## Develop
65
+
66
+ ```bash
67
+ npm install
68
+ npm run typecheck # includes tests/sdk-contract.types.ts
69
+ npm test
70
+ npm run build
71
+ ```
@@ -0,0 +1,37 @@
1
+ import { type ReactNode } from "react";
2
+ import { type FormLabels } from "../labels.js";
3
+ import type { FormAnswers, FormSubmitParams, FormSubmitReceipt } from "../types.js";
4
+ export interface FormProps {
5
+ /** The form, as the business defined it (its id from the platform). */
6
+ formId: string;
7
+ /** Where it is filled in, kept with the submission; all optional. */
8
+ context?: FormSubmitParams["context"];
9
+ /** Answers to start with, by field key (a `hidden` field's value, a preselected service). */
10
+ defaultAnswers?: FormAnswers;
11
+ /** Wins over the `submit` label. */
12
+ submitLabel?: string;
13
+ /** Replaces the default confirmation. Receives the receipt (its `completion`, a quiz's `outcome`). */
14
+ renderSuccess?: (receipt: FormSubmitReceipt) => ReactNode;
15
+ /** Show the form's title and description above the questions. Default `true`. */
16
+ showTitle?: boolean;
17
+ /**
18
+ * `single` (default): every question on one page. `steps`: the questions
19
+ * in pages with a progress bar and Back / Next — a survey. The pages are
20
+ * `steps` when given, else one page per `statement` question (the
21
+ * statement heads its page), else one page.
22
+ */
23
+ variant?: "single" | "steps";
24
+ /** For `variant="steps"`: the field keys of each page, in order. Keys left out go on the last page. */
25
+ steps?: string[][];
26
+ /** `plain` (default): just the questions. `card`: on a raised card, the way a form embeds on a page. */
27
+ frame?: "plain" | "card";
28
+ onSubmitted?: (receipt: FormSubmitReceipt) => void;
29
+ labels?: Partial<FormLabels>;
30
+ className?: string;
31
+ }
32
+ /**
33
+ * A form as the business defined it: renders its questions, submits the
34
+ * answers, shows the confirmation the form sets (or a quiz's outcome). A
35
+ * submission lands in the business's Inbox.
36
+ */
37
+ export declare function Form({ formId, context, defaultAnswers, submitLabel, renderSuccess, showTitle, variant, steps, frame, onSubmitted, labels: given, className }: FormProps): import("react").JSX.Element;
@@ -0,0 +1,191 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from "@doany-ai/app-core";
3
+ import { Button } from "@doany-ai/app-core/ui/button";
4
+ import { Checkbox } from "@doany-ai/app-core/ui/checkbox";
5
+ import { Input } from "@doany-ai/app-core/ui/input";
6
+ import { Label } from "@doany-ai/app-core/ui/label";
7
+ import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
8
+ import { Textarea } from "@doany-ai/app-core/ui/textarea";
9
+ import { CheckCircle2, Loader2 } from "lucide-react";
10
+ import { isE164, normalizePhone } from "../phone.js";
11
+ import { useEffect, useMemo, useRef, useState } from "react";
12
+ import { useForm, useSubmitForm } from "../hooks.js";
13
+ import { formLabels } from "../labels.js";
14
+ const selectClass = "h-11 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring";
15
+ const fieldClass = "h-11 rounded-lg";
16
+ function messageOf(cause, labels, given) {
17
+ const { code, message } = (cause ?? {});
18
+ if (code === "VERSION_CONFLICT")
19
+ return labels.definitionChanged;
20
+ if (code === "RATE_LIMITED")
21
+ return labels.rateLimited;
22
+ return given?.submitFailed ?? (message || labels.submitFailed);
23
+ }
24
+ function Required({ on, labels }) {
25
+ return on ? _jsxs("span", { className: "ml-1 font-normal text-muted-foreground", children: ["(", labels.required, ")"] }) : null;
26
+ }
27
+ function Question({ field, value, onChange, labels }) {
28
+ const id = `doany-form-${field.key}`;
29
+ const text = typeof value === "string" ? value : "";
30
+ const label = (_jsxs(Label, { htmlFor: id, className: "text-sm font-semibold", children: [field.label, _jsx(Required, { on: field.required, labels: labels })] }));
31
+ const help = field.help ? _jsx("p", { className: "text-xs text-muted-foreground", children: field.help }) : null;
32
+ if (field.type === "hidden")
33
+ return null;
34
+ if (field.type === "statement") {
35
+ return (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "font-heading text-lg font-semibold", children: field.label }), field.help ? _jsx("p", { className: "text-sm text-muted-foreground", children: field.help }) : null] }));
36
+ }
37
+ if (field.type === "checkbox" || field.type === "agreement" || field.type === "consent") {
38
+ if (field.type === "consent" && field.consent?.mode === "on_submit")
39
+ return null;
40
+ return (_jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx(Checkbox, { id: id, checked: Boolean(value), onCheckedChange: (checked) => onChange(checked === true), required: field.required, className: "mt-0.5" }), _jsxs(Label, { htmlFor: id, className: "text-sm font-normal leading-snug", children: [field.label, _jsx(Required, { on: field.required, labels: labels })] })] }), help] }));
41
+ }
42
+ if (field.type === "service") {
43
+ const options = field.resolved_options ?? [];
44
+ if (field.selection === "multiple") {
45
+ const chosen = Array.isArray(value) ? value : [];
46
+ return (_jsxs("fieldset", { className: "space-y-2", children: [_jsxs("legend", { className: "text-sm font-semibold", children: [field.label, _jsx(Required, { on: field.required, labels: labels })] }), help, _jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: options.map((option) => {
47
+ const optionId = `${id}-${option.value}`;
48
+ const on = chosen.includes(option.value);
49
+ return (_jsxs("div", { className: cn("flex items-center gap-3 rounded-lg border px-3 py-2.5", on ? "border-primary bg-primary/5" : "border-border"), children: [_jsx(Checkbox, { id: optionId, checked: on, onCheckedChange: (checked) => onChange(checked === true ? [...chosen, option.value] : chosen.filter((v) => v !== option.value)) }), _jsx(Label, { htmlFor: optionId, className: "font-normal", children: option.label })] }, option.value));
50
+ }) })] }));
51
+ }
52
+ return (_jsxs("div", { className: "space-y-2", children: [label, help, _jsxs("select", { id: id, value: text, onChange: (e) => onChange(e.target.value), required: field.required, className: selectClass, children: [_jsx("option", { value: "", children: labels.choose }), options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })] }));
53
+ }
54
+ if (field.type === "select") {
55
+ const options = field.options ?? [];
56
+ if (field.control === "radio") {
57
+ return (_jsxs("fieldset", { className: "space-y-2", children: [_jsxs("legend", { className: "text-sm font-semibold", children: [field.label, _jsx(Required, { on: field.required, labels: labels })] }), help, _jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: options.map((option) => {
58
+ const optionId = `${id}-${option.value}`;
59
+ const on = text === option.value;
60
+ return (_jsxs("label", { htmlFor: optionId, className: cn("flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-sm", on ? "border-primary bg-primary/5" : "border-border"), children: [_jsx("input", { type: "radio", id: optionId, name: id, value: option.value, checked: on, onChange: () => onChange(option.value), required: field.required, className: "h-4 w-4 accent-primary" }), option.label] }, option.value));
61
+ }) })] }));
62
+ }
63
+ return (_jsxs("div", { className: "space-y-2", children: [label, help, _jsxs("select", { id: id, value: text, onChange: (e) => onChange(e.target.value), required: field.required, className: selectClass, children: [_jsx("option", { value: "", children: labels.choose }), options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })] }));
64
+ }
65
+ if (field.type === "multiselect") {
66
+ const chosen = Array.isArray(value) ? value : [];
67
+ return (_jsxs("fieldset", { className: "space-y-2", children: [_jsxs("legend", { className: "text-sm font-semibold", children: [field.label, _jsx(Required, { on: field.required, labels: labels })] }), help, _jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: (field.options ?? []).map((option) => {
68
+ const optionId = `${id}-${option.value}`;
69
+ const on = chosen.includes(option.value);
70
+ return (_jsxs("div", { className: cn("flex items-center gap-3 rounded-lg border px-3 py-2.5", on ? "border-primary bg-primary/5" : "border-border"), children: [_jsx(Checkbox, { id: optionId, checked: on, onCheckedChange: (checked) => onChange(checked === true ? [...chosen, option.value] : chosen.filter((v) => v !== option.value)) }), _jsx(Label, { htmlFor: optionId, className: "font-normal", children: option.label })] }, option.value));
71
+ }) })] }));
72
+ }
73
+ if (field.type === "scale") {
74
+ const min = field.validation?.min ?? 1;
75
+ const max = field.validation?.max ?? 5;
76
+ const steps = Array.from({ length: Math.max(0, max - min + 1) }, (_, i) => min + i);
77
+ return (_jsxs("fieldset", { className: "space-y-2", children: [_jsxs("legend", { className: "text-sm font-semibold", children: [field.label, _jsx(Required, { on: field.required, labels: labels })] }), help, _jsx("div", { role: "radiogroup", className: "flex flex-wrap gap-2", children: steps.map((n) => (_jsx("button", { type: "button", role: "radio", "aria-checked": value === n, onClick: () => onChange(n), className: cn("h-11 w-11 rounded-lg border text-sm font-semibold transition-colors", value === n ? "border-primary bg-primary text-primary-foreground" : "border-border hover:border-primary/60"), children: n }, n))) })] }));
78
+ }
79
+ if (field.type === "textarea") {
80
+ return (_jsxs("div", { className: "space-y-2", children: [label, help, _jsx(Textarea, { id: id, value: text, onChange: (e) => onChange(e.target.value), required: field.required, minLength: field.validation?.min_length, maxLength: field.validation?.max_length, rows: 4, className: "rounded-lg" })] }));
81
+ }
82
+ const inputType = field.type === "email" ? "email" : field.type === "phone" ? "tel" : field.type === "number" ? "number" : field.type === "date" ? "date" : "text";
83
+ const autoComplete = field.type === "email" ? "email" : field.type === "phone" ? "tel" : undefined;
84
+ return (_jsxs("div", { className: "space-y-2", children: [label, help, _jsx(Input, { id: id, type: inputType, autoComplete: autoComplete, value: typeof value === "number" ? String(value) : text, onChange: (e) => {
85
+ if (field.type === "phone") {
86
+ // The platform takes E.164 only: say so before the submit does.
87
+ const normalized = normalizePhone(e.target.value);
88
+ e.target.setCustomValidity(!normalized || isE164(normalized) ? "" : labels.phoneFormat);
89
+ }
90
+ onChange(field.type === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value);
91
+ }, required: field.required, minLength: field.validation?.min_length, maxLength: field.validation?.max_length, min: field.validation?.min, max: field.validation?.max, className: fieldClass })] }));
92
+ }
93
+ /** The answers the platform takes: `hidden` values as given, numbers as numbers, nothing for blanks. */
94
+ function answersOf(view, answers) {
95
+ const out = {};
96
+ for (const field of view.definition.fields) {
97
+ if (field.type === "statement")
98
+ continue;
99
+ // The backend writes an on-submit consent itself and refuses a value for it.
100
+ if (field.type === "consent" && field.consent?.mode === "on_submit")
101
+ continue;
102
+ const value = answers[field.key];
103
+ if (value === undefined || value === "" || value === null)
104
+ continue;
105
+ // Every option cleared again is no answer: the platform refuses an empty list.
106
+ if (Array.isArray(value) && value.length === 0)
107
+ continue;
108
+ out[field.key] = field.type === "phone" && typeof value === "string" ? normalizePhone(value) : value;
109
+ }
110
+ return out;
111
+ }
112
+ /** The questions in pages: as `steps` says, else one page per statement, else one page. */
113
+ function pagesOf(fields, steps) {
114
+ const shown = fields.filter((f) => f.type !== "hidden");
115
+ if (steps && steps.length) {
116
+ const placed = new Set(steps.flat());
117
+ const pages = steps.map((keys) => keys.map((key) => shown.find((f) => f.key === key)).filter((f) => Boolean(f)));
118
+ const rest = shown.filter((f) => !placed.has(f.key));
119
+ if (rest.length)
120
+ pages[pages.length - 1] = [...pages[pages.length - 1], ...rest];
121
+ return pages.filter((page) => page.length);
122
+ }
123
+ if (shown.some((f) => f.type === "statement")) {
124
+ const pages = [];
125
+ for (const field of shown) {
126
+ if (field.type === "statement" || !pages.length)
127
+ pages.push([field]);
128
+ else
129
+ pages[pages.length - 1].push(field);
130
+ }
131
+ return pages;
132
+ }
133
+ return [shown];
134
+ }
135
+ function Success({ receipt, labels }) {
136
+ const completion = receipt.completion ?? {};
137
+ useEffect(() => {
138
+ if (completion.redirect_url && typeof window !== "undefined")
139
+ window.location.assign(completion.redirect_url);
140
+ }, [completion.redirect_url]);
141
+ return (_jsxs("div", { role: "status", className: "space-y-4 rounded-xl bg-success/10 p-5 text-success", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx(CheckCircle2, { className: "mt-0.5 h-5 w-5 shrink-0", "aria-hidden": "true" }), _jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "font-semibold", children: receipt.outcome?.label ?? completion.thank_you_message ?? labels.thanks }), receipt.outcome?.description ? _jsx("p", { className: "text-sm", children: receipt.outcome.description }) : null, receipt.score !== null && receipt.score !== undefined ? _jsx("p", { className: "text-sm", children: labels.score(receipt.score) }) : null] })] }), (receipt.outcome?.cta ?? completion.cta) && (_jsx(Button, { asChild: true, className: "rounded-lg", children: _jsx("a", { href: (receipt.outcome?.cta ?? completion.cta)?.url, children: (receipt.outcome?.cta ?? completion.cta)?.label }) }))] }));
142
+ }
143
+ /**
144
+ * A form as the business defined it: renders its questions, submits the
145
+ * answers, shows the confirmation the form sets (or a quiz's outcome). A
146
+ * submission lands in the business's Inbox.
147
+ */
148
+ export function Form({ formId, context, defaultAnswers, submitLabel, renderSuccess, showTitle = true, variant = "single", steps, frame = "plain", onSubmitted, labels: given, className }) {
149
+ const labels = formLabels(given);
150
+ const { data: view, isLoading, isError, error, refetch } = useForm(formId);
151
+ const submit = useSubmitForm(formId);
152
+ const [answers, setAnswers] = useState(defaultAnswers ?? {});
153
+ const [receipt, setReceipt] = useState(null);
154
+ const [page, setPage] = useState(0);
155
+ const formRef = useRef(null);
156
+ const pages = useMemo(() => (view ? (variant === "steps" ? pagesOf(view.definition.fields, steps) : [view.definition.fields]) : []), [view, variant, steps]);
157
+ const framed = (node) => (frame === "card" ? _jsx("div", { className: cn("rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-card sm:p-8", className), children: node }) : _jsx("div", { className: className, children: node }));
158
+ if (isLoading) {
159
+ return framed(_jsxs("div", { className: "space-y-4", "aria-busy": "true", children: [_jsx(Skeleton, { className: "h-6 w-1/2" }), _jsx(Skeleton, { className: "h-11 w-full" }), _jsx(Skeleton, { className: "h-11 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" })] }));
160
+ }
161
+ if (isError || !view) {
162
+ const missing = error?.status === 404;
163
+ return framed(_jsxs("div", { role: "alert", className: "space-y-3 py-10 text-center text-sm", children: [_jsx("p", { className: "text-destructive", children: missing ? labels.notFound : (given?.loadError ?? (error?.message || labels.loadError)) }), !missing && (_jsx(Button, { variant: "outline", size: "sm", onClick: () => void refetch(), children: labels.tryAgain }))] }));
164
+ }
165
+ if (receipt)
166
+ return framed(renderSuccess ? renderSuccess(receipt) : _jsx(Success, { receipt: receipt, labels: labels }));
167
+ const blocked = !view.availability.can_submit;
168
+ const blockedText = view.availability.reason === "preview_only" ? labels.previewOnly : labels.noServiceOptions;
169
+ const stepped = variant === "steps" && pages.length > 1;
170
+ const last = !stepped || page === pages.length - 1;
171
+ const fields = stepped ? pages[page] : view.definition.fields;
172
+ const next = () => {
173
+ if (formRef.current && !formRef.current.reportValidity())
174
+ return;
175
+ setPage((p) => Math.min(p + 1, pages.length - 1));
176
+ };
177
+ const onSubmit = (event) => {
178
+ event.preventDefault();
179
+ if (!last) {
180
+ next();
181
+ return;
182
+ }
183
+ submit.mutate({ definition_hash: view.definition_hash, answers: answersOf(view, answers), ...(context ? { context } : {}) }, {
184
+ onSuccess: (r) => {
185
+ setReceipt(r);
186
+ onSubmitted?.(r);
187
+ },
188
+ });
189
+ };
190
+ return framed(_jsxs("form", { ref: formRef, onSubmit: onSubmit, className: "space-y-5", "aria-label": view.definition.title, children: [showTitle && (_jsxs("header", { className: "space-y-1", children: [_jsx("h2", { className: "font-heading text-2xl font-semibold tracking-tight", children: view.definition.title }), view.definition.description ? _jsx("p", { className: "text-sm text-muted-foreground", children: view.definition.description }) : null] })), stepped && (_jsxs("div", { className: "space-y-1.5", "aria-live": "polite", children: [_jsx("p", { className: "text-xs font-medium text-muted-foreground", children: labels.stepOf(page + 1, pages.length) }), _jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-full bg-muted", role: "progressbar", "aria-valuemin": 1, "aria-valuemax": pages.length, "aria-valuenow": page + 1, children: _jsx("div", { className: "h-full rounded-full bg-primary transition-[width]", style: { width: `${((page + 1) / pages.length) * 100}%` } }) })] })), fields.map((field) => (_jsx(Question, { field: field, value: answers[field.key], onChange: (v) => setAnswers((a) => ({ ...a, [field.key]: v })), labels: labels }, field.key))), blocked && _jsx("p", { className: "text-sm text-muted-foreground", children: blockedText }), submit.isError && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: messageOf(submit.error, labels, given) })), _jsxs("div", { className: cn("flex items-center gap-3 pt-1", stepped && page > 0 ? "justify-between" : "justify-end"), children: [stepped && page > 0 ? (_jsx(Button, { type: "button", variant: "outline", className: "h-11 rounded-lg px-6", onClick: () => setPage((p) => Math.max(0, p - 1)), disabled: submit.isPending, children: labels.back })) : null, _jsxs(Button, { type: "submit", className: cn("h-11 rounded-lg font-semibold", last ? "w-full px-8 sm:w-auto" : "px-8"), disabled: blocked || submit.isPending, children: [submit.isPending && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), submit.isPending ? labels.submitting : last ? (submitLabel ?? labels.submit) : labels.next] })] })] }));
191
+ }
@@ -0,0 +1,14 @@
1
+ import type { FormSubmitParams, FormSubmitReceipt, FormView } from "./types.js";
2
+ /** The query keys this package reads under, for invalidating from elsewhere. */
3
+ export declare const formKeys: {
4
+ all: readonly ["doany", "forms"];
5
+ form: (formId: string) => readonly ["doany", "forms", "form", string];
6
+ };
7
+ /** A form's questions, and the hash a submission must carry. */
8
+ export declare function useForm(formId: string | null | undefined): import("@tanstack/react-query").UseQueryResult<FormView, Error>;
9
+ /**
10
+ * Submits one filled form. The platform refuses answers to a definition that
11
+ * changed since it was read (`VERSION_CONFLICT`); the hook then re-reads the
12
+ * form so the page can show the current questions.
13
+ */
14
+ export declare function useSubmitForm(formId: string): import("@tanstack/react-query").UseMutationResult<FormSubmitReceipt, unknown, FormSubmitParams, unknown>;
package/dist/hooks.js ADDED
@@ -0,0 +1,36 @@
1
+ import { useDoany } from "@doany-ai/app-core";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ /** The query keys this package reads under, for invalidating from elsewhere. */
4
+ export const formKeys = {
5
+ all: ["doany", "forms"],
6
+ form: (formId) => [...formKeys.all, "form", formId],
7
+ };
8
+ /** A form's questions, and the hash a submission must carry. */
9
+ export function useForm(formId) {
10
+ const doany = useDoany();
11
+ return useQuery({
12
+ queryKey: formKeys.form(formId ?? ""),
13
+ queryFn: () => doany.forms.get(formId),
14
+ enabled: Boolean(formId),
15
+ // The questions and the services on offer can change; a page open for a
16
+ // while asks again before the visitor fills it in.
17
+ staleTime: 60 * 1000,
18
+ });
19
+ }
20
+ /**
21
+ * Submits one filled form. The platform refuses answers to a definition that
22
+ * changed since it was read (`VERSION_CONFLICT`); the hook then re-reads the
23
+ * form so the page can show the current questions.
24
+ */
25
+ export function useSubmitForm(formId) {
26
+ const doany = useDoany();
27
+ const queryClient = useQueryClient();
28
+ return useMutation({
29
+ mutationFn: (params) => doany.forms.submit(formId, params),
30
+ onError: (cause) => {
31
+ const code = cause?.code;
32
+ if (code === "VERSION_CONFLICT")
33
+ void queryClient.invalidateQueries({ queryKey: formKeys.form(formId) });
34
+ },
35
+ });
36
+ }
@@ -0,0 +1,4 @@
1
+ export { formKeys, useForm, useSubmitForm } from "./hooks.js";
2
+ export { defaultFormLabels, formLabels, type FormLabels } from "./labels.js";
3
+ export { Form, type FormProps } from "./components/form.js";
4
+ export type { FormAnswers, FormAvailability, FormCompletion, FormDefinition, FormField, FormFieldType, FormOption, FormsClient, FormServiceOption, FormSubmitParams, FormSubmitReceipt, FormView, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // @doany-ai/forms: the business's forms, on its site.
2
+ //
3
+ // The hooks read and submit through the SDK client given to app-core's
4
+ // <DoanyProvider>; <Form> is a default a site can restyle through the theme
5
+ // variables, adjust through props (its words through `labels`), or replace
6
+ // while keeping the hooks. The package ships no CSS: the site's Tailwind
7
+ // build scans it (see @doany-ai/app-core/tailwind-preset).
8
+ export { formKeys, useForm, useSubmitForm } from "./hooks.js";
9
+ export { defaultFormLabels, formLabels } from "./labels.js";
10
+ export { Form } from "./components/form.js";
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Every word the form components show besides the form's own questions,
3
+ * which come from the business's definition. A component takes a part of it
4
+ * as `labels` and keeps the default for the rest:
5
+ *
6
+ * <Form formId={id} labels={{ submit: "Send", thanks: "Merci" }} />
7
+ */
8
+ export interface FormLabels {
9
+ submit: string;
10
+ submitting: string;
11
+ /** The default confirmation, when the form sets no `thank_you_message`. */
12
+ thanks: string;
13
+ /** Under the confirmation of a quiz: its score. */
14
+ score: (score: number) => string;
15
+ /** The platform's words are shown instead, unless the site sets this. */
16
+ loadError: string;
17
+ submitFailed: string;
18
+ /** The form was read on a preview address: it does not take real submissions. */
19
+ previewOnly: string;
20
+ /** A required service question has nothing to offer. */
21
+ noServiceOptions: string;
22
+ /** The questions changed while the visitor filled them. */
23
+ definitionChanged: string;
24
+ rateLimited: string;
25
+ tryAgain: string;
26
+ notFound: string;
27
+ /** A multi-step form. */
28
+ next: string;
29
+ back: string;
30
+ stepOf: (step: number, of: number) => string;
31
+ /** A choice with nothing chosen yet. */
32
+ choose: string;
33
+ /** Beside a required question. */
34
+ required: string;
35
+ /** Shown while a phone number is not in the international format. */
36
+ phoneFormat: string;
37
+ }
38
+ export declare const defaultFormLabels: FormLabels;
39
+ /** The defaults with a site's changes on top. */
40
+ export declare const formLabels: (labels?: Partial<FormLabels>) => FormLabels;
package/dist/labels.js ADDED
@@ -0,0 +1,22 @@
1
+ export const defaultFormLabels = {
2
+ submit: "Submit",
3
+ submitting: "Sending…",
4
+ thanks: "Thanks. Your message has been received.",
5
+ score: (score) => `Your score: ${score}`,
6
+ loadError: "This form could not be loaded.",
7
+ submitFailed: "Your message could not be sent.",
8
+ previewOnly: "This is a preview: submissions are not sent.",
9
+ noServiceOptions: "This form cannot be sent right now.",
10
+ definitionChanged: "This form was updated. Please review your answers and send again.",
11
+ rateLimited: "Too many attempts. Please wait a moment and try again.",
12
+ tryAgain: "Try again",
13
+ notFound: "This form is not available.",
14
+ next: "Next",
15
+ back: "Back",
16
+ stepOf: (step, of) => `Step ${step} of ${of}`,
17
+ choose: "Choose…",
18
+ required: "required",
19
+ phoneFormat: "Use the international format, like +1 415 555 0132.",
20
+ };
21
+ /** The defaults with a site's changes on top. */
22
+ export const formLabels = (labels) => (labels ? { ...defaultFormLabels, ...labels } : defaultFormLabels);
@@ -0,0 +1,4 @@
1
+ /** The platform takes phone numbers in E.164 (`+14155550132`): the visitor's
2
+ * spaces, dots, dashes and brackets are dropped and a `00` prefix becomes `+`. */
3
+ export declare function normalizePhone(raw: string): string;
4
+ export declare const isE164: (value: string) => boolean;
package/dist/phone.js ADDED
@@ -0,0 +1,7 @@
1
+ /** The platform takes phone numbers in E.164 (`+14155550132`): the visitor's
2
+ * spaces, dots, dashes and brackets are dropped and a `00` prefix becomes `+`. */
3
+ export function normalizePhone(raw) {
4
+ const trimmed = raw.trim().replace(/[\s().-]/g, "");
5
+ return trimmed.startsWith("00") ? `+${trimmed.slice(2)}` : trimmed;
6
+ }
7
+ export const isE164 = (value) => /^\+[1-9][0-9]{1,14}$/.test(value);
@@ -0,0 +1,98 @@
1
+ import type { DoanyClientLike } from "@doany-ai/app-core";
2
+ /**
3
+ * A form as `@doany-ai/sdk` 0.3.0-alpha.2 returns it to the website — the
4
+ * fields this package reads. A real SDK client satisfies `FormsClient`.
5
+ */
6
+ export type FormFieldType = "text" | "textarea" | "email" | "phone" | "number" | "date" | "select" | "multiselect" | "checkbox" | "scale" | "agreement" | "consent" | "hidden" | "statement" | "service";
7
+ export interface FormOption {
8
+ value: string;
9
+ label: string;
10
+ }
11
+ export interface FormServiceOption extends FormOption {
12
+ service_mode: "booking" | "inquiry";
13
+ }
14
+ export interface FormField {
15
+ key: string;
16
+ type: FormFieldType;
17
+ label: string;
18
+ help?: string;
19
+ required?: boolean;
20
+ /** `radio` or `dropdown` for a choice; the default is a dropdown. */
21
+ control?: string;
22
+ validation?: {
23
+ min_length?: number;
24
+ max_length?: number;
25
+ min?: number;
26
+ max?: number;
27
+ };
28
+ options?: FormOption[];
29
+ selection?: "single" | "multiple";
30
+ resolved_options?: FormServiceOption[];
31
+ consent?: {
32
+ mode: "checkbox" | "on_submit";
33
+ };
34
+ }
35
+ export interface FormCompletion {
36
+ thank_you_message?: string;
37
+ redirect_url?: string;
38
+ cta?: {
39
+ label: string;
40
+ url: string;
41
+ };
42
+ }
43
+ export interface FormDefinition {
44
+ schema_version: number;
45
+ title: string;
46
+ description?: string;
47
+ fields: FormField[];
48
+ settings?: FormCompletion;
49
+ }
50
+ export interface FormAvailability {
51
+ can_submit: boolean;
52
+ reason: "preview_only" | "no_service_options" | null;
53
+ field_keys: string[];
54
+ }
55
+ export interface FormView {
56
+ id: string;
57
+ type: string;
58
+ definition_hash: string;
59
+ definition: FormDefinition;
60
+ availability: FormAvailability;
61
+ }
62
+ export type FormAnswers = Record<string, unknown>;
63
+ export interface FormSubmitParams {
64
+ definition_hash: string;
65
+ answers: FormAnswers;
66
+ context?: {
67
+ page_path?: string;
68
+ referrer?: string;
69
+ utm?: Partial<Record<"source" | "medium" | "campaign" | "term" | "content", string>>;
70
+ language?: string;
71
+ timezone?: string;
72
+ source_service_id?: string;
73
+ };
74
+ }
75
+ export interface FormSubmitReceipt {
76
+ id: string;
77
+ submitted_at: string;
78
+ score: number | null;
79
+ outcome: {
80
+ key: string;
81
+ label: string;
82
+ description?: string;
83
+ cta?: {
84
+ label: string;
85
+ url: string;
86
+ };
87
+ } | null;
88
+ completion: FormCompletion | undefined;
89
+ }
90
+ /** The part of the SDK client this package calls. */
91
+ export interface FormsClient extends DoanyClientLike {
92
+ forms: {
93
+ get(formId: string): Promise<FormView>;
94
+ submit(formId: string, params: FormSubmitParams, options?: {
95
+ idempotencyKey?: string;
96
+ }): Promise<FormSubmitReceipt>;
97
+ };
98
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@doany-ai/forms",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "The business's forms on a Doany site: a form hook and a default component that renders a form's questions and submits the answers",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "scripts": {
18
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
19
+ "typecheck": "tsc -p tsconfig.json --noEmit",
20
+ "test": "vitest run",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "peerDependencies": {
24
+ "@doany-ai/app-core": "^0.1.0-alpha.0",
25
+ "@doany-ai/sdk": "^0.3.0-alpha.2",
26
+ "@tanstack/react-query": "^5.0.0",
27
+ "react": "^18.2.0 || ^19.0.0",
28
+ "react-dom": "^18.2.0 || ^19.0.0"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@doany-ai/sdk": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "dependencies": {
36
+ "lucide-react": "^0.475.0"
37
+ },
38
+ "devDependencies": {
39
+ "@doany-ai/app-core": "file:../doany-app-core",
40
+ "@doany-ai/sdk": "file:../doany-sdk",
41
+ "@tanstack/react-query": "^5.84.1",
42
+ "@testing-library/jest-dom": "^6.6.3",
43
+ "@testing-library/react": "^16.2.0",
44
+ "@types/node": "^22.20.4",
45
+ "@types/react": "^18.2.66",
46
+ "@types/react-dom": "^18.2.22",
47
+ "jsdom": "^26.0.0",
48
+ "react": "^18.2.0",
49
+ "react-dom": "^18.2.0",
50
+ "typescript": "^5.8.2",
51
+ "vitest": "^3.0.0"
52
+ },
53
+ "license": "MIT"
54
+ }