@mounaji_npm/form-runner 0.1.0

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.
@@ -0,0 +1,220 @@
1
+ "use client";
2
+ import { jsxs as S, jsx as a } from "react/jsx-runtime";
3
+ import { useState as m } from "react";
4
+ function z(t) {
5
+ return t.filter((r) => r.activa !== !1).map((r, n) => ({ p: r, i: n })).sort((r, n) => (r.p.orden ?? 0) - (n.p.orden ?? 0) || r.i - n.i).map(({ p: r }) => r);
6
+ }
7
+ function G(t) {
8
+ return t != null && t !== "";
9
+ }
10
+ function q(t, r) {
11
+ return z(t).filter((n) => !G(r[n.id]));
12
+ }
13
+ function C() {
14
+ return new Promise((t, r) => {
15
+ if (!navigator.geolocation) {
16
+ r(new Error("Este dispositivo no comparte la ubicación"));
17
+ return;
18
+ }
19
+ navigator.geolocation.getCurrentPosition(
20
+ (n) => t({
21
+ lat: n.coords.latitude,
22
+ lng: n.coords.longitude,
23
+ precision: n.coords.accuracy
24
+ }),
25
+ (n) => r(new Error(n.message)),
26
+ { enableHighAccuracy: !0, timeout: 15e3 }
27
+ );
28
+ });
29
+ }
30
+ const c = {
31
+ bloque: { marginBottom: "1.25rem" },
32
+ etiqueta: {
33
+ display: "block",
34
+ fontSize: "var(--mn-form-label-size, 1.125rem)",
35
+ fontWeight: 600,
36
+ marginBottom: "0.5rem"
37
+ },
38
+ entrada: {
39
+ width: "100%",
40
+ minHeight: "52px",
41
+ fontSize: "1.125rem",
42
+ padding: "0.6rem 0.8rem",
43
+ borderRadius: "var(--mn-radius, 0.75rem)",
44
+ border: "1px solid var(--mn-border, #c9c9c9)",
45
+ background: "var(--mn-surface, #fff)",
46
+ color: "inherit",
47
+ boxSizing: "border-box"
48
+ },
49
+ opcion: (t) => ({
50
+ minHeight: "52px",
51
+ padding: "0.6rem 1rem",
52
+ marginRight: "0.5rem",
53
+ marginBottom: "0.5rem",
54
+ fontSize: "1.125rem",
55
+ borderRadius: "var(--mn-radius, 0.75rem)",
56
+ border: t ? "2px solid var(--mn-accent, #2563eb)" : "1px solid var(--mn-border, #c9c9c9)",
57
+ background: t ? "var(--mn-accent-soft, #dbeafe)" : "var(--mn-surface, #fff)",
58
+ color: "inherit",
59
+ cursor: "pointer"
60
+ }),
61
+ enviar: {
62
+ width: "100%",
63
+ minHeight: "60px",
64
+ fontSize: "1.25rem",
65
+ fontWeight: 700,
66
+ borderRadius: "var(--mn-radius, 0.75rem)",
67
+ border: "none",
68
+ background: "var(--mn-accent, #2563eb)",
69
+ color: "var(--mn-accent-contrast, #fff)",
70
+ cursor: "pointer"
71
+ },
72
+ aviso: { color: "var(--mn-danger, #b91c1c)", fontSize: "1rem" }
73
+ };
74
+ function k({
75
+ opciones: t,
76
+ valor: r,
77
+ onPick: n,
78
+ deshabilitado: f
79
+ }) {
80
+ return /* @__PURE__ */ a("div", { children: t.map((l) => /* @__PURE__ */ a(
81
+ "button",
82
+ {
83
+ type: "button",
84
+ disabled: f,
85
+ style: c.opcion(r === l),
86
+ onClick: () => n(l),
87
+ children: l
88
+ },
89
+ l
90
+ )) });
91
+ }
92
+ function j({
93
+ preguntas: t,
94
+ valoresIniciales: r,
95
+ onChange: n,
96
+ onSubmit: f,
97
+ requerirTodas: l = !1,
98
+ etiquetaEnviar: w = "Guardar",
99
+ deshabilitado: s = !1,
100
+ obtenerUbicacion: B = C
101
+ }) {
102
+ const [b, P] = m(r ?? {}), [v, p] = m(null), [g, y] = m(null), [x, h] = m(!1), R = z(t), u = (e, i) => {
103
+ P((o) => {
104
+ const d = { ...o, [e]: i };
105
+ return n == null || n(d), d;
106
+ }), p(null);
107
+ }, E = async () => {
108
+ if (l) {
109
+ const e = q(t, b);
110
+ if (e.length > 0) {
111
+ p(`Falta contestar: ${e.map((i) => i.texto).join(" · ")}`);
112
+ return;
113
+ }
114
+ }
115
+ h(!0);
116
+ try {
117
+ await f(b);
118
+ } finally {
119
+ h(!1);
120
+ }
121
+ };
122
+ return /* @__PURE__ */ S("div", { children: [
123
+ R.map((e) => {
124
+ const i = b[e.id] ?? null;
125
+ return /* @__PURE__ */ S("div", { style: c.bloque, children: [
126
+ /* @__PURE__ */ a("label", { style: c.etiqueta, children: e.texto }),
127
+ e.tipo === "texto" && /* @__PURE__ */ a(
128
+ "input",
129
+ {
130
+ type: "text",
131
+ style: c.entrada,
132
+ disabled: s,
133
+ value: typeof i == "string" ? i : "",
134
+ onChange: (o) => u(e.id, o.target.value)
135
+ }
136
+ ),
137
+ e.tipo === "numero" && /* @__PURE__ */ a(
138
+ "input",
139
+ {
140
+ type: "number",
141
+ inputMode: "numeric",
142
+ style: c.entrada,
143
+ disabled: s,
144
+ value: typeof i == "number" ? i : "",
145
+ onChange: (o) => u(e.id, o.target.value === "" ? null : Number(o.target.value))
146
+ }
147
+ ),
148
+ e.tipo === "opciones" && /* @__PURE__ */ a(
149
+ k,
150
+ {
151
+ opciones: e.opciones ?? [],
152
+ valor: i,
153
+ onPick: (o) => u(e.id, o),
154
+ deshabilitado: s
155
+ }
156
+ ),
157
+ e.tipo === "si_no" && /* @__PURE__ */ a(
158
+ k,
159
+ {
160
+ opciones: ["Sí", "No"],
161
+ valor: i === !0 ? "Sí" : i === !1 ? "No" : null,
162
+ onPick: (o) => u(e.id, o === "Sí"),
163
+ deshabilitado: s
164
+ }
165
+ ),
166
+ e.tipo === "foto" && /* @__PURE__ */ a(
167
+ "input",
168
+ {
169
+ type: "file",
170
+ accept: "image/*",
171
+ capture: "environment",
172
+ style: c.entrada,
173
+ disabled: s,
174
+ onChange: (o) => {
175
+ var d;
176
+ return u(e.id, ((d = o.target.files) == null ? void 0 : d[0]) ?? null);
177
+ }
178
+ }
179
+ ),
180
+ e.tipo === "ubicacion" && /* @__PURE__ */ a("div", { children: /* @__PURE__ */ a(
181
+ "button",
182
+ {
183
+ type: "button",
184
+ disabled: s || g === e.id,
185
+ style: c.opcion(!!i),
186
+ onClick: async () => {
187
+ y(e.id);
188
+ try {
189
+ u(e.id, await B());
190
+ } catch (o) {
191
+ p(o instanceof Error ? o.message : "No se pudo obtener la ubicación");
192
+ } finally {
193
+ y(null);
194
+ }
195
+ },
196
+ children: g === e.id ? "Buscando…" : i ? "📍 Ubicación guardada (tocá para actualizar)" : "📍 Usar mi ubicación"
197
+ }
198
+ ) })
199
+ ] }, e.id);
200
+ }),
201
+ v && /* @__PURE__ */ a("p", { role: "alert", style: c.aviso, children: v }),
202
+ /* @__PURE__ */ a(
203
+ "button",
204
+ {
205
+ type: "button",
206
+ style: c.enviar,
207
+ disabled: s || x,
208
+ onClick: E,
209
+ children: x ? "Guardando…" : w
210
+ }
211
+ )
212
+ ] });
213
+ }
214
+ export {
215
+ j as FormRunner,
216
+ G as estaContestada,
217
+ q as faltantes,
218
+ z as prepararPreguntas
219
+ };
220
+ //# sourceMappingURL=mounajiformrunner.es.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mounajiformrunner.es.js","sources":["../src/logic.ts","../src/FormRunner.tsx"],"sourcesContent":["import type { Pregunta, Respuestas } from './types';\n\n/** Filtra inactivas y ordena por `orden` (estable para órdenes iguales). */\nexport function prepararPreguntas(preguntas: Pregunta[]): Pregunta[] {\n return preguntas\n .filter((p) => p.activa !== false)\n .map((p, i) => ({ p, i }))\n .sort((a, b) => (a.p.orden ?? 0) - (b.p.orden ?? 0) || a.i - b.i)\n .map(({ p }) => p);\n}\n\n/** Una respuesta cuenta como contestada si no es null/undefined/''. `false` y 0 SÍ cuentan. */\nexport function estaContestada(valor: unknown): boolean {\n return valor !== null && valor !== undefined && valor !== '';\n}\n\n/** Preguntas activas sin respuesta, en orden de formulario. */\nexport function faltantes(preguntas: Pregunta[], respuestas: Respuestas): Pregunta[] {\n return prepararPreguntas(preguntas).filter((p) => !estaContestada(respuestas[p.id]));\n}\n","import { useState } from 'react';\nimport type { Pregunta, Respuestas, Ubicacion, ValorRespuesta } from './types';\nimport { prepararPreguntas, faltantes } from './logic';\n\nexport interface FormRunnerProps {\n preguntas: Pregunta[];\n /** Respuestas iniciales (el estado vive adentro). */\n valoresIniciales?: Respuestas;\n onChange?: (respuestas: Respuestas) => void;\n onSubmit: (respuestas: Respuestas) => void | Promise<void>;\n /** Si es true, no deja enviar con preguntas sin contestar. Default false. */\n requerirTodas?: boolean;\n etiquetaEnviar?: string;\n deshabilitado?: boolean;\n /**\n * Captura de ubicación. Default: navigator.geolocation. Inyectable para\n * tests y para apps que traen la ubicación de un mapa en vez del GPS.\n */\n obtenerUbicacion?: () => Promise<Ubicacion>;\n}\n\nfunction ubicacionPorGps(): Promise<Ubicacion> {\n return new Promise((resolve, reject) => {\n if (!navigator.geolocation) {\n reject(new Error('Este dispositivo no comparte la ubicación'));\n return;\n }\n navigator.geolocation.getCurrentPosition(\n (pos) =>\n resolve({\n lat: pos.coords.latitude,\n lng: pos.coords.longitude,\n precision: pos.coords.accuracy,\n }),\n (err) => reject(new Error(err.message)),\n { enableHighAccuracy: true, timeout: 15000 },\n );\n });\n}\n\n// Estilos por token con fallback neutro: la app que consume define la marca.\nconst S = {\n bloque: { marginBottom: '1.25rem' } as const,\n etiqueta: {\n display: 'block',\n fontSize: 'var(--mn-form-label-size, 1.125rem)',\n fontWeight: 600,\n marginBottom: '0.5rem',\n } as const,\n entrada: {\n width: '100%',\n minHeight: '52px',\n fontSize: '1.125rem',\n padding: '0.6rem 0.8rem',\n borderRadius: 'var(--mn-radius, 0.75rem)',\n border: '1px solid var(--mn-border, #c9c9c9)',\n background: 'var(--mn-surface, #fff)',\n color: 'inherit',\n boxSizing: 'border-box',\n } as const,\n opcion: (activa: boolean) =>\n ({\n minHeight: '52px',\n padding: '0.6rem 1rem',\n marginRight: '0.5rem',\n marginBottom: '0.5rem',\n fontSize: '1.125rem',\n borderRadius: 'var(--mn-radius, 0.75rem)',\n border: activa\n ? '2px solid var(--mn-accent, #2563eb)'\n : '1px solid var(--mn-border, #c9c9c9)',\n background: activa ? 'var(--mn-accent-soft, #dbeafe)' : 'var(--mn-surface, #fff)',\n color: 'inherit',\n cursor: 'pointer',\n }) as const,\n enviar: {\n width: '100%',\n minHeight: '60px',\n fontSize: '1.25rem',\n fontWeight: 700,\n borderRadius: 'var(--mn-radius, 0.75rem)',\n border: 'none',\n background: 'var(--mn-accent, #2563eb)',\n color: 'var(--mn-accent-contrast, #fff)',\n cursor: 'pointer',\n } as const,\n aviso: { color: 'var(--mn-danger, #b91c1c)', fontSize: '1rem' } as const,\n};\n\nfunction Opciones({\n opciones,\n valor,\n onPick,\n deshabilitado,\n}: {\n opciones: string[];\n valor: ValorRespuesta;\n onPick: (v: string) => void;\n deshabilitado?: boolean;\n}) {\n return (\n <div>\n {opciones.map((op) => (\n <button\n key={op}\n type=\"button\"\n disabled={deshabilitado}\n style={S.opcion(valor === op)}\n onClick={() => onPick(op)}\n >\n {op}\n </button>\n ))}\n </div>\n );\n}\n\nexport function FormRunner({\n preguntas,\n valoresIniciales,\n onChange,\n onSubmit,\n requerirTodas = false,\n etiquetaEnviar = 'Guardar',\n deshabilitado = false,\n obtenerUbicacion = ubicacionPorGps,\n}: FormRunnerProps) {\n const [respuestas, setRespuestas] = useState<Respuestas>(valoresIniciales ?? {});\n const [aviso, setAviso] = useState<string | null>(null);\n const [buscandoGps, setBuscandoGps] = useState<string | null>(null);\n const [enviando, setEnviando] = useState(false);\n\n const activas = prepararPreguntas(preguntas);\n\n const setValor = (id: string, valor: ValorRespuesta) => {\n setRespuestas((prev) => {\n const next = { ...prev, [id]: valor };\n onChange?.(next);\n return next;\n });\n setAviso(null);\n };\n\n const enviar = async () => {\n if (requerirTodas) {\n const faltan = faltantes(preguntas, respuestas);\n if (faltan.length > 0) {\n setAviso(`Falta contestar: ${faltan.map((p) => p.texto).join(' · ')}`);\n return;\n }\n }\n setEnviando(true);\n try {\n await onSubmit(respuestas);\n } finally {\n setEnviando(false);\n }\n };\n\n return (\n <div>\n {activas.map((p) => {\n const valor = respuestas[p.id] ?? null;\n return (\n <div key={p.id} style={S.bloque}>\n <label style={S.etiqueta}>{p.texto}</label>\n\n {p.tipo === 'texto' && (\n <input\n type=\"text\"\n style={S.entrada}\n disabled={deshabilitado}\n value={typeof valor === 'string' ? valor : ''}\n onChange={(e) => setValor(p.id, e.target.value)}\n />\n )}\n\n {p.tipo === 'numero' && (\n <input\n type=\"number\"\n inputMode=\"numeric\"\n style={S.entrada}\n disabled={deshabilitado}\n value={typeof valor === 'number' ? valor : ''}\n onChange={(e) =>\n setValor(p.id, e.target.value === '' ? null : Number(e.target.value))\n }\n />\n )}\n\n {p.tipo === 'opciones' && (\n <Opciones\n opciones={p.opciones ?? []}\n valor={valor}\n onPick={(v) => setValor(p.id, v)}\n deshabilitado={deshabilitado}\n />\n )}\n\n {p.tipo === 'si_no' && (\n <Opciones\n opciones={['Sí', 'No']}\n valor={valor === true ? 'Sí' : valor === false ? 'No' : null}\n onPick={(v) => setValor(p.id, v === 'Sí')}\n deshabilitado={deshabilitado}\n />\n )}\n\n {p.tipo === 'foto' && (\n <input\n type=\"file\"\n accept=\"image/*\"\n capture=\"environment\"\n style={S.entrada}\n disabled={deshabilitado}\n onChange={(e) => setValor(p.id, e.target.files?.[0] ?? null)}\n />\n )}\n\n {p.tipo === 'ubicacion' && (\n <div>\n <button\n type=\"button\"\n disabled={deshabilitado || buscandoGps === p.id}\n style={S.opcion(Boolean(valor))}\n onClick={async () => {\n setBuscandoGps(p.id);\n try {\n setValor(p.id, await obtenerUbicacion());\n } catch (e) {\n setAviso(e instanceof Error ? e.message : 'No se pudo obtener la ubicación');\n } finally {\n setBuscandoGps(null);\n }\n }}\n >\n {buscandoGps === p.id\n ? 'Buscando…'\n : valor\n ? '📍 Ubicación guardada (tocá para actualizar)'\n : '📍 Usar mi ubicación'}\n </button>\n </div>\n )}\n </div>\n );\n })}\n\n {aviso && (\n <p role=\"alert\" style={S.aviso}>\n {aviso}\n </p>\n )}\n\n <button\n type=\"button\"\n style={S.enviar}\n disabled={deshabilitado || enviando}\n onClick={enviar}\n >\n {enviando ? 'Guardando…' : etiquetaEnviar}\n </button>\n </div>\n );\n}\n"],"names":["prepararPreguntas","preguntas","p","i","a","b","estaContestada","valor","faltantes","respuestas","ubicacionPorGps","resolve","reject","pos","err","S","activa","Opciones","opciones","onPick","deshabilitado","jsx","op","FormRunner","valoresIniciales","onChange","onSubmit","requerirTodas","etiquetaEnviar","obtenerUbicacion","setRespuestas","useState","aviso","setAviso","buscandoGps","setBuscandoGps","enviando","setEnviando","activas","setValor","id","prev","next","enviar","faltan","jsxs","e","v","_a"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ (function(c,n){typeof exports=="object"&&typeof module<"u"?n(exports,require("react/jsx-runtime"),require("react")):typeof define=="function"&&define.amd?define(["exports","react/jsx-runtime","react"],n):(c=typeof globalThis<"u"?globalThis:c||self,n(c.MounajiFormRunner={},c.jsxRuntime,c.React))})(this,(function(c,n,m){"use strict";function b(r){return r.filter(o=>o.activa!==!1).map((o,t)=>({p:o,i:t})).sort((o,t)=>(o.p.orden??0)-(t.p.orden??0)||o.i-t.i).map(({p:o})=>o)}function y(r){return r!=null&&r!==""}function h(r,o){return b(r).filter(t=>!y(o[t.id]))}function w(){return new Promise((r,o)=>{if(!navigator.geolocation){o(new Error("Este dispositivo no comparte la ubicación"));return}navigator.geolocation.getCurrentPosition(t=>r({lat:t.coords.latitude,lng:t.coords.longitude,precision:t.coords.accuracy}),t=>o(new Error(t.message)),{enableHighAccuracy:!0,timeout:15e3})})}const s={bloque:{marginBottom:"1.25rem"},etiqueta:{display:"block",fontSize:"var(--mn-form-label-size, 1.125rem)",fontWeight:600,marginBottom:"0.5rem"},entrada:{width:"100%",minHeight:"52px",fontSize:"1.125rem",padding:"0.6rem 0.8rem",borderRadius:"var(--mn-radius, 0.75rem)",border:"1px solid var(--mn-border, #c9c9c9)",background:"var(--mn-surface, #fff)",color:"inherit",boxSizing:"border-box"},opcion:r=>({minHeight:"52px",padding:"0.6rem 1rem",marginRight:"0.5rem",marginBottom:"0.5rem",fontSize:"1.125rem",borderRadius:"var(--mn-radius, 0.75rem)",border:r?"2px solid var(--mn-accent, #2563eb)":"1px solid var(--mn-border, #c9c9c9)",background:r?"var(--mn-accent-soft, #dbeafe)":"var(--mn-surface, #fff)",color:"inherit",cursor:"pointer"}),enviar:{width:"100%",minHeight:"60px",fontSize:"1.25rem",fontWeight:700,borderRadius:"var(--mn-radius, 0.75rem)",border:"none",background:"var(--mn-accent, #2563eb)",color:"var(--mn-accent-contrast, #fff)",cursor:"pointer"},aviso:{color:"var(--mn-danger, #b91c1c)",fontSize:"1rem"}};function x({opciones:r,valor:o,onPick:t,deshabilitado:p}){return n.jsx("div",{children:r.map(d=>n.jsx("button",{type:"button",disabled:p,style:s.opcion(o===d),onClick:()=>t(d),children:d},d))})}function q({preguntas:r,valoresIniciales:o,onChange:t,onSubmit:p,requerirTodas:d=!1,etiquetaEnviar:B="Guardar",deshabilitado:u=!1,obtenerUbicacion:C=w}){const[v,E]=m.useState(o??{}),[S,g]=m.useState(null),[k,z]=m.useState(null),[P,j]=m.useState(!1),G=b(r),l=(e,a)=>{E(i=>{const f={...i,[e]:a};return t==null||t(f),f}),g(null)},F=async()=>{if(d){const e=h(r,v);if(e.length>0){g(`Falta contestar: ${e.map(a=>a.texto).join(" · ")}`);return}}j(!0);try{await p(v)}finally{j(!1)}};return n.jsxs("div",{children:[G.map(e=>{const a=v[e.id]??null;return n.jsxs("div",{style:s.bloque,children:[n.jsx("label",{style:s.etiqueta,children:e.texto}),e.tipo==="texto"&&n.jsx("input",{type:"text",style:s.entrada,disabled:u,value:typeof a=="string"?a:"",onChange:i=>l(e.id,i.target.value)}),e.tipo==="numero"&&n.jsx("input",{type:"number",inputMode:"numeric",style:s.entrada,disabled:u,value:typeof a=="number"?a:"",onChange:i=>l(e.id,i.target.value===""?null:Number(i.target.value))}),e.tipo==="opciones"&&n.jsx(x,{opciones:e.opciones??[],valor:a,onPick:i=>l(e.id,i),deshabilitado:u}),e.tipo==="si_no"&&n.jsx(x,{opciones:["Sí","No"],valor:a===!0?"Sí":a===!1?"No":null,onPick:i=>l(e.id,i==="Sí"),deshabilitado:u}),e.tipo==="foto"&&n.jsx("input",{type:"file",accept:"image/*",capture:"environment",style:s.entrada,disabled:u,onChange:i=>{var f;return l(e.id,((f=i.target.files)==null?void 0:f[0])??null)}}),e.tipo==="ubicacion"&&n.jsx("div",{children:n.jsx("button",{type:"button",disabled:u||k===e.id,style:s.opcion(!!a),onClick:async()=>{z(e.id);try{l(e.id,await C())}catch(i){g(i instanceof Error?i.message:"No se pudo obtener la ubicación")}finally{z(null)}},children:k===e.id?"Buscando…":a?"📍 Ubicación guardada (tocá para actualizar)":"📍 Usar mi ubicación"})})]},e.id)}),S&&n.jsx("p",{role:"alert",style:s.aviso,children:S}),n.jsx("button",{type:"button",style:s.enviar,disabled:u||P,onClick:F,children:P?"Guardando…":B})]})}c.FormRunner=q,c.estaContestada=y,c.faltantes=h,c.prepararPreguntas=b,Object.defineProperty(c,Symbol.toStringTag,{value:"Module"})}));
2
+ //# sourceMappingURL=mounajiformrunner.umd.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mounajiformrunner.umd.cjs","sources":["../src/logic.ts","../src/FormRunner.tsx"],"sourcesContent":["import type { Pregunta, Respuestas } from './types';\n\n/** Filtra inactivas y ordena por `orden` (estable para órdenes iguales). */\nexport function prepararPreguntas(preguntas: Pregunta[]): Pregunta[] {\n return preguntas\n .filter((p) => p.activa !== false)\n .map((p, i) => ({ p, i }))\n .sort((a, b) => (a.p.orden ?? 0) - (b.p.orden ?? 0) || a.i - b.i)\n .map(({ p }) => p);\n}\n\n/** Una respuesta cuenta como contestada si no es null/undefined/''. `false` y 0 SÍ cuentan. */\nexport function estaContestada(valor: unknown): boolean {\n return valor !== null && valor !== undefined && valor !== '';\n}\n\n/** Preguntas activas sin respuesta, en orden de formulario. */\nexport function faltantes(preguntas: Pregunta[], respuestas: Respuestas): Pregunta[] {\n return prepararPreguntas(preguntas).filter((p) => !estaContestada(respuestas[p.id]));\n}\n","import { useState } from 'react';\nimport type { Pregunta, Respuestas, Ubicacion, ValorRespuesta } from './types';\nimport { prepararPreguntas, faltantes } from './logic';\n\nexport interface FormRunnerProps {\n preguntas: Pregunta[];\n /** Respuestas iniciales (el estado vive adentro). */\n valoresIniciales?: Respuestas;\n onChange?: (respuestas: Respuestas) => void;\n onSubmit: (respuestas: Respuestas) => void | Promise<void>;\n /** Si es true, no deja enviar con preguntas sin contestar. Default false. */\n requerirTodas?: boolean;\n etiquetaEnviar?: string;\n deshabilitado?: boolean;\n /**\n * Captura de ubicación. Default: navigator.geolocation. Inyectable para\n * tests y para apps que traen la ubicación de un mapa en vez del GPS.\n */\n obtenerUbicacion?: () => Promise<Ubicacion>;\n}\n\nfunction ubicacionPorGps(): Promise<Ubicacion> {\n return new Promise((resolve, reject) => {\n if (!navigator.geolocation) {\n reject(new Error('Este dispositivo no comparte la ubicación'));\n return;\n }\n navigator.geolocation.getCurrentPosition(\n (pos) =>\n resolve({\n lat: pos.coords.latitude,\n lng: pos.coords.longitude,\n precision: pos.coords.accuracy,\n }),\n (err) => reject(new Error(err.message)),\n { enableHighAccuracy: true, timeout: 15000 },\n );\n });\n}\n\n// Estilos por token con fallback neutro: la app que consume define la marca.\nconst S = {\n bloque: { marginBottom: '1.25rem' } as const,\n etiqueta: {\n display: 'block',\n fontSize: 'var(--mn-form-label-size, 1.125rem)',\n fontWeight: 600,\n marginBottom: '0.5rem',\n } as const,\n entrada: {\n width: '100%',\n minHeight: '52px',\n fontSize: '1.125rem',\n padding: '0.6rem 0.8rem',\n borderRadius: 'var(--mn-radius, 0.75rem)',\n border: '1px solid var(--mn-border, #c9c9c9)',\n background: 'var(--mn-surface, #fff)',\n color: 'inherit',\n boxSizing: 'border-box',\n } as const,\n opcion: (activa: boolean) =>\n ({\n minHeight: '52px',\n padding: '0.6rem 1rem',\n marginRight: '0.5rem',\n marginBottom: '0.5rem',\n fontSize: '1.125rem',\n borderRadius: 'var(--mn-radius, 0.75rem)',\n border: activa\n ? '2px solid var(--mn-accent, #2563eb)'\n : '1px solid var(--mn-border, #c9c9c9)',\n background: activa ? 'var(--mn-accent-soft, #dbeafe)' : 'var(--mn-surface, #fff)',\n color: 'inherit',\n cursor: 'pointer',\n }) as const,\n enviar: {\n width: '100%',\n minHeight: '60px',\n fontSize: '1.25rem',\n fontWeight: 700,\n borderRadius: 'var(--mn-radius, 0.75rem)',\n border: 'none',\n background: 'var(--mn-accent, #2563eb)',\n color: 'var(--mn-accent-contrast, #fff)',\n cursor: 'pointer',\n } as const,\n aviso: { color: 'var(--mn-danger, #b91c1c)', fontSize: '1rem' } as const,\n};\n\nfunction Opciones({\n opciones,\n valor,\n onPick,\n deshabilitado,\n}: {\n opciones: string[];\n valor: ValorRespuesta;\n onPick: (v: string) => void;\n deshabilitado?: boolean;\n}) {\n return (\n <div>\n {opciones.map((op) => (\n <button\n key={op}\n type=\"button\"\n disabled={deshabilitado}\n style={S.opcion(valor === op)}\n onClick={() => onPick(op)}\n >\n {op}\n </button>\n ))}\n </div>\n );\n}\n\nexport function FormRunner({\n preguntas,\n valoresIniciales,\n onChange,\n onSubmit,\n requerirTodas = false,\n etiquetaEnviar = 'Guardar',\n deshabilitado = false,\n obtenerUbicacion = ubicacionPorGps,\n}: FormRunnerProps) {\n const [respuestas, setRespuestas] = useState<Respuestas>(valoresIniciales ?? {});\n const [aviso, setAviso] = useState<string | null>(null);\n const [buscandoGps, setBuscandoGps] = useState<string | null>(null);\n const [enviando, setEnviando] = useState(false);\n\n const activas = prepararPreguntas(preguntas);\n\n const setValor = (id: string, valor: ValorRespuesta) => {\n setRespuestas((prev) => {\n const next = { ...prev, [id]: valor };\n onChange?.(next);\n return next;\n });\n setAviso(null);\n };\n\n const enviar = async () => {\n if (requerirTodas) {\n const faltan = faltantes(preguntas, respuestas);\n if (faltan.length > 0) {\n setAviso(`Falta contestar: ${faltan.map((p) => p.texto).join(' · ')}`);\n return;\n }\n }\n setEnviando(true);\n try {\n await onSubmit(respuestas);\n } finally {\n setEnviando(false);\n }\n };\n\n return (\n <div>\n {activas.map((p) => {\n const valor = respuestas[p.id] ?? null;\n return (\n <div key={p.id} style={S.bloque}>\n <label style={S.etiqueta}>{p.texto}</label>\n\n {p.tipo === 'texto' && (\n <input\n type=\"text\"\n style={S.entrada}\n disabled={deshabilitado}\n value={typeof valor === 'string' ? valor : ''}\n onChange={(e) => setValor(p.id, e.target.value)}\n />\n )}\n\n {p.tipo === 'numero' && (\n <input\n type=\"number\"\n inputMode=\"numeric\"\n style={S.entrada}\n disabled={deshabilitado}\n value={typeof valor === 'number' ? valor : ''}\n onChange={(e) =>\n setValor(p.id, e.target.value === '' ? null : Number(e.target.value))\n }\n />\n )}\n\n {p.tipo === 'opciones' && (\n <Opciones\n opciones={p.opciones ?? []}\n valor={valor}\n onPick={(v) => setValor(p.id, v)}\n deshabilitado={deshabilitado}\n />\n )}\n\n {p.tipo === 'si_no' && (\n <Opciones\n opciones={['Sí', 'No']}\n valor={valor === true ? 'Sí' : valor === false ? 'No' : null}\n onPick={(v) => setValor(p.id, v === 'Sí')}\n deshabilitado={deshabilitado}\n />\n )}\n\n {p.tipo === 'foto' && (\n <input\n type=\"file\"\n accept=\"image/*\"\n capture=\"environment\"\n style={S.entrada}\n disabled={deshabilitado}\n onChange={(e) => setValor(p.id, e.target.files?.[0] ?? null)}\n />\n )}\n\n {p.tipo === 'ubicacion' && (\n <div>\n <button\n type=\"button\"\n disabled={deshabilitado || buscandoGps === p.id}\n style={S.opcion(Boolean(valor))}\n onClick={async () => {\n setBuscandoGps(p.id);\n try {\n setValor(p.id, await obtenerUbicacion());\n } catch (e) {\n setAviso(e instanceof Error ? e.message : 'No se pudo obtener la ubicación');\n } finally {\n setBuscandoGps(null);\n }\n }}\n >\n {buscandoGps === p.id\n ? 'Buscando…'\n : valor\n ? '📍 Ubicación guardada (tocá para actualizar)'\n : '📍 Usar mi ubicación'}\n </button>\n </div>\n )}\n </div>\n );\n })}\n\n {aviso && (\n <p role=\"alert\" style={S.aviso}>\n {aviso}\n </p>\n )}\n\n <button\n type=\"button\"\n style={S.enviar}\n disabled={deshabilitado || enviando}\n onClick={enviar}\n >\n {enviando ? 'Guardando…' : etiquetaEnviar}\n </button>\n </div>\n );\n}\n"],"names":["prepararPreguntas","preguntas","p","i","a","b","estaContestada","valor","faltantes","respuestas","ubicacionPorGps","resolve","reject","pos","err","S","activa","Opciones","opciones","onPick","deshabilitado","jsx","op","FormRunner","valoresIniciales","onChange","onSubmit","requerirTodas","etiquetaEnviar","obtenerUbicacion","setRespuestas","useState","aviso","setAviso","buscandoGps","setBuscandoGps","enviando","setEnviando","activas","setValor","id","prev","next","enviar","faltan","jsxs","e","v","_a"],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import type { Pregunta, Respuestas, Ubicacion } from './types';
2
+ export interface FormRunnerProps {
3
+ preguntas: Pregunta[];
4
+ /** Respuestas iniciales (el estado vive adentro). */
5
+ valoresIniciales?: Respuestas;
6
+ onChange?: (respuestas: Respuestas) => void;
7
+ onSubmit: (respuestas: Respuestas) => void | Promise<void>;
8
+ /** Si es true, no deja enviar con preguntas sin contestar. Default false. */
9
+ requerirTodas?: boolean;
10
+ etiquetaEnviar?: string;
11
+ deshabilitado?: boolean;
12
+ /**
13
+ * Captura de ubicación. Default: navigator.geolocation. Inyectable para
14
+ * tests y para apps que traen la ubicación de un mapa en vez del GPS.
15
+ */
16
+ obtenerUbicacion?: () => Promise<Ubicacion>;
17
+ }
18
+ export declare function FormRunner({ preguntas, valoresIniciales, onChange, onSubmit, requerirTodas, etiquetaEnviar, deshabilitado, obtenerUbicacion, }: FormRunnerProps): import("react").JSX.Element;
19
+ //# sourceMappingURL=FormRunner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FormRunner.d.ts","sourceRoot":"","sources":["../../src/FormRunner.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAkB,MAAM,SAAS,CAAC;AAG/E,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,UAAU,CAAC;IAC9B,QAAQ,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAC;IAC5C,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,6EAA6E;IAC7E,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,CAAC;CAC7C;AAkGD,wBAAgB,UAAU,CAAC,EACzB,SAAS,EACT,gBAAgB,EAChB,QAAQ,EACR,QAAQ,EACR,aAAqB,EACrB,cAA0B,EAC1B,aAAqB,EACrB,gBAAkC,GACnC,EAAE,eAAe,+BA0IjB"}
@@ -0,0 +1,5 @@
1
+ export { FormRunner } from './FormRunner';
2
+ export type { FormRunnerProps } from './FormRunner';
3
+ export { prepararPreguntas, estaContestada, faltantes } from './logic';
4
+ export type { Pregunta, TipoPregunta, Respuestas, ValorRespuesta, Ubicacion, } from './types';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACvE,YAAY,EACV,QAAQ,EACR,YAAY,EACZ,UAAU,EACV,cAAc,EACd,SAAS,GACV,MAAM,SAAS,CAAC"}
@@ -0,0 +1,8 @@
1
+ import type { Pregunta, Respuestas } from './types';
2
+ /** Filtra inactivas y ordena por `orden` (estable para órdenes iguales). */
3
+ export declare function prepararPreguntas(preguntas: Pregunta[]): Pregunta[];
4
+ /** Una respuesta cuenta como contestada si no es null/undefined/''. `false` y 0 SÍ cuentan. */
5
+ export declare function estaContestada(valor: unknown): boolean;
6
+ /** Preguntas activas sin respuesta, en orden de formulario. */
7
+ export declare function faltantes(preguntas: Pregunta[], respuestas: Respuestas): Pregunta[];
8
+ //# sourceMappingURL=logic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logic.d.ts","sourceRoot":"","sources":["../../src/logic.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAEpD,4EAA4E;AAC5E,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,QAAQ,EAAE,GAAG,QAAQ,EAAE,CAMnE;AAED,+FAA+F;AAC/F,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEtD;AAED,+DAA+D;AAC/D,wBAAgB,SAAS,CAAC,SAAS,EAAE,QAAQ,EAAE,EAAE,UAAU,EAAE,UAAU,GAAG,QAAQ,EAAE,CAEnF"}
@@ -0,0 +1,20 @@
1
+ export type TipoPregunta = 'texto' | 'opciones' | 'numero' | 'si_no' | 'foto' | 'ubicacion';
2
+ export interface Pregunta {
3
+ id: string;
4
+ texto: string;
5
+ tipo: TipoPregunta;
6
+ /** Solo para tipo 'opciones'. */
7
+ opciones?: string[] | null;
8
+ orden?: number;
9
+ activa?: boolean;
10
+ }
11
+ export interface Ubicacion {
12
+ lat: number;
13
+ lng: number;
14
+ /** Precisión en metros, si el dispositivo la informa. */
15
+ precision?: number;
16
+ }
17
+ export type ValorRespuesta = string | number | boolean | Ubicacion | File | null;
18
+ /** Respuestas indexadas por id de pregunta. */
19
+ export type Respuestas = Record<string, ValorRespuesta>;
20
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,WAAW,CAAC;AAE5F,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,CAAC;IACnB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CAAC;AAEjF,+CAA+C;AAC/C,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@mounaji_npm/form-runner",
3
+ "version": "0.1.0",
4
+ "description": "Renders a form from a question catalog (data, not code): texto/opciones/numero/si_no/foto/ubicacion. Headless-friendly, themed via --mn-* tokens. Born for censo-totoral; serves surveys, wizards and onboarding.",
5
+ "type": "module",
6
+ "main": "./dist/mounajiformrunner.umd.cjs",
7
+ "module": "./dist/mounajiformrunner.es.js",
8
+ "types": "./dist/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/types/index.d.ts",
12
+ "import": "./dist/mounajiformrunner.es.js",
13
+ "require": "./dist/mounajiformrunner.umd.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "build": "vite build && tsc -p tsconfig.build.json",
22
+ "dev": "vite build --watch",
23
+ "typecheck": "tsc -p tsconfig.json",
24
+ "test": "npm run build && node --test \"tests/*.test.mjs\""
25
+ },
26
+ "keywords": [
27
+ "mounaji",
28
+ "form",
29
+ "survey",
30
+ "census",
31
+ "dynamic-form",
32
+ "design-tokens"
33
+ ],
34
+ "license": "MIT",
35
+ "peerDependencies": {
36
+ "react": ">=18",
37
+ "react-dom": ">=18"
38
+ }
39
+ }
@@ -0,0 +1,265 @@
1
+ import { useState } from 'react';
2
+ import type { Pregunta, Respuestas, Ubicacion, ValorRespuesta } from './types';
3
+ import { prepararPreguntas, faltantes } from './logic';
4
+
5
+ export interface FormRunnerProps {
6
+ preguntas: Pregunta[];
7
+ /** Respuestas iniciales (el estado vive adentro). */
8
+ valoresIniciales?: Respuestas;
9
+ onChange?: (respuestas: Respuestas) => void;
10
+ onSubmit: (respuestas: Respuestas) => void | Promise<void>;
11
+ /** Si es true, no deja enviar con preguntas sin contestar. Default false. */
12
+ requerirTodas?: boolean;
13
+ etiquetaEnviar?: string;
14
+ deshabilitado?: boolean;
15
+ /**
16
+ * Captura de ubicación. Default: navigator.geolocation. Inyectable para
17
+ * tests y para apps que traen la ubicación de un mapa en vez del GPS.
18
+ */
19
+ obtenerUbicacion?: () => Promise<Ubicacion>;
20
+ }
21
+
22
+ function ubicacionPorGps(): Promise<Ubicacion> {
23
+ return new Promise((resolve, reject) => {
24
+ if (!navigator.geolocation) {
25
+ reject(new Error('Este dispositivo no comparte la ubicación'));
26
+ return;
27
+ }
28
+ navigator.geolocation.getCurrentPosition(
29
+ (pos) =>
30
+ resolve({
31
+ lat: pos.coords.latitude,
32
+ lng: pos.coords.longitude,
33
+ precision: pos.coords.accuracy,
34
+ }),
35
+ (err) => reject(new Error(err.message)),
36
+ { enableHighAccuracy: true, timeout: 15000 },
37
+ );
38
+ });
39
+ }
40
+
41
+ // Estilos por token con fallback neutro: la app que consume define la marca.
42
+ const S = {
43
+ bloque: { marginBottom: '1.25rem' } as const,
44
+ etiqueta: {
45
+ display: 'block',
46
+ fontSize: 'var(--mn-form-label-size, 1.125rem)',
47
+ fontWeight: 600,
48
+ marginBottom: '0.5rem',
49
+ } as const,
50
+ entrada: {
51
+ width: '100%',
52
+ minHeight: '52px',
53
+ fontSize: '1.125rem',
54
+ padding: '0.6rem 0.8rem',
55
+ borderRadius: 'var(--mn-radius, 0.75rem)',
56
+ border: '1px solid var(--mn-border, #c9c9c9)',
57
+ background: 'var(--mn-surface, #fff)',
58
+ color: 'inherit',
59
+ boxSizing: 'border-box',
60
+ } as const,
61
+ opcion: (activa: boolean) =>
62
+ ({
63
+ minHeight: '52px',
64
+ padding: '0.6rem 1rem',
65
+ marginRight: '0.5rem',
66
+ marginBottom: '0.5rem',
67
+ fontSize: '1.125rem',
68
+ borderRadius: 'var(--mn-radius, 0.75rem)',
69
+ border: activa
70
+ ? '2px solid var(--mn-accent, #2563eb)'
71
+ : '1px solid var(--mn-border, #c9c9c9)',
72
+ background: activa ? 'var(--mn-accent-soft, #dbeafe)' : 'var(--mn-surface, #fff)',
73
+ color: 'inherit',
74
+ cursor: 'pointer',
75
+ }) as const,
76
+ enviar: {
77
+ width: '100%',
78
+ minHeight: '60px',
79
+ fontSize: '1.25rem',
80
+ fontWeight: 700,
81
+ borderRadius: 'var(--mn-radius, 0.75rem)',
82
+ border: 'none',
83
+ background: 'var(--mn-accent, #2563eb)',
84
+ color: 'var(--mn-accent-contrast, #fff)',
85
+ cursor: 'pointer',
86
+ } as const,
87
+ aviso: { color: 'var(--mn-danger, #b91c1c)', fontSize: '1rem' } as const,
88
+ };
89
+
90
+ function Opciones({
91
+ opciones,
92
+ valor,
93
+ onPick,
94
+ deshabilitado,
95
+ }: {
96
+ opciones: string[];
97
+ valor: ValorRespuesta;
98
+ onPick: (v: string) => void;
99
+ deshabilitado?: boolean;
100
+ }) {
101
+ return (
102
+ <div>
103
+ {opciones.map((op) => (
104
+ <button
105
+ key={op}
106
+ type="button"
107
+ disabled={deshabilitado}
108
+ style={S.opcion(valor === op)}
109
+ onClick={() => onPick(op)}
110
+ >
111
+ {op}
112
+ </button>
113
+ ))}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ export function FormRunner({
119
+ preguntas,
120
+ valoresIniciales,
121
+ onChange,
122
+ onSubmit,
123
+ requerirTodas = false,
124
+ etiquetaEnviar = 'Guardar',
125
+ deshabilitado = false,
126
+ obtenerUbicacion = ubicacionPorGps,
127
+ }: FormRunnerProps) {
128
+ const [respuestas, setRespuestas] = useState<Respuestas>(valoresIniciales ?? {});
129
+ const [aviso, setAviso] = useState<string | null>(null);
130
+ const [buscandoGps, setBuscandoGps] = useState<string | null>(null);
131
+ const [enviando, setEnviando] = useState(false);
132
+
133
+ const activas = prepararPreguntas(preguntas);
134
+
135
+ const setValor = (id: string, valor: ValorRespuesta) => {
136
+ setRespuestas((prev) => {
137
+ const next = { ...prev, [id]: valor };
138
+ onChange?.(next);
139
+ return next;
140
+ });
141
+ setAviso(null);
142
+ };
143
+
144
+ const enviar = async () => {
145
+ if (requerirTodas) {
146
+ const faltan = faltantes(preguntas, respuestas);
147
+ if (faltan.length > 0) {
148
+ setAviso(`Falta contestar: ${faltan.map((p) => p.texto).join(' · ')}`);
149
+ return;
150
+ }
151
+ }
152
+ setEnviando(true);
153
+ try {
154
+ await onSubmit(respuestas);
155
+ } finally {
156
+ setEnviando(false);
157
+ }
158
+ };
159
+
160
+ return (
161
+ <div>
162
+ {activas.map((p) => {
163
+ const valor = respuestas[p.id] ?? null;
164
+ return (
165
+ <div key={p.id} style={S.bloque}>
166
+ <label style={S.etiqueta}>{p.texto}</label>
167
+
168
+ {p.tipo === 'texto' && (
169
+ <input
170
+ type="text"
171
+ style={S.entrada}
172
+ disabled={deshabilitado}
173
+ value={typeof valor === 'string' ? valor : ''}
174
+ onChange={(e) => setValor(p.id, e.target.value)}
175
+ />
176
+ )}
177
+
178
+ {p.tipo === 'numero' && (
179
+ <input
180
+ type="number"
181
+ inputMode="numeric"
182
+ style={S.entrada}
183
+ disabled={deshabilitado}
184
+ value={typeof valor === 'number' ? valor : ''}
185
+ onChange={(e) =>
186
+ setValor(p.id, e.target.value === '' ? null : Number(e.target.value))
187
+ }
188
+ />
189
+ )}
190
+
191
+ {p.tipo === 'opciones' && (
192
+ <Opciones
193
+ opciones={p.opciones ?? []}
194
+ valor={valor}
195
+ onPick={(v) => setValor(p.id, v)}
196
+ deshabilitado={deshabilitado}
197
+ />
198
+ )}
199
+
200
+ {p.tipo === 'si_no' && (
201
+ <Opciones
202
+ opciones={['Sí', 'No']}
203
+ valor={valor === true ? 'Sí' : valor === false ? 'No' : null}
204
+ onPick={(v) => setValor(p.id, v === 'Sí')}
205
+ deshabilitado={deshabilitado}
206
+ />
207
+ )}
208
+
209
+ {p.tipo === 'foto' && (
210
+ <input
211
+ type="file"
212
+ accept="image/*"
213
+ capture="environment"
214
+ style={S.entrada}
215
+ disabled={deshabilitado}
216
+ onChange={(e) => setValor(p.id, e.target.files?.[0] ?? null)}
217
+ />
218
+ )}
219
+
220
+ {p.tipo === 'ubicacion' && (
221
+ <div>
222
+ <button
223
+ type="button"
224
+ disabled={deshabilitado || buscandoGps === p.id}
225
+ style={S.opcion(Boolean(valor))}
226
+ onClick={async () => {
227
+ setBuscandoGps(p.id);
228
+ try {
229
+ setValor(p.id, await obtenerUbicacion());
230
+ } catch (e) {
231
+ setAviso(e instanceof Error ? e.message : 'No se pudo obtener la ubicación');
232
+ } finally {
233
+ setBuscandoGps(null);
234
+ }
235
+ }}
236
+ >
237
+ {buscandoGps === p.id
238
+ ? 'Buscando…'
239
+ : valor
240
+ ? '📍 Ubicación guardada (tocá para actualizar)'
241
+ : '📍 Usar mi ubicación'}
242
+ </button>
243
+ </div>
244
+ )}
245
+ </div>
246
+ );
247
+ })}
248
+
249
+ {aviso && (
250
+ <p role="alert" style={S.aviso}>
251
+ {aviso}
252
+ </p>
253
+ )}
254
+
255
+ <button
256
+ type="button"
257
+ style={S.enviar}
258
+ disabled={deshabilitado || enviando}
259
+ onClick={enviar}
260
+ >
261
+ {enviando ? 'Guardando…' : etiquetaEnviar}
262
+ </button>
263
+ </div>
264
+ );
265
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { FormRunner } from './FormRunner';
2
+ export type { FormRunnerProps } from './FormRunner';
3
+ export { prepararPreguntas, estaContestada, faltantes } from './logic';
4
+ export type {
5
+ Pregunta,
6
+ TipoPregunta,
7
+ Respuestas,
8
+ ValorRespuesta,
9
+ Ubicacion,
10
+ } from './types';
package/src/logic.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { Pregunta, Respuestas } from './types';
2
+
3
+ /** Filtra inactivas y ordena por `orden` (estable para órdenes iguales). */
4
+ export function prepararPreguntas(preguntas: Pregunta[]): Pregunta[] {
5
+ return preguntas
6
+ .filter((p) => p.activa !== false)
7
+ .map((p, i) => ({ p, i }))
8
+ .sort((a, b) => (a.p.orden ?? 0) - (b.p.orden ?? 0) || a.i - b.i)
9
+ .map(({ p }) => p);
10
+ }
11
+
12
+ /** Una respuesta cuenta como contestada si no es null/undefined/''. `false` y 0 SÍ cuentan. */
13
+ export function estaContestada(valor: unknown): boolean {
14
+ return valor !== null && valor !== undefined && valor !== '';
15
+ }
16
+
17
+ /** Preguntas activas sin respuesta, en orden de formulario. */
18
+ export function faltantes(preguntas: Pregunta[], respuestas: Respuestas): Pregunta[] {
19
+ return prepararPreguntas(preguntas).filter((p) => !estaContestada(respuestas[p.id]));
20
+ }
package/src/types.ts ADDED
@@ -0,0 +1,23 @@
1
+ export type TipoPregunta = 'texto' | 'opciones' | 'numero' | 'si_no' | 'foto' | 'ubicacion';
2
+
3
+ export interface Pregunta {
4
+ id: string;
5
+ texto: string;
6
+ tipo: TipoPregunta;
7
+ /** Solo para tipo 'opciones'. */
8
+ opciones?: string[] | null;
9
+ orden?: number;
10
+ activa?: boolean;
11
+ }
12
+
13
+ export interface Ubicacion {
14
+ lat: number;
15
+ lng: number;
16
+ /** Precisión en metros, si el dispositivo la informa. */
17
+ precision?: number;
18
+ }
19
+
20
+ export type ValorRespuesta = string | number | boolean | Ubicacion | File | null;
21
+
22
+ /** Respuestas indexadas por id de pregunta. */
23
+ export type Respuestas = Record<string, ValorRespuesta>;