Saltar al contenido

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.

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 llave pk_live_… o pk_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 (con createElementsProxy de @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. totals no incluye el cupón: su valor solo lo conoce el servidor. La cotización creada (result, y lo que recibe onQuoteCreated) trae subtotal, total y moneda ya 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 StrictMode la 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, con products, isLoading, error y refetch.
  • useCordClients(): tus clientes, solo en modo proxy (con una pk_ responde clients_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.