@dolard.eu/versiq-widget
v0.2.0
Published
Versiq Widget SDK - Embed conversational qualification into your website.
Downloads
257
Maintainers
Readme
@dolard.eu/versiq-widget
Versiq Widget SDK — embed a conversational conversion agent that answers visitors using your site's data (catalogue, inventory, CRM, knowledge base), not the public-web average a generic LLM assistant returns.
Installation
npm install @dolard.eu/versiq-widget
# or
pnpm add @dolard.eu/versiq-widgetQuick Start
Script Tag (CDN)
Hot-link from any public npm CDN, pinned to a version, with Subresource Integrity (SRI) so the browser refuses to run a tampered bundle.
Before copy-pasting, replace
<VERSION>with the latest published version (see npm) and<SRI_HASH>with the regenerated SHA-384 (see Regenerating the SRI hash below). The placeholder values are intentional — every release ships a new bundle, so a hash hardcoded here would always be stale.
<script
src="https://unpkg.com/@dolard.eu/versiq-widget@<VERSION>/dist/widget.umd.js"
integrity="sha384-<SRI_HASH>"
crossorigin="anonymous"
data-api-key="pk_your_key"
></script>Equivalent via jsDelivr (same hash — both CDNs serve the bytes published to npm,
so the integrity value is identical):
<script
src="https://cdn.jsdelivr.net/npm/@dolard.eu/versiq-widget@<VERSION>/dist/widget.umd.js"
integrity="sha384-<SRI_HASH>"
crossorigin="anonymous"
data-api-key="pk_your_key"
></script>Pinning a version avoids breaking changes from later releases. To always follow
the latest, drop @<VERSION> — but then also drop integrity: SRI locks
the bundle to one specific publish, you cannot pin "latest hash".
Regenerating the SRI hash after a version bump
Each @dolard.eu/versiq-widget release ships a new bundle, so the SRI hash must
be regenerated and re-pasted into the snippet. From any shell with curl and
openssl available:
curl -sSL https://unpkg.com/@dolard.eu/versiq-widget@<version>/dist/widget.umd.js \
| openssl dgst -sha384 -binary | openssl base64 -APrefix the output with sha384- and copy it as the integrity attribute value.
Verify the result by reloading the host page in a browser — the DevTools Console
reports a clear error if the hash mismatches.
Programmatic API
The minimal integration is one line — every visual and behavioural aspect (theme, position, language, open-state, branding) is configured in the Versiq admin portal for your Application and resolved server-side from the API key.
import { createWidget } from "@dolard.eu/versiq-widget";
const widget = createWidget({ apiKey: "pk_live_your_key" });
// Control the widget at runtime
widget.open();
widget.close();
widget.reset();
// Cleanup (e.g., on SPA route change)
widget.destroy();Why this widget converts
Two reasons most platforms fail to convert mobile visitors, and two reasons this widget does:
- It speaks with your data, not the public-web average. The agent answers from your catalogue, your stock, your CRM, your knowledge base — what your competitors and generic AI assistants don't have.
- It is driven by your thumb, not your keyboard. The LLM dynamically picks the right component at every turn — quick replies, sliders, product cards — instead of forcing a form. Visitors qualify their need in a few taps on mobile, where 3+ field forms lose 50%+ of users (HubSpot).
The widget rendering layer (QuickReplies, PropertyCard, ActionButtons) is
the runtime that materialises this. Schemas live in the Versiq backend
repository and are out of scope for the SDK consumer.
Configuration
Widget configuration is split into three scopes — only the first one is your responsibility as an integrator.
1. Host-side (passed to createWidget or as data-* attributes)
These can only live on the integrator's page because they describe the host context (DOM, identity, environment).
| Option | Type | Default | Description |
| ----------- | ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------- |
| apiKey | string | required | Publishable API key (pk_live_*, pk_test_*). Binds the widget to one Application. |
| container | HTMLElement \| string | - | DOM container for inline mode. Required only when the admin has set position: "inline" on the portal. |
| baseUrl | string | Production URL | Override the widget iframe origin. Only useful for sandbox / self-hosted setups. |
| debug | boolean | false | Enable debug logging in the browser console. |
| email | string | - | Pre-identified visitor email — must be paired with userHash. |
| userId | string | - | Host-side stable user identifier — must be paired with userHash. |
| userHash | string | - | HMAC-SHA256 of email (or userId), signed with the Application identity secret. See Identity. |
2. Server-resolved (configured in the Versiq admin portal)
These are not passed by the integration — they are stored in the
Application's widget_config JSONB row and fetched at bootstrap from the API
key. To change any of them, edit the Application in the portal.
| Field | Configured in admin | Notes |
| ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| theme | Apparence → palette | Full palette (primaryColor, backgroundColor, textColor, borderRadius, fontFamily, colorScheme). |
| position | Apparence → position | "bottom-right", "bottom-left", or "inline". |
| language | Apparence → langue | ISO 639-1 (e.g. fr, en). Falls back to browser language when unset. |
| showProfile | Apparence → panneau profil | Toggles the profile panel in the widget header. |
| open | Apparence → état initial | Default open state on page load. Host can still call widget.open() programmatically. |
| brand.title | Branding → titre | Custom header title (falls back to a vertical-specific default). |
| brand.avatarUrl | Branding → avatar | Custom avatar URL (must originate from the portal upload — arbitrary URLs are rejected). |
| vertical | Application creation | real-estate, b2b-qualification, … Bound to the API key. |
3. Client overrides (advanced — rarely needed in production)
For tooling, A/B previews or staging environments, every server-resolved field
above can also be passed as a WidgetConfig argument or data-* attribute. A
host-side value, when present, takes precedence over the admin value. This
is intentional — but in production you should configure things in the portal so
all your sites stay in sync.
// Override only for a staging preview — production should leave this out
createWidget({
apiKey: "pk_test_...",
theme: { primaryColor: "#3B82F6" },
position: "bottom-left",
});ThemeConfig shape (admin-defined, occasionally overridden)
| Option | Type | Description |
| ----------------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
| primaryColor | string | Primary brand color (hex, e.g., #3B82F6) |
| backgroundColor | string | Background color for the widget container |
| textColor | string | Text color |
| borderRadius | number | Border radius in pixels |
| fontFamily | string | Font family |
| colorScheme | "light" \| "dark" \| "auto" | Widget color scheme. "auto" follows the visitor's prefers-color-scheme. Defaults to light. |
API Reference
createWidget(config)
Creates a new widget instance. The minimal call is createWidget({ apiKey }) —
every behaviour comes from the admin portal. The example below illustrates the
inline mode where the integrator must additionally supply a container
(host-context only).
const widget = createWidget({
apiKey: "pk_live_your_key",
// Required only when the admin set position: "inline" on the portal
container: document.getElementById("widget-container"),
});VersiqWidget Methods
| Method | Description |
| ----------------------------------------------------- | -------------------------------------------------------- |
| open() | Open the widget |
| close() | Close the widget |
| reset() | Reset the conversation |
| setTheme(theme: ThemeConfig) | Update theme dynamically |
| setColorScheme(scheme: "light" \| "dark" \| "auto") | Sugar over setTheme({ colorScheme }) for dark-mode UIs |
| identify(params) | Set host-attested identity (HMAC) |
| destroy() | Remove widget and cleanup |
| on(event, handler) | Subscribe to events |
| off(event, handler) | Unsubscribe from events |
Window API (Script Tag)
When using the script tag, the API is available on window.Versiq:
window.Versiq.open();
window.Versiq.close();
window.Versiq.setTheme({ primaryColor: "#10B981" });
// Wire your own dark-mode toggle to the widget:
window.Versiq.setColorScheme("dark"); // or "light", or "auto"Events
Subscribe to widget events to react to user interactions.
widget.on("ready", () => {
console.log("Widget is ready");
});
widget.on("profile-update", (data) => {
console.log("Profile updated:", data.profile);
});
widget.on("qualified", (data) => {
console.log("Lead qualified:", data.profile, "Score:", data.score);
});
widget.on("message", (data) => {
console.log("New message:", data.message);
});
widget.on("error", (data) => {
console.error("Widget error:", data.code, data.message);
});Event Types
| Event | Payload | Description |
| ------------------- | ------------------------------------------- | ----------------------------- |
| ready | - | Widget is loaded and ready |
| open | - | Widget was opened |
| close | - | Widget was closed |
| message | { message: WidgetMessage } | New chat message |
| profile-update | { profile: WidgetProfile } | Profile data was updated |
| qualified | { profile: WidgetProfile, score: number } | Lead qualification complete |
| error | { code: string, message: string } | An error occurred |
| quota-warning | { remaining: number, limit: number } | Lead quota running low |
| quota-exceeded | - | Lead quota exceeded |
| identity-verified | { email: string, userId?: string } | Host identity verified (HMAC) |
TypeScript
The package includes full TypeScript support with exported types:
import type {
WidgetConfig,
ThemeConfig,
VersiqWidget,
WidgetEventType,
WidgetProfile,
WidgetMessage,
B2BProfile, // B2B-specific (backward compat)
} from "@dolard.eu/versiq-widget";Profile Types
WidgetProfile(Record<string, unknown>) — Generic profile used in events. Shape depends on the vertical (BuyerProfilefor real-estate,B2BProfilefor b2b-qualification).B2BProfile— Typed B2B qualification profile (sector, companySize, etc.). Kept for backward compatibility.
Vertical Resolution
The vertical (real-estate, b2b-qualification, …) is resolved server-side
from the API key — you do not pass it from the integration. Each pk_* key is
bound to exactly one Application, and each Application is bound to one vertical.
Switching vertical = creating a new Application + new key.
Real-Estate flow
When the resolved vertical is real-estate, the widget runs in qualification
mode: Versiq qualifies the buyer through conversation and emits the profile — no
property data is needed from the integrator (data-dependent tools like
searchProperties, getCityStats, estimateProperty are automatically
excluded).
// Assumes the real-estate Application is configured for `inline` mode in
// the admin portal. Only the host-context fields are passed here.
const widget = createWidget({
apiKey: "pk_live_your_real_estate_key",
container: "#chat",
});
// Track profile updates in real-time
widget.on("profile-update", (data) => {
console.log("Criteria so far:", data.profile);
// data.profile: { userType, location, budget, propertyType, ... }
});
// Lead is fully qualified — trigger your own search/CRM
widget.on("qualified", (data) => {
console.log("Qualified lead:", data.profile);
// data.score: 1 (V1: binary — 1 = qualified)
triggerPropertySearch(data.profile);
});Display Modes
Display mode (bottom-right, bottom-left, inline) is set in the admin
portal. From the integrator's side, the only difference is whether you also need
to supply a container.
Floating (admin chose bottom-right or bottom-left)
Widget appears as a floating button in the corner of the page. No container
needed.
createWidget({ apiKey: "pk_live_your_key" });Inline (admin chose inline)
Widget is mounted directly into a DOM container you provide.
createWidget({
apiKey: "pk_live_your_key",
container: document.getElementById("chat-container"),
});React Integration
"use client";
import { useEffect, useRef } from "react";
import { createWidget, type VersiqWidget } from "@dolard.eu/versiq-widget";
export function ContactWidget() {
const widgetRef = useRef<VersiqWidget | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current || widgetRef.current) return;
// Assumes the Application is configured for `inline` mode in the admin
// portal — only the host-context fields (apiKey + container) are passed
// here. Theme, position, language, etc. come from the server config.
const widget = createWidget({
apiKey: "pk_live_your_key",
container: containerRef.current,
});
widgetRef.current = widget;
return () => {
widgetRef.current?.destroy();
widgetRef.current = null;
};
}, []);
return <div ref={containerRef} className="h-[600px]" />;
}Data Attributes (Script Tag)
All WidgetConfig options can be passed as kebab-cased data-* attributes on
the <script> tag. The same scope split as the JavaScript API applies — in
practice only data-api-key is needed.
Host-side (essential)
| Attribute | Maps to | Example |
| ---------------- | ---------- | --------------------------------------- |
| data-api-key | apiKey | data-api-key="pk_live_abc123" |
| data-base-url | baseUrl | data-base-url="https://app.versiq.io" |
| data-debug | debug | data-debug="true" |
| data-email | email | data-email="[email protected]" |
| data-user-id | userId | data-user-id="usr_123" |
| data-user-hash | userHash | data-user-hash="<hmac>" |
Overrides (tooling/preview only — use the admin portal in production)
| Attribute | Maps to | Example |
| --------------- | ---------- | -------------------------------------- |
| data-position | position | data-position="bottom-left" |
| data-open | open | data-open="true" |
| data-theme | theme | data-theme='{"primaryColor":"#F00"}' |
Host Page Permissions Policy
Some widget features rely on browser-level permissions that must be granted by the host page — the iframe itself can't unlock a capability the parent document forbids.
| Feature | Browser permission | Required Permissions-Policy on the host |
| ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------- |
| Autour de moi quick-reply (real-estate) — proposes nearby cities/districts based on the visitor's current coords | geolocation (navigator.geolocation.getCurrentPosition) | geolocation=(self) at minimum (or include the widget origin explicitly) |
| Copy property listing / share link buttons | clipboard-write | clipboard-write=(self) (most hosts already inherit the default) |
If your site already ships a Permissions-Policy header (recommended for
defense in depth — see
MDN),
add geolocation=(self) to the list. The widget iframe is created with
allow="clipboard-write; geolocation" so the parent's permission propagates
automatically once it's not denied.
Minimal example
Permissions-Policy: camera=(), microphone=(), geolocation=(self)What happens if you forget
The widget keeps working — except the geolocation-backed quick-replies log
Geolocation has been disabled in this document by permissions policy. to the
visitor's console and the chip silently does nothing on click. The underlying
conversation still works; the visitor just types the city manually.
E2E Test Selectors (data-testid)
The widget exposes a stable contract of data-testid attributes for
end-to-end testing (Playwright, Cypress, etc.). These selectors are guaranteed
not to change without a major version bump.
| Selector | Element | Additional attributes |
| ----------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| widget-root | Chat container (open) | — |
| widget-input | Message input field | — |
| widget-send | Send button | — |
| widget-message | Message bubble | data-role="user" \| "assistant" |
| widget-suggestion | Quick reply chip | data-index="<N>" (position) |
| widget-cta-button | Call-to-action button | data-objective="<objectiveType>" |
| widget-avatar | Header avatar | — |
| widget-map-toggle | Map open/reopen control (FAB mobile / button desktop) | — (real-estate vertical, #1164) |
| widget-map-panel | Map container — desktop <aside> or mobile <Sheet> | — (real-estate vertical, #1164) |
| widget-map-close | Map close button (desktop only) | — (real-estate vertical, #1164) |
| widget-areas-overview | Areas-overview fullscreen panel root | — (real-estate vertical, #1165) |
| widget-areas-overview-close | Close button (top-right) on the panel | — (real-estate vertical, #1165) |
| widget-areas-overview-submit | Validate-selection button (footer) | — (real-estate vertical, #1165) |
| widget-areas-overview-empty | Empty-state message when areas.length === 0 | — (real-estate vertical, #1165) |
| widget-area-card-<slug> | Single area card | data-area="<area>", data-state="kept\|rejected\|toExplore" (#1165) |
| widget-area-keep-<slug> | Favori toggle button on a card | data-area="<area>" (#1165) |
| widget-area-reject-<slug> | Rejeter toggle button on a card | data-area="<area>" (#1165) |
| widget-exploration-mode | Exploration overlay root (single-city deep-dive) | data-city="<city>" (real-estate vertical, #1166) |
| widget-exploration-mode-close | Close button (×) on the overlay | — (#1166) |
| widget-exploration-mode-modify-criteria | Footer button opening EmbedCriteriaPopup | — (#1166) |
| widget-exploration-mode-exit | Footer "Terminer" button (immediate exit, sends current state) | — (#1166) |
| widget-exploration-mode-empty | Empty-state when initialProperties === [] | — (#1166) |
| widget-exploration-mode-empty-modify-criteria | Modify-criteria CTA inside the empty state | — (#1166) |
| widget-exploration-mode-recap | Recap screen shown once every property has been classified | — (#1166) |
| widget-exploration-mode-recap-finish | Send-selection CTA on the recap screen | — (#1166) |
| widget-exploration-property-<id> | Currently displayed property card | data-property-id="<id>", data-state="favorite\|rejected\|neutral" (#1166) |
| widget-exploration-favorite-<id> | Favori toggle button for the current property | data-property-id="<id>" (#1166) |
| widget-exploration-reject-<id> | Écarter toggle button for the current property | data-property-id="<id>" (#1166) |
| widget-exploration-previous | Navigate to the previous property | — (#1166) |
| widget-exploration-next | Navigate to the next property | — (#1166) |
| widget-criteria-popup | Criteria edit modal root (rendered inside ExplorationMode) | — (#1166) |
| widget-criteria-popup-close | Close button (×) of the criteria modal | — (#1166) |
| widget-criteria-popup-cancel | Footer "Annuler" button | — (#1166) |
| widget-criteria-popup-apply | Footer "Appliquer" button | — (#1166) |
| widget-criteria-popup-warning | Warning banner shown when at least one field changed | — (#1166) |
| widget-criteria-budget-min | Budget min input | — (#1166) |
| widget-criteria-budget-max | Budget max input | — (#1166) |
| widget-criteria-surface-min | Surface min input | — (#1166) |
| widget-criteria-surface-max | Surface max input | — (#1166) |
| widget-criteria-rooms | Rooms min input | — (#1166) |
| widget-criteria-property-type-<type> | Property-type toggle (apartment, house, all) | data-selected="true\|false" (#1166) |
Contract source lives in the Versiq backend repository (sdolard/Toize, path
apps/app/src/app/widget/embed/components/). Property card selectors (widget-property-card) are not yet exposed (pending #727). Map selectors only appear whenMapContext.isVisible === true— i.e. once the LLM has profiled a geocodable city. On B2B verticals without a map, they never render. Areas-overview selectors only appear when the LLM has streamed an<!--AREAS_OVERVIEW:-->marker;<slug>is the city name lowercased, accent- stripped (NFD), with non-alphanumeric runs collapsed to-(e.g.Vénissieux→venissieux). Exploration-mode and criteria-popup selectors (widget-exploration-*/widget-criteria-*) only appear after theenterExplorationModetool has streamed an<!--EXPLORATION_MODE:-->marker (real-estate vertical, #1166).
Example (Playwright)
await page.getByTestId("widget-input").fill("Looking for a 2-bedroom in Lyon");
await page.getByTestId("widget-send").click();
await expect(
page.getByTestId("widget-message").filter({ hasText: "Lyon" }),
).toBeVisible();Performance
Bundle Size
| Format | Size (minified) | Size (gzipped) | Limit | | ------ | --------------- | -------------- | ----- | | UMD | ~84 KB | ~24 KB | 50 KB | | ESM | ~112 KB | ~26 KB | - |
CI enforces the 50 KB gzipped limit via size-limit.
Largest Contentful Paint (LCP)
Target: < 1.5s
The widget loads as an iframe, so LCP depends on:
- SDK download (~16 KB gzipped) - typically < 100ms
- Iframe creation - instant
- Embed page load - varies by network
Measured baseline: 0.14s (localhost, no throttling)
Integration Time
Target: < 15 minutes from docs to working widget
The Quick Start section provides copy-paste integration in under 5 minutes.
License
Commercial Source-available — see LICENSE.
This is not an open-source release: the source is published so integrators can audit what runs in their visitors' browsers, but fork / redistribution / competing-SDK use are prohibited without a written agreement. Production use against the Versiq backend is governed by the Versiq Commercial Terms of Service.
For commercial licensing inquiries: [email protected]
