diff --git a/.changeset/float-teaser-dismiss.md b/.changeset/float-teaser-dismiss.md new file mode 100644 index 0000000..6ccbc36 --- /dev/null +++ b/.changeset/float-teaser-dismiss.md @@ -0,0 +1,6 @@ +--- +"@perspective-ai/sdk": minor +"@perspective-ai/sdk-react": minor +--- + +Add an × dismiss button to the float teaser bubble so users can close it without opening the chat. Dismissing removes the teaser and the notification dot, and persists for the rest of the browser session (per agent). Configurable via `teaser.dismissible` (default `true`) or `data-perspective-teaser-dismissible="false"`. diff --git a/packages/sdk-react/README.md b/packages/sdk-react/README.md index 2db29f1..0be9dba 100644 --- a/packages/sdk-react/README.md +++ b/packages/sdk-react/README.md @@ -285,6 +285,10 @@ import { FloatBubble } from "@perspective-ai/sdk-react"; // Disable the welcome sequence entirely (no teaser, chime, or dot) + +// Hide the teaser's × button (by default users can dismiss the teaser +// without opening the chat; a dismissal holds for the browser session) + ``` ## Hook Options @@ -298,7 +302,7 @@ interface UsePopupOptions { theme?: "light" | "dark" | "system"; channel?: "TEXT" | "VOICE" | ["TEXT", "VOICE"]; // Interaction mode welcomeMessage?: string; // Teaser text (float only) - teaser?: TeaserConfig; // Teaser on/off, delay (ms), and chime sound (float only) + teaser?: TeaserConfig; // Teaser on/off, delay (ms), chime sound, and dismissibility (float only) buttonText?: string; // Trigger button text (popup/slider) params?: Record; brand?: { diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 3f6c74f..37d7526 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -196,7 +196,7 @@ interface EmbedConfig { theme?: "light" | "dark" | "system"; // Theme preference (default: "system") channel?: "TEXT" | "VOICE" | ["TEXT", "VOICE"]; // Interaction mode (default: from server config) welcomeMessage?: string; // Teaser text next to float button (float-type only) - teaser?: TeaserConfig; // Teaser on/off, delay, and sound (float-type only) + teaser?: TeaserConfig; // Teaser on/off, delay, sound, and dismissibility (float-type only) buttonText?: string; // Custom text for popup/slider trigger buttons params?: Record; // Custom URL parameters for tracking brand?: { @@ -397,7 +397,7 @@ createFloatBubble({ }); ``` -The teaser appears after 3 seconds with a chime sound. Click the teaser or the float button to open the chat. Only applies to float-type embeds. +The teaser appears after 3 seconds with a chime sound. Click the teaser or the float button to open the chat, or the teaser's × button to dismiss it without opening the chat (the dismissal holds for the rest of the browser session). Only applies to float-type embeds. ### Teaser Control @@ -411,6 +411,7 @@ createFloatBubble({ enabled: true, // false disables the whole welcome sequence (teaser, chime, dot) delay: 5000, // ms after mount before the teaser appears (default: 3000) sound: false, // mute the chime (default: true) + dismissible: false, // hide the teaser's × button (default: true) }, }); ``` @@ -418,6 +419,7 @@ createFloatBubble({ - `enabled: false` turns off the teaser bubble, the chime sound, and the notification dot entirely. - `delay` sets when the teaser bubble appears, in milliseconds. The chime tracks the teaser, playing 1 second before it appears (or immediately, when `delay` is under a second). - `sound: false` mutes the chime while keeping the teaser bubble. +- `dismissible: false` removes the × button from the teaser bubble. By default, dismissing the teaser also clears the notification dot and keeps the teaser hidden for the rest of the browser session (per agent, stored in `sessionStorage`). Or declaratively via data attributes: @@ -434,11 +436,12 @@ Or declaratively via data attributes: > ``` -| Data Attribute | Description | -| ------------------------------- | --------------------------------------- | -| `data-perspective-teaser` | `"false"` disables the welcome sequence | -| `data-perspective-teaser-delay` | Milliseconds before the teaser appears | -| `data-perspective-teaser-sound` | `"false"` mutes the chime | +| Data Attribute | Description | +| ------------------------------------- | --------------------------------------- | +| `data-perspective-teaser` | `"false"` disables the welcome sequence | +| `data-perspective-teaser-delay` | Milliseconds before the teaser appears | +| `data-perspective-teaser-sound` | `"false"` mutes the chime | +| `data-perspective-teaser-dismissible` | `"false"` hides the teaser's × button | Teaser settings configured in the Perspective dashboard (delivered via the config API) take precedence over values passed in code. @@ -661,28 +664,29 @@ For non-module environments, use the browser bundle: ### Data Attributes Reference -| Attribute | Description | -| --------------------------------- | ------------------------------------------------------- | -| `data-perspective-widget` | Inline widget embed | -| `data-perspective-frame` | Widget frame: `"layout=fill,radius=4px,shadow=none,…"` | -| `data-perspective-popup` | Popup trigger button | -| `data-perspective-slider` | Slider trigger button | -| `data-perspective-float` | Floating chat bubble | -| `data-perspective-fullpage` | Full page embed | -| `data-perspective-params` | Custom params: `"key1=value1,key2=value2"` | -| `data-perspective-theme` | Theme: `"light"`, `"dark"`, or `"system"` | -| `data-perspective-brand` | Light mode colors: `"primary=#xxx,bg=#yyy"` | -| `data-perspective-brand-dark` | Dark mode colors | -| `data-perspective-no-style` | Disable auto-styling on trigger buttons | -| `data-perspective-disable-close` | Prevent user from closing popup/slider | -| `data-perspective-auto-open` | Auto-open trigger: `"timeout:5000"` or `"exit-intent"` | -| `data-perspective-show-once` | Show-once dedup: `"session"`, `"visitor"`, or `"false"` | -| `data-perspective-launcher-icon` | Launcher icon: `"avatar"`, `"default"`, or image URL | -| `data-perspective-launcher-style` | Launcher CSS: `"width:64px;border-radius:12px"` | -| `data-perspective-launcher-class` | CSS class(es) for the launcher button | -| `data-perspective-teaser` | `"false"` disables the float welcome teaser | -| `data-perspective-teaser-delay` | Milliseconds before the teaser appears (default 3000) | -| `data-perspective-teaser-sound` | `"false"` mutes the teaser chime | +| Attribute | Description | +| ------------------------------------- | ------------------------------------------------------- | +| `data-perspective-widget` | Inline widget embed | +| `data-perspective-frame` | Widget frame: `"layout=fill,radius=4px,shadow=none,…"` | +| `data-perspective-popup` | Popup trigger button | +| `data-perspective-slider` | Slider trigger button | +| `data-perspective-float` | Floating chat bubble | +| `data-perspective-fullpage` | Full page embed | +| `data-perspective-params` | Custom params: `"key1=value1,key2=value2"` | +| `data-perspective-theme` | Theme: `"light"`, `"dark"`, or `"system"` | +| `data-perspective-brand` | Light mode colors: `"primary=#xxx,bg=#yyy"` | +| `data-perspective-brand-dark` | Dark mode colors | +| `data-perspective-no-style` | Disable auto-styling on trigger buttons | +| `data-perspective-disable-close` | Prevent user from closing popup/slider | +| `data-perspective-auto-open` | Auto-open trigger: `"timeout:5000"` or `"exit-intent"` | +| `data-perspective-show-once` | Show-once dedup: `"session"`, `"visitor"`, or `"false"` | +| `data-perspective-launcher-icon` | Launcher icon: `"avatar"`, `"default"`, or image URL | +| `data-perspective-launcher-style` | Launcher CSS: `"width:64px;border-radius:12px"` | +| `data-perspective-launcher-class` | CSS class(es) for the launcher button | +| `data-perspective-teaser` | `"false"` disables the float welcome teaser | +| `data-perspective-teaser-delay` | Milliseconds before the teaser appears (default 3000) | +| `data-perspective-teaser-sound` | `"false"` mutes the teaser chime | +| `data-perspective-teaser-dismissible` | `"false"` hides the teaser's × button | ### Auto-Trigger (Data Attributes) diff --git a/packages/sdk/src/browser.test.ts b/packages/sdk/src/browser.test.ts index 9306e0e..630080a 100644 --- a/packages/sdk/src/browser.test.ts +++ b/packages/sdk/src/browser.test.ts @@ -893,6 +893,25 @@ describe("browser entry", () => { expect(document.querySelector(".perspective-float-teaser")).toBeTruthy(); }); + it("parses data-perspective-teaser-dismissible='false' to hide the dismiss button", async () => { + vi.useFakeTimers(); + document.body.innerHTML = ` +
+ `; + autoInit(); + + // The teaser is deferred until the config fetch resolves + await flushConfigFetch(); + await flushConfigFetch(); + + vi.advanceTimersByTime(3000); + expect(document.querySelector(".perspective-float-teaser")).toBeTruthy(); + expect( + document.querySelector(".perspective-float-teaser-dismiss") + ).toBeFalsy(); + }); + it("does not arm the teaser before the config fetch resolves", () => { vi.useFakeTimers(); // Never-resolving fetch: the config stays in flight for the whole test diff --git a/packages/sdk/src/browser.ts b/packages/sdk/src/browser.ts index 4f5e516..6e937aa 100644 --- a/packages/sdk/src/browser.ts +++ b/packages/sdk/src/browser.ts @@ -379,7 +379,8 @@ function extractLauncherConfig( * Extract teaser config from element data attributes: * data-perspective-teaser="false" disables the welcome sequence, * data-perspective-teaser-delay="5000" sets the teaser delay in ms, - * data-perspective-teaser-sound="false" mutes the chime. + * data-perspective-teaser-sound="false" mutes the chime, + * data-perspective-teaser-dismissible="false" hides the teaser's × button. */ function extractTeaserConfig( el: HTMLElement @@ -387,6 +388,7 @@ function extractTeaserConfig( const enabledAttr = el.getAttribute(DATA_ATTRS.teaser); const delayAttr = el.getAttribute(DATA_ATTRS.teaserDelay); const soundAttr = el.getAttribute(DATA_ATTRS.teaserSound); + const dismissibleAttr = el.getAttribute(DATA_ATTRS.teaserDismissible); const teaser: NonNullable = {}; @@ -405,6 +407,10 @@ function extractTeaserConfig( teaser.sound = false; } + if (dismissibleAttr === "false") { + teaser.dismissible = false; + } + return Object.keys(teaser).length > 0 ? teaser : undefined; } diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 681e29d..4dac78d 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -95,6 +95,7 @@ export const DATA_ATTRS = { teaser: "data-perspective-teaser", teaserDelay: "data-perspective-teaser-delay", teaserSound: "data-perspective-teaser-sound", + teaserDismissible: "data-perspective-teaser-dismissible", disableJsonLdAttribution: "data-perspective-disable-jsonld-attribution", } as const; @@ -200,4 +201,5 @@ export const STORAGE_KEYS = { triggerShown: "perspective-trigger-shown", embedAuthToken: "perspective-embed-auth-token", embedState: "perspective-embed-state", + teaserDismissed: "perspective-teaser-dismissed", } as const; diff --git a/packages/sdk/src/float.test.ts b/packages/sdk/src/float.test.ts index d36ac1b..d3c5aec 100644 --- a/packages/sdk/src/float.test.ts +++ b/packages/sdk/src/float.test.ts @@ -1118,6 +1118,154 @@ describe("createFloatBubble", () => { }); }); + describe("teaser dismiss", () => { + it("dismiss button removes the teaser and dot without opening the chat", () => { + vi.useFakeTimers(); + + const handle = createFloatBubble({ + researchId: "test-research-id", + welcomeMessage: "Hello!", + }); + + vi.advanceTimersByTime(3000); + expect(document.querySelector(".perspective-float-teaser")).toBeTruthy(); + + const dismissBtn = document.querySelector( + ".perspective-float-teaser-dismiss" + ) as HTMLButtonElement; + expect(dismissBtn).toBeTruthy(); + expect(dismissBtn.getAttribute("aria-label")).toBe("Dismiss message"); + + dismissBtn.click(); + + expect(document.querySelector(".perspective-float-teaser")).toBeFalsy(); + expect( + document.querySelector(".perspective-float-notification-dot") + ).toBeFalsy(); + // Dismissing is not engaging — the chat must stay closed. + expect(handle.isOpen).toBe(false); + expect(document.querySelector(".perspective-float-window")).toBeFalsy(); + + handle.unmount(); + }); + + it("teaser.dismissible=false hides the dismiss button", () => { + vi.useFakeTimers(); + + const handle = createFloatBubble({ + researchId: "test-research-id", + welcomeMessage: "Hello!", + teaser: { dismissible: false }, + }); + + vi.advanceTimersByTime(3000); + expect(document.querySelector(".perspective-float-teaser")).toBeTruthy(); + expect( + document.querySelector(".perspective-float-teaser-dismiss") + ).toBeFalsy(); + + handle.unmount(); + }); + + it("a dismissal is not re-armed by a later config update", () => { + vi.useFakeTimers(); + + const handle = createFloatBubble({ + researchId: "test-research-id", + welcomeMessage: "Hello!", + teaser: { delay: 1000 }, + }); + + vi.advanceTimersByTime(1000); + ( + document.querySelector( + ".perspective-float-teaser-dismiss" + ) as HTMLButtonElement + ).click(); + + // A config refresh with a different delay would normally reschedule + // the sequence — after a dismissal it must stay quiet. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (handle.update as any)({ + _apiConfig: { + primaryColor: "#7c3aed", + textColor: "#ffffff", + darkPrimaryColor: "#a78bfa", + darkTextColor: "#ffffff", + embedSettings: { teaser: { delay: 5000 } }, + }, + }); + + vi.advanceTimersByTime(10000); + expect(document.querySelector(".perspective-float-teaser")).toBeFalsy(); + + handle.unmount(); + }); + + it("a dismissal persists across mounts in the same session (no teaser, no chime)", () => { + vi.useFakeTimers(); + const audioCtxCtor = vi.fn(() => createFakeAudioContext()); + vi.stubGlobal("AudioContext", audioCtxCtor); + + const first = createFloatBubble({ + researchId: "test-research-id", + welcomeMessage: "Hello!", + }); + + vi.advanceTimersByTime(3000); + ( + document.querySelector( + ".perspective-float-teaser-dismiss" + ) as HTMLButtonElement + ).click(); + first.unmount(); + audioCtxCtor.mockClear(); + + // Simulates a page navigation: a fresh mount in the same session. + const second = createFloatBubble({ + researchId: "test-research-id", + welcomeMessage: "Hello!", + }); + + vi.advanceTimersByTime(10000); + expect(document.querySelector(".perspective-float-teaser")).toBeFalsy(); + expect( + document.querySelector(".perspective-float-notification-dot") + ).toBeFalsy(); + expect(audioCtxCtor).not.toHaveBeenCalled(); + + second.unmount(); + vi.unstubAllGlobals(); + }); + + it("a dismissal is scoped to the researchId", () => { + vi.useFakeTimers(); + + const first = createFloatBubble({ + researchId: "research-a", + welcomeMessage: "Hello!", + }); + + vi.advanceTimersByTime(3000); + ( + document.querySelector( + ".perspective-float-teaser-dismiss" + ) as HTMLButtonElement + ).click(); + first.unmount(); + + const second = createFloatBubble({ + researchId: "research-b", + welcomeMessage: "Hello!", + }); + + vi.advanceTimersByTime(3000); + expect(document.querySelector(".perspective-float-teaser")).toBeTruthy(); + + second.unmount(); + }); + }); + describe("launcher config", () => { afterEach(() => { document.body.innerHTML = ""; diff --git a/packages/sdk/src/float.ts b/packages/sdk/src/float.ts index f45d1db..c21071b 100644 --- a/packages/sdk/src/float.ts +++ b/packages/sdk/src/float.ts @@ -21,7 +21,12 @@ import { } from "./iframe"; import { createLoadingIndicator, prefetchSceneImage } from "./loading"; import { injectStyles, AUDIO_ICON, MESSAGES_ICON, CLOSE_ICON } from "./styles"; -import { getPersistedOpenState, setPersistedOpenState } from "./state"; +import { + getPersistedOpenState, + setPersistedOpenState, + getPersistedTeaserDismissed, + setPersistedTeaserDismissed, +} from "./state"; import { cn, getThemeClass, @@ -100,6 +105,7 @@ function resolveTeaserConfig( enabled: merged.enabled !== false, delay, sound: merged.sound !== false, + dismissible: merged.dismissible !== false, }; } @@ -326,6 +332,12 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { let armedTeaserDelay: number | null = null; let delayAnchorMs: number | null = null; let teaserDelivered = false; + // An explicit × dismissal holds for the whole browser session: it survives + // config updates (which otherwise re-arm the sequence) and page navigations. + let teaserDismissed = getPersistedTeaserDismissed({ + researchId, + host: config.host, + }); const persistOpenState = (open: boolean) => { setPersistedOpenState({ researchId, @@ -415,6 +427,13 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { } }; + const dismissTeaser = () => { + teaserDismissed = true; + setPersistedTeaserDismissed({ researchId, host: config.host }); + clearWelcomeTimers(); + removeTeaser(); + }; + const renderTeaser = (message: string) => { removeTeaser(); if (isOpen) return; @@ -433,6 +452,19 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { teaserEl.appendChild(messageEl); teaserEl.addEventListener("click", () => openFloat()); + if (resolveTeaserConfig(currentConfig).dismissible) { + const dismissBtn = document.createElement("button"); + dismissBtn.className = "perspective-float-teaser-dismiss"; + dismissBtn.innerHTML = CLOSE_ICON; + dismissBtn.setAttribute("aria-label", "Dismiss message"); + dismissBtn.addEventListener("click", (event) => { + // The teaser itself opens the chat on click — a dismissal must not. + event.stopPropagation(); + dismissTeaser(); + }); + teaserEl.appendChild(dismissBtn); + } + document.body.appendChild(teaserEl); teaser = teaserEl; showNotificationDot(); @@ -454,7 +486,7 @@ export function createFloatBubble(config: InternalEmbedConfig): FloatHandle { const maybeStartWelcomeSequence = ( teaserConfig = resolveTeaserConfig(currentConfig) ) => { - if (welcomeSequenceStarted || isOpen) return; + if (welcomeSequenceStarted || isOpen || teaserDismissed) return; // Not marked as started, so a later update() that enables the teaser // (e.g. async API config arriving) can still kick off the sequence. diff --git a/packages/sdk/src/state.ts b/packages/sdk/src/state.ts index 834d482..4491740 100644 --- a/packages/sdk/src/state.ts +++ b/packages/sdk/src/state.ts @@ -59,3 +59,49 @@ export function setPersistedOpenState( // Storage can be unavailable in some browser/privacy modes. } } + +type TeaserDismissedState = { + v: 1; + dismissed: boolean; +}; + +type TeaserDismissedKey = { + researchId: string; + host?: string; +}; + +function getTeaserDismissedKey({ researchId, host }: TeaserDismissedKey) { + return `${STORAGE_KEYS.teaserDismissed}:${getHost(host)}:${researchId}`; +} + +/** Whether the user dismissed the float teaser this browser session. */ +export function getPersistedTeaserDismissed(key: TeaserDismissedKey): boolean { + if (!hasDom()) { + return false; + } + + try { + const raw = sessionStorage.getItem(getTeaserDismissedKey(key)); + if (!raw) { + return false; + } + + const parsed = JSON.parse(raw) as Partial; + return parsed.dismissed === true; + } catch { + return false; + } +} + +export function setPersistedTeaserDismissed(key: TeaserDismissedKey): void { + if (!hasDom()) { + return; + } + + try { + const value: TeaserDismissedState = { v: 1, dismissed: true }; + sessionStorage.setItem(getTeaserDismissedKey(key), JSON.stringify(value)); + } catch { + // Storage can be unavailable in some browser/privacy modes. + } +} diff --git a/packages/sdk/src/styles.ts b/packages/sdk/src/styles.ts index 4851a42..3da2ab0 100644 --- a/packages/sdk/src/styles.ts +++ b/packages/sdk/src/styles.ts @@ -363,6 +363,38 @@ export function injectStyles(): void { font-weight: 500; } + .perspective-float-teaser-dismiss { + position: absolute; + top: -8px; + right: -8px; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border-radius: 50%; + border: 1px solid var(--perspective-teaser-border); + background: var(--perspective-teaser-bg); + color: var(--perspective-close-text); + box-shadow: var(--perspective-shadow-sm); + cursor: pointer; + } + + .perspective-float-teaser-dismiss:hover { + color: var(--perspective-close-hover-text); + } + + .perspective-float-teaser-dismiss:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + + .perspective-float-teaser-dismiss svg { + width: 12px; + height: 12px; + } + @keyframes perspective-teaser-in { from { opacity: 0; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 72153d0..4121cf9 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -122,6 +122,8 @@ export interface TeaserConfig { delay?: number; /** Whether the chime sound plays. It fires 1s before the teaser appears (or immediately, when `delay` is under 1s). Default: `true`. */ sound?: boolean; + /** Whether the teaser shows an × button that dismisses it (and the notification dot) without opening the chat. A dismissal persists for the browser session. Default: `true`. */ + dismissible?: boolean; } /** Customization options for the float launcher button */