Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/widget-nudge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@getmunin/chat-widget': minor
'@getmunin/backend-core': minor
'@getmunin/docs-pages': minor
---

Chat widget: an opt-in nudge above the closed launcher, plus launcher refinements.

- `data-munin-nudge` shows a short teaser after a delay: an eyebrow (org name · now), a message bubble, a dismiss button and an inline input. Leave the value empty for a localized default (all 21 bundled languages). Sending from the input opens the panel straight into a new conversation with that message as the first turn. The launcher badge reads `1` while it shows; real unread messages take precedence.
- It never appears once the panel has been opened or the current session already has messages, and a dismissal or open snoozes it for 7 days per channel (stored in `localStorage`).
- `data-munin-nudge-delay` sets the delay in seconds (default 8, 0–3600). The bubble defaults to a light tint of the theme color; `data-munin-nudge-color` sets it explicitly, with the text flipping between ink and paper for contrast.
- The launcher now follows `data-munin-corners`: square (the default) gives a square launcher, rounded keeps the circle. On the square launcher the unread badge sits centered on the corner; the badge is slightly larger and no longer has a border.
- Opening the widget when the visitor has no past or current conversation goes straight to a new chat instead of the welcome screen.
- `skill://conv/setup-chat-widget` and the chat-widget docs guide document the new attributes and `data-munin-corners`.
60 changes: 60 additions & 0 deletions apps/chat-widget/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ describe('parseConfig', () => {
corners: 'square',
colorScheme: 'auto',
showHistory: true,
nudge: null,
nudgeDelayMs: 8000,
});
expect(result.config.visitor).toBeUndefined();
expect(result.config.externalId).toBeUndefined();
Expand Down Expand Up @@ -519,3 +521,61 @@ describe('parseConfig', () => {
expect(result.config.visitor?.name).toHaveLength(120);
});
});

describe('parseConfig nudge', () => {
const required = {
'data-munin-host': 'https://munin.example.com',
'data-widget-key': 'mn_widget_abc',
'data-channel-id': 'cnv_001',
};

beforeEach(() => {
document.body.innerHTML = '';
});

function parse(attrs: Record<string, string>) {
const result = parseConfig(makeScript({ ...required, ...attrs }));
if (!result.ok) throw new Error('expected a valid config');
return result;
}

it('leaves the nudge off when the attribute is absent', () => {
expect(parse({}).config.nudge).toBeNull();
});

it('uses the localized default for an empty or boolean-true value', () => {
expect(parse({ 'data-munin-nudge': '' }).config.nudge).toBe('');
expect(parse({ 'data-munin-nudge': 'true' }).config.nudge).toBe('');
});

it('treats an explicit false as off', () => {
expect(parse({ 'data-munin-nudge': 'false' }).config.nudge).toBeNull();
});

it('keeps custom text, trimmed and capped at 280 chars', () => {
expect(parse({ 'data-munin-nudge': ' Need a hand? ' }).config.nudge).toBe('Need a hand?');
expect(parse({ 'data-munin-nudge': 'x'.repeat(400) }).config.nudge).toHaveLength(280);
});

it('accepts a hex nudge color and warns on anything else', () => {
expect(parse({}).config.nudgeColor).toBeUndefined();
expect(parse({ 'data-munin-nudge-color': '#FFE8CC' }).config.nudgeColor).toBe('#FFE8CC');
const bad = parse({ 'data-munin-nudge-color': 'peach' });
expect(bad.config.nudgeColor).toBeUndefined();
expect(bad.warnings.map((w) => w.attr)).toContain('data-munin-nudge-color');
});

it('reads the delay in seconds and defaults to 8', () => {
expect(parse({}).config.nudgeDelayMs).toBe(8000);
expect(parse({ 'data-munin-nudge-delay': '2.5' }).config.nudgeDelayMs).toBe(2500);
expect(parse({ 'data-munin-nudge-delay': '0' }).config.nudgeDelayMs).toBe(0);
});

it('warns and falls back on an out-of-range or non-numeric delay', () => {
for (const bad of ['-1', 'soon', '', '9999']) {
const result = parse({ 'data-munin-nudge-delay': bad });
expect(result.config.nudgeDelayMs).toBe(8000);
expect(result.warnings.map((w) => w.attr)).toContain('data-munin-nudge-delay');
}
});
});
41 changes: 41 additions & 0 deletions apps/chat-widget/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export const WIDGET_END_USER_BODY_MAX_CHARS = 1_000;
export const WIDGET_END_USER_BODY_HTML_MAX_CHARS = 4_000;
export const NUDGE_TEXT_MAX_CHARS = 280;
export const NUDGE_DELAY_MAX_SECONDS = 3_600;

const VALID_POSITIONS = ['bottom-right', 'bottom-left'] as const;
type Position = (typeof VALID_POSITIONS)[number];
Expand Down Expand Up @@ -49,6 +51,9 @@ export interface WidgetConfig {
corners: Corners;
colorScheme: ColorScheme;
showHistory: boolean;
nudge: string | null;
nudgeDelayMs: number;
nudgeColor?: string;
visitor?: WidgetVisitor;
cookieDomain?: string;
}
Expand All @@ -70,6 +75,7 @@ const DEFAULTS = {
corners: 'square' as Corners,
colorScheme: 'auto' as ColorScheme,
showHistory: true,
nudgeDelaySeconds: 8,
};

export function parseConfig(scriptEl: HTMLElement): ParseResult {
Expand Down Expand Up @@ -121,6 +127,11 @@ export function parseConfig(scriptEl: HTMLElement): ParseResult {
DEFAULTS.colorScheme;
const showHistory =
optBool(scriptEl, 'data-munin-show-history', warnings) ?? DEFAULTS.showHistory;
const nudge = parseNudge(scriptEl);
const nudgeColor = optColor(scriptEl, 'data-munin-nudge-color', warnings);
const nudgeDelayMs =
(optSeconds(scriptEl, 'data-munin-nudge-delay', NUDGE_DELAY_MAX_SECONDS, warnings) ??
DEFAULTS.nudgeDelaySeconds) * 1000;

const visitor = parseVisitor(scriptEl, warnings);
const cookieDomain = optCookieDomain(scriptEl, warnings);
Expand Down Expand Up @@ -153,6 +164,9 @@ export function parseConfig(scriptEl: HTMLElement): ParseResult {
corners,
colorScheme,
showHistory,
nudge,
nudgeDelayMs,
nudgeColor,
visitor,
cookieDomain,
},
Expand Down Expand Up @@ -244,6 +258,33 @@ function optBool(el: HTMLElement, attr: string, warnings: ParseError[]): boolean
return undefined;
}

function parseNudge(el: HTMLElement): string | null {
const v = el.getAttribute('data-munin-nudge');
if (v === null) return null;
const t = v.trim();
const lower = t.toLowerCase();
if (lower === 'false' || lower === '0' || lower === 'no') return null;
if (t === '' || lower === 'true' || lower === '1' || lower === 'yes') return '';
return t.slice(0, NUDGE_TEXT_MAX_CHARS);
}

function optSeconds(
el: HTMLElement,
attr: string,
max: number,
warnings: ParseError[],
): number | undefined {
const v = el.getAttribute(attr);
if (v === null) return undefined;
const t = v.trim();
const n = Number(t);
if (t === '' || !Number.isFinite(n) || n < 0 || n > max) {
warnings.push({ attr, message: `${attr} must be a number of seconds between 0 and ${max}` });
return undefined;
}
return n;
}

function parseVisitor(el: HTMLElement, warnings: ParseError[]): WidgetVisitor | undefined {
const name = el.getAttribute('data-munin-visitor-name')?.trim();
const emailRaw = el.getAttribute('data-munin-visitor-email')?.trim();
Expand Down
36 changes: 36 additions & 0 deletions apps/chat-widget/src/nudge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { isNudgeSnoozed, NUDGE_SNOOZE_MS, snoozeNudge } from './nudge.ts';

describe('nudge snooze', () => {
beforeEach(() => {
localStorage.clear();
});

it('is not snoozed for a channel that was never dismissed', () => {
expect(isNudgeSnoozed('cch_never')).toBe(false);
});

it('stays snoozed inside the window and expires after it', () => {
const t0 = 1_700_000_000_000;
snoozeNudge('cch_window', t0);
expect(isNudgeSnoozed('cch_window', t0 + NUDGE_SNOOZE_MS - 1)).toBe(true);
expect(isNudgeSnoozed('cch_window', t0 + NUDGE_SNOOZE_MS)).toBe(false);
});

it('persists the snooze to localStorage per channel', () => {
snoozeNudge('cch_persist', 1234);
expect(localStorage.getItem('munin-widget-nudge-snoozed:cch_persist')).toBe('1234');
expect(isNudgeSnoozed('cch_other', 1234)).toBe(false);
});

it('reads a snooze written by an earlier page load', () => {
const now = Date.now();
localStorage.setItem('munin-widget-nudge-snoozed:cch_earlier', String(now - 1000));
expect(isNudgeSnoozed('cch_earlier', now)).toBe(true);
});

it('ignores a corrupt stored value', () => {
localStorage.setItem('munin-widget-nudge-snoozed:cch_corrupt', 'garbage');
expect(isNudgeSnoozed('cch_corrupt')).toBe(false);
});
});
30 changes: 30 additions & 0 deletions apps/chat-widget/src/nudge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const SNOOZE_PREFIX = 'munin-widget-nudge-snoozed:';
export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;

const memory = new Map<string, number>();

export function isNudgeSnoozed(channelId: string, now: number = Date.now()): boolean {
const at = memory.get(channelId) ?? readStoredAt(channelId);
if (at === null) return false;
return now - at < NUDGE_SNOOZE_MS;
}

export function snoozeNudge(channelId: string, now: number = Date.now()): void {
memory.set(channelId, now);
try {
localStorage.setItem(SNOOZE_PREFIX + channelId, String(now));
} catch {
return;
}
}

function readStoredAt(channelId: string): number | null {
try {
const raw = localStorage.getItem(SNOOZE_PREFIX + channelId);
if (raw === null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const cs: Strings = {
attachTooLarge: 'Obrázek musí mít méně než {n} MB.',
attachmentUnavailable: 'Obrázek už není dostupný',
lightboxCloseAriaLabel: 'Zavřít obrázek',
nudgeDefault: 'Dobrý den. Máte dotaz? Napište ho sem a hned odpovíme.',
nudgePlaceholder: 'Zeptejte se…',
nudgeAuthor: 'Zákaznická podpora',
nudgeDismissAriaLabel: 'Skrýt zprávu',
};

export default cs;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const da: Strings = {
attachTooLarge: 'Billeder skal være under {n} MB.',
attachmentUnavailable: 'Billedet er ikke længere tilgængeligt',
lightboxCloseAriaLabel: 'Luk billedet',
nudgeDefault: 'Hej. Er der noget, du vil vide? Skriv her, så svarer vi med det samme.',
nudgePlaceholder: 'Stil et spørgsmål…',
nudgeAuthor: 'Kundesupport',
nudgeDismissAriaLabel: 'Skjul beskeden',
};

export default da;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const de: Strings = {
attachTooLarge: 'Bilder müssen kleiner als {n} MB sein.',
attachmentUnavailable: 'Bild nicht mehr verfügbar',
lightboxCloseAriaLabel: 'Bild schließen',
nudgeDefault: 'Hallo. Haben Sie eine Frage? Schreiben Sie hier, wir antworten sofort.',
nudgePlaceholder: 'Stellen Sie eine Frage…',
nudgeAuthor: 'Kundensupport',
nudgeDismissAriaLabel: 'Nachricht ausblenden',
};

export default de;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const en: Strings = {
attachTooLarge: 'Images have to be under {n} MB.',
attachmentUnavailable: 'Image no longer available',
lightboxCloseAriaLabel: 'Close image',
nudgeDefault: "Hi. Got a question? Type it here and we'll answer right away.",
nudgePlaceholder: "Ask a question…",
nudgeAuthor: "Support",
nudgeDismissAriaLabel: "Dismiss message",
};

export default en;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const es: Strings = {
attachTooLarge: 'Las imágenes deben pesar menos de {n} MB.',
attachmentUnavailable: 'La imagen ya no está disponible',
lightboxCloseAriaLabel: 'Cerrar la imagen',
nudgeDefault: 'Hola. ¿Tienes alguna pregunta? Escríbela aquí y te respondemos al momento.',
nudgePlaceholder: 'Haz una pregunta…',
nudgeAuthor: 'Atención al cliente',
nudgeDismissAriaLabel: 'Ocultar el mensaje',
};

export default es;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/et.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const et: Strings = {
attachTooLarge: 'Pilt peab olema alla {n} MB.',
attachmentUnavailable: 'Pilt ei ole enam saadaval',
lightboxCloseAriaLabel: 'Sulge pilt',
nudgeDefault: 'Tere. Kas sul on küsimus? Kirjuta siia ja vastame kohe.',
nudgePlaceholder: 'Esita küsimus…',
nudgeAuthor: 'Klienditugi',
nudgeDismissAriaLabel: 'Peida sõnum',
};

export default et;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/fi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const fi: Strings = {
attachTooLarge: 'Kuvan koko saa olla enintään {n} MB.',
attachmentUnavailable: 'Kuva ei ole enää saatavilla',
lightboxCloseAriaLabel: 'Sulje kuva',
nudgeDefault: 'Hei. Onko sinulla kysyttävää? Kirjoita tähän, niin vastaamme heti.',
nudgePlaceholder: 'Kysy jotain…',
nudgeAuthor: 'Asiakastuki',
nudgeDismissAriaLabel: 'Piilota viesti',
};

export default fi;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const fr: Strings = {
attachTooLarge: 'Les images doivent peser moins de {n} Mo.',
attachmentUnavailable: 'Image plus disponible',
lightboxCloseAriaLabel: 'Fermer l’image',
nudgeDefault: 'Bonjour. Une question ? Écrivez-la ici, nous répondons tout de suite.',
nudgePlaceholder: 'Posez une question…',
nudgeAuthor: 'Service client',
nudgeDismissAriaLabel: 'Masquer le message',
};

export default fr;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/hu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const hu: Strings = {
attachTooLarge: 'A kép legfeljebb {n} MB lehet.',
attachmentUnavailable: 'A kép már nem elérhető',
lightboxCloseAriaLabel: 'Kép bezárása',
nudgeDefault: 'Szia. Kérdésed van? Írd ide, és azonnal válaszolunk.',
nudgePlaceholder: 'Tegyél fel egy kérdést…',
nudgeAuthor: 'Ügyfélszolgálat',
nudgeDismissAriaLabel: 'Üzenet elrejtése',
};

export default hu;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const is: Strings = {
attachTooLarge: 'Myndir verða að vera undir {n} MB.',
attachmentUnavailable: 'Myndin er ekki lengur tiltæk',
lightboxCloseAriaLabel: 'Loka mynd',
nudgeDefault: 'Hæ. Ertu með spurningu? Skrifaðu hér og við svörum strax.',
nudgePlaceholder: 'Spyrðu spurningar…',
nudgeAuthor: 'Þjónustuver',
nudgeDismissAriaLabel: 'Fela skilaboð',
};

export default is;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const it: Strings = {
attachTooLarge: 'Le immagini devono pesare meno di {n} MB.',
attachmentUnavailable: 'Immagine non più disponibile',
lightboxCloseAriaLabel: 'Chiudi l’immagine',
nudgeDefault: 'Ciao. Hai una domanda? Scrivila qui e ti rispondiamo subito.',
nudgePlaceholder: 'Fai una domanda…',
nudgeAuthor: 'Assistenza clienti',
nudgeDismissAriaLabel: 'Nascondi il messaggio',
};

export default it;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/lt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const lt: Strings = {
attachTooLarge: 'Vaizdas turi būti mažesnis nei {n} MB.',
attachmentUnavailable: 'Vaizdas nebepasiekiamas',
lightboxCloseAriaLabel: 'Uždaryti vaizdą',
nudgeDefault: 'Sveiki. Turite klausimą? Parašykite čia ir iškart atsakysime.',
nudgePlaceholder: 'Užduokite klausimą…',
nudgeAuthor: 'Klientų aptarnavimas',
nudgeDismissAriaLabel: 'Paslėpti žinutę',
};

export default lt;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/lv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const lv: Strings = {
attachTooLarge: 'Attēlam jābūt mazākam par {n} MB.',
attachmentUnavailable: 'Attēls vairs nav pieejams',
lightboxCloseAriaLabel: 'Aizvērt attēlu',
nudgeDefault: 'Sveiki. Vai jums ir jautājums? Rakstiet šeit, un mēs atbildēsim uzreiz.',
nudgePlaceholder: 'Uzdodiet jautājumu…',
nudgeAuthor: 'Klientu atbalsts',
nudgeDismissAriaLabel: 'Paslēpt ziņu',
};

export default lv;
4 changes: 4 additions & 0 deletions apps/chat-widget/src/strings/nb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const nb: Strings = {
attachTooLarge: 'Bildet må være under {n} MB.',
attachmentUnavailable: 'Bildet er ikke tilgjengelig lenger',
lightboxCloseAriaLabel: 'Lukk bildet',
nudgeDefault: 'Hei. Lurer du på noe? Skriv her, så svarer vi med en gang.',
nudgePlaceholder: 'Still et spørsmål…',
nudgeAuthor: 'Kundesupport',
nudgeDismissAriaLabel: 'Skjul meldingen',
};

export default nb;
Loading
Loading