React y Next.js
El visor de cotizaciones, el Builder listo para usar y su estado completo como hook.
Instalación
npm install @flouviahq/elements
Los componentes funcionan en React 17, 18 y 19, y en el App Router de Next.js (llevan 'use client').
Para probarlo sin instalar nada, abre un ejemplo completo en StackBlitz: el de Vite tiene el visor y el Builder, y el de Next.js 15 el proxy de servidor y los webhooks.
- Vite + ReactEl visor con su estado en vivo y el Builder con tu llave de prueba.
- Next.js 15El proxy, los webhooks, el Builder y el visor de cotizaciones en Next.js 15.
Necesitas el token de una cotización de tu cuenta; el proyecto te lo pide al abrir.
Tu navegador no dejó abrir StackBlitz desde esta página. Los mismos archivos están en la carpeta examples del paquete.
El proveedor: <CordProvider>
El Builder y los hooks necesitan un <CordProvider> arriba en el árbol. Tiene dos modos y no puedes pasar los dos a la vez:
- Publishable (
publishableKey): el navegador habla directo con Cord con una llavepk_live_…opk_test_…. Puede leer el catálogo (sin tu costo) y crear borradores de cotización, pero nunca lee tu CRM. - Proxy (
proxyUrl): el navegador habla con tu backend, que llama a Cord con tu Secret Key (concreateElementsProxyde@flouviahq/node). Es el modo para leer los clientes de tu CRM y, si tu proxy lo autoriza, crear cotizaciones con datos de vendedor.
// app/layout.tsx
import type { ReactNode } from 'react';
import { CordProvider } from '@flouviahq/elements/react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<CordProvider publishableKey={process.env.NEXT_PUBLIC_CORD_PUBLISHABLE_KEY!} locale="es">
{children}
</CordProvider>
);
}
<CordProvider proxyUrl="/api/cord">{children}</CordProvider>
locale (es, en, pt, fr o de) es el idioma de los componentes, y appearance su apariencia.
<CordCotizador> y <CordInvoice>: el visor
Si ya tienes el token de una cotización y solo quieres mostrarla para que el cliente la apruebe y la pague, usa el visor: un iframe de Cord que se dibuja con tu marca.
<CordCotizador> y <CordInvoice> no necesitan un <CordProvider>: el token de la cotización es su propia credencial.
import { CordCotizador } from '@flouviahq/elements/react/viewer';
export function QuotePage({ token }: { token: string }) {
return (
<CordCotizador
token={token}
onApproved={(detail) => console.log('Aprobada por', detail.signed_by)}
onPay={(detail) => console.log('Pago abierto en', detail.url)}
/>
);
}
@flouviahq/elements/react/viewer trae solo el visor; @flouviahq/elements/react exporta los mismos componentes junto con el Builder. Si tu página no arma cotizaciones, importa desde /react/viewer y no descargas el Builder ni el formulario fiscal.
| Prop | Qué hace |
|---|---|
token |
Obligatorio. El token público de la cotización, el de su link /q/{token}. |
appearance |
La apariencia de esta instancia; sin ella hereda la del <CordProvider>. Cambiarla no recarga el documento. |
minHeight |
Alto en píxeles mientras carga (420 por defecto). Cambiarlo no recarga el documento. |
loading |
lazy (por defecto) espera a que el iframe se acerque a la pantalla; eager lo carga de inmediato, para un modal o una pestaña oculta. Se lee al montar. |
readyTimeout |
Milisegundos para recibir onReady antes de onError con timeout: 10000 por defecto y 0 lo desactiva. Con lazy cuenta desde que el iframe se ve. Se lee al montar. |
onReady |
El documento terminó de cargar. Trae livemode y, con dominios permitidos, status, folio, moneda y total. |
onChange |
El estado en vivo cada vez que cambia: ready, status, total, approved, paid, invoiced, livemode, error y los demás campos de la tabla del estado. onStateChange sigue como alias hasta la 3.0. |
onError |
El documento no se pudo mostrar o ya no se puede aprobar: code es not_found, expired, frame_blocked o timeout (ver Errores de carga). |
onApproved, onRejected, onPay, onMessage… |
Lo que hace el cliente; la lista completa está en Eventos. |
baseUrl, className, style |
El origen de Cord y los estilos del contenedor. Cambiar token, baseUrl o document carga otro documento. |
import { useState } from 'react';
import { CordCotizador } from '@flouviahq/elements/react/viewer';
export function CotizacionEnModal({ token }: { token: string }) {
const [aviso, setAviso] = useState<string | null>(null);
return (
<>
{aviso && <p role="alert">{aviso}</p>}
<CordCotizador
token={token}
loading="eager"
readyTimeout={8000}
onChange={(s) => { if (s.paid) setAviso(`Pagada: ${s.folio}`); }}
onError={({ code }) => setAviso(code === 'expired' ? 'La cotización venció.' : 'No pudimos mostrar la cotización.')}
/>
</>
);
}
<CordInvoice> hace lo mismo con una factura (/i/{token}), acepta los mismos props y suma onPaid:
import { CordInvoice } from '@flouviahq/elements/react/viewer';
<CordInvoice token={invoiceToken} onPaid={(d) => console.log('Pagada', d.folio)} />
En Vue, CordInvoice de @flouviahq/elements/vue emite paid.
<CordBuilder>: crear cotizaciones
<CordBuilder> es el formulario completo para armar una cotización: cliente, condiciones, partidas con buscador de productos, impuesto por línea, totales y envío. Las divisas, los impuestos, los términos y la vigencia salen de la configuración de tu organización en Cord.
import { CordBuilder } from '@flouviahq/elements/react';
export function NewQuote() {
return <CordBuilder fiscal onQuoteCreated={(q) => console.log('Creada', q.folio, q.link_publico)} />;
}
| Prop | Qué hace |
|---|---|
onQuoteCreated |
Se llama con la cotización creada: id, folio, token, link_publico, subtotal, total y moneda. |
catalog |
Tu propio catálogo; sin él se usa el de Cord, completo. Puede llegar después de montar (por ejemplo, [] mientras lo cargas). |
clients |
Clientes para sugerir en el nombre (de tu CRM, o en modo proxy). |
fiscal |
Muestra los datos fiscales del receptor. |
ai |
Muestra la zona para llenar las partidas con IA desde un pedido escrito, una foto o un PDF. |
className, style |
En la raíz del Builder. |
Con hijos, el Builder es compuesto: dibujas solo las partes que quieres, en el orden que quieras. Las partes son Header, Fiscal, Config, Items, Notes, Summary, AiDrop y SubmitButton, y cada una acepta className y style.
<CordBuilder onQuoteCreated={(q) => router.push(`/cotizaciones/${q.id}`)}>
<CordBuilder.Header />
<CordBuilder.Items className="mi-clase" />
<CordBuilder.Summary />
<CordBuilder.SubmitButton className="btn btn-primary" />
</CordBuilder>
- Teclado. El buscador de productos de cada partida sigue el patrón combobox: las flechas recorren los resultados, Enter elige y Escape cierra. Con el ratón se elige con un clic.
- Varias instancias. Cada
<CordBuilder>genera sus propios ids, así que puedes tener dos en la misma página. - Los totales son los que guarda Cord. El Builder redondea con los decimales de la divisa (0 en CLP y JPY) y con la regla de impuesto del país (en Chile, el IVA del documento), igual que el servidor al crear la cotización.
Estilos
Cada nodo lleva una clase estable .cord-<elemento> (.cord-builderRoot, .cord-itemRow, .cord-submitButton…). La hoja por defecto va en @layer cord y se inserta al principio del <head>, así que tu CSS (Tailwind, CSS Modules o CSS normal) siempre gana, sin !important. Con appearance={{ baseTheme: 'none' }} no se inserta ninguna hoja.
Los nodos internos no llevan estilos en línea. Lo único en línea son las variables --cord-* de tu appearance, en la raíz del Builder, y los estilos que tú pases en style o en appearance.elements.
El tema va en el atributo data-cord-theme de la raíz: dark, auto (sigue al sistema) o flat (sin sombra). La hoja por defecto lo pinta con CSS, sin JavaScript, así que el HTML que renderiza tu servidor y el del navegador son iguales y Next.js hidrata sin desajustes.
useQuoteBuilder: tu propia interfaz
Si quieres dibujar la interfaz tú, useQuoteBuilder te da el mismo estado y las mismas reglas que usa <CordBuilder>. El estado se lee del objeto que devuelve; los cambios se hacen con los métodos de builder.
'use client';
import { useQuoteBuilder } from '@flouviahq/elements/react';
import type { CordProduct } from '@flouviahq/elements';
export function MiBuilder({ productos }: { productos: CordProduct[] }) {
const { builder, cliente, items, totals, status, taxLabel, formatMoney, issueFor } = useQuoteBuilder({
catalog: productos,
onQuoteCreated: (q) => console.log('Link', q.link_publico, 'total', q.total, q.moneda),
});
return (
<form onSubmit={(e) => { e.preventDefault(); void builder.submit(); }}>
<input value={cliente.empresa} onChange={(e) => builder.setCliente({ empresa: e.target.value })} />
{issueFor('cliente.empresa') && <p role="alert">{issueFor('cliente.empresa')}</p>}
{items.map((item) => (
<div key={item.key}>
<input value={item.descripcion} onChange={(e) => builder.updateItem(item.key, { descripcion: e.target.value })} />
<input type="number" value={item.cantidad} onChange={(e) => builder.updateItem(item.key, { cantidad: Number(e.target.value) })} />
<button type="button" onClick={() => builder.removeItem(item.key)}>Quitar</button>
</div>
))}
<button type="button" onClick={() => builder.addItem()}>Agregar partida</button>
<p>{taxLabel}: {formatMoney(totals.impuestos)}</p>
<p>Total: {formatMoney(totals.total)}</p>
<button type="submit" disabled={status === 'submitting'}>Crear cotización</button>
</form>
);
}
Lo que devuelve:
| Campo | Qué es |
|---|---|
status |
loading, ready, submitting, success o error. |
config |
La configuración de tu organización: divisas, impuestos, términos y vigencia. |
cliente, items, moneda, terminos, vigencia_dias, notas, precios_incluyen_impuesto, cupon |
Lo capturado. Cada partida tiene su key. |
totals |
subtotal, impuestos, total, porTasa y retenciones, del mismo motor que el servidor. |
products |
El catálogo completo; con el de Cord llega por páginas y crece mientras carga. |
issues |
Problemas por campo: { field, code }. |
error, result |
El último error (CordError) y la cotización creada. |
builder |
Los métodos: setCliente, setMoneda, setTerminos, setVigencia, setNotas, setPreciosIncluyenImpuesto, setCupon, addItem, addProduct, updateItem, removeItem, searchProducts, setCatalog, validate, submit, reset y draftWithAi. |
t, taxLabel, formatMoney, issueFor |
Los textos en el idioma del proveedor, el nombre del impuesto del país (IVA, VAT, GST), el formato de importes en la divisa elegida y el mensaje traducido del problema de un campo. |
- Cupón.
totalsno incluye el cupón: su valor solo lo conoce el servidor. La cotización creada (result, y lo que recibeonQuoteCreated) traesubtotal,totalymonedaya con el cupón aplicado. - Ciclo de vida. El hook carga la configuración y el catálogo en un efecto y cancela lo que esté en curso al desmontarse. En
StrictModela carga que React descarta queda cancelada y no aparece como error. - Fuera de React. El mismo estado existe sin React en
@flouviahq/elements/headless(createQuoteBuilder); ver Headless.
ivaPct ya no se usa. Cada partida elige su tasa del catálogo de impuestos de tu organización, que llega en la configuración. Si todavía pasas ivaPct a <CordProvider>, se ignora y solo verás un aviso en la consola en desarrollo. Quítalo.
Errores
Maneja los errores por code, nunca por el mensaje. error.code es un CordErrorCode y un código nuevo del servidor llega como texto, sin romper tu tipo. Cuando el servidor rechaza un campo, el Builder lo pasa a issues con su campo, y issueFor(campo) te da el mensaje.
| Código | Cuándo |
|---|---|
network_error |
No hubo respuesta de Cord (el Builder muestra su propio mensaje). |
unsupported_currency |
La divisa no es una de las que ofrece tu organización (campo moneda). |
unknown_tax_rate |
Una partida trae una tasa que tu catálogo de impuestos no tiene. |
invalid_fiscal_data |
Los datos fiscales no son válidos; cada campo llega en issues como fiscal.<campo>. |
coupon_vencido, coupon_agotado, coupon_no_existe… |
El cupón no se pudo aplicar (campo cupon). |
ai_unavailable, ai_quota_exceeded, no_items |
El borrador con IA no está disponible, se acabó el cupo del mes o el pedido no trae partidas. |
rate_limited |
Demasiadas peticiones; el cliente reintenta solo con Retry-After. |
Cada error trae requestId (req_…); inclúyelo si escribes a soporte.
Otros hooks
useCordCatalog(): el catálogo completo, conproducts,isLoading,erroryrefetch.useCordClients(): tus clientes, solo en modo proxy (con unapk_respondeclients_require_proxy).useCreateQuote(): crea una cotización con tus propios datos; un doble clic no crea dos.
useCordCatalog y useCordClients cancelan su petición al desmontarse o al volver a pedir.