@vitrina/widget
v0.3.0
Published
Vitrina embeddable chat widget for dealer websites — shadow-DOM UI, real-time SSE, publishable origin-locked key.
Readme
@vitrina/widget
Embeddable chat widget for dealer websites. Renders a floating launcher + a
conversation panel inside a Shadow DOM (fully style-isolated from the host
page) and speaks the Vitrina web channel protocol: it creates/resumes an
anonymous visitor conversation, POSTs messages, and subscribes to a
visitor-scoped SSE stream that invalidates so the widget re-fetches history —
all authenticated with a publishable, origin-locked widget key (pk_…).
Two ways to embed: an NPM import (import { init }) for app/storefront
codebases, or a <script> loader for any static site.
Install
This package is published to the public npm registry under the @vitrina
org scope. No authentication or .npmrc configuration is required.
Option A — NPM import (app/storefront codebases)
npm install @vitrina/widget
# or: pnpm add @vitrina/widget
# or: yarn add @vitrina/widgetOption B — <script> loader (any static site, no build step)
<script>
window.vitrinaChat = {
publicKey: 'pk_live_xxx',
apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
};
</script>
<script src="https://api.vitrinadev.com/widget.js" defer></script>The loader is served by the same host the widget talks to, so a Content-Security-Policy only ever needs one Vitrina origin:
script-src https://api.vitrinadev.com;
connect-src https://api.vitrinadev.com;See "Usage — <script> loader" below for the full config.
Usage — import { init }
import { init } from '@vitrina/widget';
const widget = init({
publicKey: 'pk_live_xxx',
apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
vehicleId: 'veh_123', // optional: pre-attach the inquiry to a vehicle
// Appearance is managed in Vitrina and fetched at load. Set any of these only
// to OVERRIDE it for this site — an inline value always wins.
locale: 'es', // optional: 'es' | 'en' (auto-detected otherwise)
theme: { accent: '#2563eb', position: 'br', logoUrl: 'https://…/logo.png' },
welcomeMessage: 'Hola, ¿en qué te puedo ayudar?',
});
// The returned handle lets you drive the widget imperatively:
widget.open();
widget.setVehicle('veh_456'); // e.g. on an SPA route change
widget.close();
widget.destroy(); // unmounts + aborts the SSE streamThe widget mounts itself on init(); nothing else is required.
Usage — <script> loader
For any site (no build step). Set window.vitrinaChat before the loader
script, and it auto-initializes:
<script>
window.vitrinaChat = {
publicKey: 'pk_live_xxx',
apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
};
</script>
<script src="https://api.vitrinadev.com/widget.js" defer></script>That is the whole install. Appearance — colour, corner, logo, greeting, language — is managed in Vitrina (Configuración › Conexiones › Web chat) and fetched at load, so changing it never means editing this page again. You can still pin any of it inline, and an inline value always wins:
<script>
window.vitrinaChat = {
publicKey: 'pk_live_xxx',
apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
// optional, all overrides of what Vitrina serves:
vehicleId: 'veh_123',
locale: 'es',
theme: { accent: '#2563eb', position: 'br', logoUrl: 'https://…/logo.png' },
welcomeMessage: 'Hola, ¿en qué te puedo ayudar?',
};
</script>
<script src="https://api.vitrinadev.com/widget.js" defer></script>https://api.vitrinadev.com/widget.js always serves the current release. The
URL is deliberately unversioned so a dealer never edits their HTML to receive a
fix; it is cached for five minutes and revalidated with an ETag. Pin a specific
version instead — https://cdn.jsdelivr.net/npm/@vitrina/[email protected]/dist/loader.global.js
— only if you have a reason to hold one back.
The window.vitrinaChat object is exactly the config table below. After load, the
live handle is stashed on window.vitrinaChatInstance, so the host page can call
window.vitrinaChatInstance.open() / .close() / .setVehicle(id) / .destroy().
The loader is defensive: it console.warns and no-ops on a missing/invalid config,
is idempotent against a double-load, and never throws into the host page.
Configuration options
| Option | Type | Required | Default | Description |
| ---------------- | ----------------------------- | -------- | ------------------ | --------------------------------------------------------------------------- |
| publicKey | string | yes | — | Publishable widget key (pk_…), origin-locked. Safe to ship in page source. |
| apiBaseUrl | string | yes | — | Vitrina API base, e.g. https://<host>/api/v1. Trailing slash is trimmed. |
| vehicleId | string | no | null | Pre-attach the inquiry to a vehicle (the id from /stock). |
| locale | 'es' \| 'en' | no | auto (navigator) | Widget chrome language. Falls back to es (Chilean market default). |
| theme.accent | string (CSS color) | no | #111827 | Brand accent for the launcher + inbound bubbles. Sanitized; bad values fall back. |
| theme.position | 'br' \| 'bl' | no | 'br' | Launcher corner: bottom-right or bottom-left. |
| theme.logoUrl | string (http/https URL) | no | — | Optional logo in the panel header. Non-http(s) URLs are ignored. |
| welcomeMessage | string | no | localized greeting | Greeting shown before the visitor sends the first message. |
| remoteConfig | boolean | no | true | Fetch appearance from Vitrina at load. false = fully self-contained. |
Message content is never parsed as HTML — every bubble is written via
textContent, so it is XSS-safe by construction.
Where appearance comes from
locale, theme.* and welcomeMessage are resolved server-side from the
dealer's own Vitrina settings and fetched once at load (GET /widget/config).
That is what lets a dealer restyle their bubble from the admin UI and have
already-installed widgets pick it up — within about a minute — instead of asking
every site owner to re-paste a snippet.
Three things worth knowing:
- Anything you set inline wins. A widget installed before this existed carries a full inline config and therefore renders exactly as it always has. Treat inline values as per-site overrides.
- It fails open. A network error, an older API without the route, a malformed answer — the widget renders with your inline/default theme and works normally. The request is never on the critical path.
- It is cached in
localStorage(last known good) and by the browser (60s, ETag-revalidated), so repeat visits paint the right colours with no network wait. PassremoteConfig: falseto opt out of the fetch entirely.
Security
pk_is public by design. It is safe to ship in page source: it only works on the dealer's allow-listed origins (origin-locked, Vitrina ADR 0033) and only grantsstock:read+leads:intake+widget:chat. It is not a secret and carries no admin capability.- Origin lock + CORS. Requests only succeed from the domains configured for
that key; the widget sends a fixed, minimal header set (
Authorization,Content-Type,X-Vitrina-Visitor) and nevercredentials: 'include'. - AI kill-switch defaults OFF. With AI answers off, a visitor talks to a human via the dealer inbox; replies arrive over the same SSE→refetch path once the dealer enables AI later — no widget change needed.
- Honeypot. The composer includes a hidden
hp_websitefield (visually hidden off-screen, neverdisplay:none) that is always submitted — empty for a human, a spam signal when a bot fills it.
Browser support
Evergreen browsers (Chrome/Edge, Firefox, Safari, and their mobile equivalents).
The widget requires Shadow DOM, fetch with streaming ReadableStream bodies
(for SSE), AbortController, and CSS custom properties — all baseline in browsers
from ~2020 onward. No IE11 support.
Development
pnpm build # tsup → dist/ (ESM library + IIFE loader + .d.ts)
pnpm typecheck # tsc --noEmit
pnpm test # vitest (happy-dom)