"use client";
/**
* blip-react: Blip's buttons and player for a React app (Next.js, Vite, Remix...). One file, React 18 or 19, nothing
* else. Browser only; your API key never comes here. Get the latest:
* curl -o src/components/blip.tsx https://sendablip.com/plugin/v1/blip-react.tsx
* Install guide (the server routes these call): https://sendablip.com/plugin/v1/INSTALL.md
*
* a Record button; your route POST /api/blip/record answers {url}
* ...} /> Report a problem
* Blip's own button on every page it's on: a tab on the right edge,
* launcher="circle" or "pill", position="left", color="#0F766E"
* a Blip, playing
* Blip Reply's "Leave a video review"
* a student's conversation with their coach
* ...} /> your coaches' and assistants' inbox
*/
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
export const BLIP_REACT_VERSION = "2.27.0";
const BASE = "https://sendablip.com"; // brand-files: origin (scripts/brand-files.mts writes it from src/lib/brand.ts)
const NAME = "Blip"; // brand-files: name (scripts/brand-files.mts writes it from src/lib/brand.ts)
type BlipRecorderApi = {
open(source: string | (() => Promise), options?: { anchor?: Element | null; inPage?: boolean }): Promise;
ask(endpoint: string, data?: Record): () => Promise;
isOpen(): boolean;
close(): void;
};
type BlipSupportApi = {
open(options?: { way?: "write" | "screenshot" | "video"; anchor?: Element | null; token?: string; name?: string; email?: string; id?: string; expires?: number; signature?: string; onSent?: (t: { ref: string }) => void }): void;
close(): void;
isOpen(): boolean;
identify(person: { name?: string; email?: string; id?: string; expires?: number; signature?: string } | null): void;
};
declare global {
interface Window {
BlipRecorder?: BlipRecorderApi;
BlipSupport?: BlipSupportApi;
BlipReply?: { open(slug: string): void };
}
}
const loading = new Map>();
const READY: Record boolean> = {
"recorder.js": () => !!window.BlipRecorder,
"support.js": () => !!window.BlipSupport,
};
/** Load one of Blip's hosted scripts once per page (or use the one already on it). */
export function loadBlipScript(src: string, attrs: Record = {}): Promise {
if (typeof document === "undefined") return Promise.resolve();
if (READY[src.split("/").pop() ?? ""]?.()) return Promise.resolve();
const known = loading.get(src);
if (known) return known;
const p = new Promise((resolve, reject) => {
const there = document.querySelector(`script[src="${src}"]`);
if (there && there.dataset.blipLoaded) return resolve();
const s = there ?? document.createElement("script");
s.addEventListener("load", () => {
s.dataset.blipLoaded = "1";
resolve();
});
s.addEventListener("error", () => {
loading.delete(src);
reject(new Error(`Couldn't load ${src}`));
});
if (!there) {
s.src = src;
s.async = true;
for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v);
document.head.appendChild(s);
}
});
loading.set(src, p);
return p;
}
const BUTTON: CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 8,
minHeight: 40,
padding: "8px 16px",
border: 0,
borderRadius: 999,
background: "#8C00FF",
color: "#fff",
font: "inherit",
fontWeight: 600,
cursor: "pointer",
};
const DOT: CSSProperties = { width: 10, height: 10, borderRadius: "50%", background: "#ff453a", flex: "none" };
interface Look {
className?: string;
/** Replaces the built-in look. Pass className alone to style it yourself (the built-in look is then left off). */
style?: CSSProperties;
children?: ReactNode;
/** Blip's address; only for a test server. */
base?: string;
}
const lookOf = (l: Look): CSSProperties | undefined => (l.className && !l.style ? undefined : { ...BUTTON, ...l.style });
/**
* A Record button. On click it POSTs {ref, title} to your own route (endpoint), which answers {url} from the plugin's
* createRequest (anyone records, no Blip account) or recorderUrl (your team, signed in to Blip), and opens the recorder
* inside your page, next to the button (a recorder link for your team opens a window, since it signs in to Blip). The
* finished Blip comes to your webhook as video.ready.
*/
export function BlipRecordButton({
endpoint = "/api/blip/record",
blipRef,
title,
onOpen,
onRecorded,
onError,
disabled,
...look
}: Look & {
endpoint?: string;
/** Your ref: your id for what it's about, like "yourapp:project:42" (React keeps the name ref for itself). */
blipRef?: string;
title?: string;
onOpen?: (url: string) => void;
/** Their recording is uploaded (a request opened inside your page): its Blip id. video.ready follows when it's ready to play. */
onRecorded?: (videoId: string) => void;
onError?: (message: string) => void;
disabled?: boolean;
}) {
const base = look.base ?? BASE;
const button = useRef(null);
const [busy, setBusy] = useState(false);
const recorded = useRef(onRecorded);
recorded.current = onRecorded;
useEffect(() => {
const heard = (e: Event) => recorded.current?.((e as CustomEvent<{ video_id: string }>).detail?.video_id ?? "");
addEventListener("blip:recorded", heard);
return () => removeEventListener("blip:recorded", heard);
}, []);
useEffect(() => {
loadBlipScript(`${base}/plugin/v1/recorder.js`).catch(() => onError?.(`Couldn't load ${NAME}'s recorder.`));
// Once per page.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [base]);
function record() {
const go = () => {
const r = window.BlipRecorder;
if (!r) throw new Error(`${NAME}'s recorder didn't load.`);
setBusy(true);
// Called right in the click, so the recorder's window isn't blocked.
return r.open(r.ask(endpoint, { ref: blipRef, title }), { anchor: button.current }).then(onOpen);
};
const done = (e: unknown) => onError?.(e instanceof Error ? e.message : "Couldn't open the recorder.");
try {
const p = window.BlipRecorder ? go() : loadBlipScript(`${base}/plugin/v1/recorder.js`).then(go);
p.catch(done).finally(() => setBusy(false));
} catch (e) {
done(e);
}
}
return (
);
}
/**
* Report a problem: people write it, take a screenshot and mark it up, or show it on video, and it's a ticket in your
* Blip Support inbox and (with ticket.created ticked on your app) a webhook to your app. token is the support button's,
* from Blip, Settings, Developers; your site must be on its list. Where your workspace has published guides (2.17), the
* card shows the ones set for the page and ones that match what's typed, and Send stays on screen throughout: nothing
* to pass here. floating: Blip's own button (a tab on the right edge by
* default; launcher, position, color, icon to change it).
*/
export function BlipSupportButton({
token,
way = "write",
user,
network,
onTicket,
floating,
launcher,
icon = true,
logo,
label = "Report a problem",
position,
color,
...look
}: Look & {
token: string;
way?: "write" | "screenshot" | "video";
/**
* Who's signed in to your app: fills in their name and email. Signed on your server (2.24: pass what the plugin's
* signSupportUser() returned, whole), Blip checks it, the ticket is theirs and your team sees Verified.
*/
user?: { name?: string | null; email?: string | null; id?: string; expires?: number; signature?: string } | null;
/**
* Send the page's failed requests with each report (2.24): the method, the address without its query string, the
* status and the time. Never bodies, headers or cookies. Off unless you set it; it has to be set when the script
* first loads on the page.
*/
network?: boolean;
/** The ticket's number as they saw it (your app's, if your webhook answered with one), and (2.13) the link to their status page for it. */
onTicket?: (ref: string, url: string | null) => void;
/** Blip's own button, on every page this is on, instead of a button here. */
floating?: boolean;
/** The floating button's look: a tab on the screen's edge (the default), a round button, or a pill with the words. */
launcher?: "tab" | "circle" | "pill";
/** Blip's icon on the floating button (always on the circle). */
icon?: boolean;
/** Your own icon on the floating button instead of Blip's: an https image address. */
logo?: string;
label?: string;
position?: "left" | "right";
/** The floating button's colour, a hex. Blip purple (#8C00FF) by default. */
color?: string;
}) {
const base = look.base ?? BASE;
const button = useRef(null);
const heard = useRef(onTicket);
heard.current = onTicket;
useEffect(() => {
const attrs: Record = { "data-token": token };
if (network) attrs["data-network"] = "true";
if (!floating) attrs["data-button"] = "false";
else {
attrs["data-label"] = label;
if (launcher && launcher !== "tab") attrs["data-launcher"] = launcher;
if (position === "left") attrs["data-position"] = "left";
if (color) attrs["data-color"] = color;
if (!icon) attrs["data-icon"] = "none";
else if (logo && /^https:\/\//.test(logo)) attrs["data-logo"] = logo;
if (way !== "write") attrs["data-way"] = way;
}
loadBlipScript(`${base}/plugin/v1/support.js`, attrs).catch(() => undefined);
const sent = (e: Event) => {
const d = (e as CustomEvent<{ ref: string; url?: string | null }>).detail;
heard.current?.(d?.ref ?? "", d?.url || null);
};
addEventListener("blip:ticket", sent);
return () => removeEventListener("blip:ticket", sent);
// The first one on the page sets the floating button's look.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [base, token]);
const name = user?.name ?? "", email = user?.email ?? "";
// The signed parts go along whole when your server made them; without them it's a pre-fill, as before.
const signature = user?.signature ?? "", expires = user?.expires ?? 0, id = user?.id ?? "";
const who = useMemo(() => (name || email ? { name, email, ...(signature && expires ? { signature, expires, ...(id ? { id } : {}) } : {}) } : null), [name, email, signature, expires, id]);
useEffect(() => {
const tell = () => window.BlipSupport?.identify(who);
if (window.BlipSupport) tell();
else loadBlipScript(`${base}/plugin/v1/support.js`).then(tell, () => undefined);
}, [base, who]);
if (floating) return null;
const open = () => {
const go = () => window.BlipSupport?.open({ token, way, anchor: button.current, ...(who ?? {}) });
if (window.BlipSupport) go();
else loadBlipScript(`${base}/plugin/v1/support.js`).then(go, () => undefined);
};
return (
);
}
/** A Blip, playing: its share code (video.share.code) or its embed_url. The share link must be on, or use player_url. */
export function BlipPlayer({ code, url, title = NAME, aspect = 16 / 9, className, style, base = BASE }: { code?: string; url?: string; title?: string; aspect?: number; className?: string; style?: CSSProperties; base?: string }) {
const src = url ?? (code && /^[0-9a-f]{24}$/.test(code) ? `${base}/embed/${code}` : null);
if (!src) return null;
return (
);
}
/** Blip Reply's button: opens the Reply's open link (its Sharing tab, "Anyone with the link") in a small window. */
export function BlipReplyButton({ slug, label = "Leave a video review", ...look }: Look & { slug: string; label?: string }) {
const base = look.base ?? BASE;
const open = () => {
if (!/^[a-z0-9][a-z0-9-]{0,58}[a-z0-9]$/.test(slug)) return;
const url = `${base}/reply/open/${slug}`;
const h = Math.min(860, (screen.availHeight || 860) - 40);
const win = window.open(url, `blip-reply-${slug}`, `popup=yes,width=480,height=${h}`);
if (win) {
win.opener = null;
win.focus();
} else window.open(url, "_blank", "noopener");
};
return (
);
}
// ---- Threads -----------------------------------------------------------------------------------------------------------
export interface BlipThreadProps {
/** `thread_url` (BlipThread) or `inbox_url` (BlipThreadInbox) from your server's createThreadSession. */
url: string | null | undefined;
/** Your colour for the buttons and the person's own messages: a hex like "#0F766E". */
accent?: string;
/** Fix the look; without it, the person's device decides. */
theme?: "light" | "dark";
/** Pixels, or a CSS length ("100%" to fill a box you size yourself). 640 by default. */
height?: number | string;
/** The frame's name for screen readers. */
title?: string;
className?: string;
style?: CSSProperties;
/** Conversations with something new for this person. */
onUnread?: (count: number) => void;
/** A message was sent from here. */
onMessage?: (message: { thread_id: string }) => void;
/** The session ran out (two hours): ask your server for a new one and pass its url. */
onExpired?: () => void;
base?: string;
}
function ThreadFrame({ url, accent, theme, about, height = 640, title, className, style, onUnread, onWaiting, onMessage, onExpired, base = BASE }: BlipThreadProps & { about?: string; onWaiting?: (count: number) => void }) {
const frame = useRef(null);
const heard = useRef({ onUnread, onWaiting, onMessage, onExpired });
useEffect(() => {
heard.current = { onUnread, onWaiting, onMessage, onExpired };
});
const origin = useMemo(() => {
try {
return new URL(base).origin;
} catch {
return BASE;
}
}, [base]);
// Only Blip's own Threads page, with your look added.
const src = useMemo(() => {
try {
const u = new URL(url ?? "");
if (u.origin !== origin || u.pathname !== "/threads/embed") return null;
if (accent && /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) u.searchParams.set("accent", accent.replace(/^#?/, "#"));
if (theme === "light" || theme === "dark") u.searchParams.set("theme", theme);
if (about) u.searchParams.set("ref", about.slice(0, 200));
return u.toString();
} catch {
return null;
}
}, [url, origin, accent, theme, about]);
useEffect(() => {
const on = (e: MessageEvent) => {
const m = e.data as { blip?: string; unread?: number; waiting?: number; message?: { thread_id?: string }; expired?: boolean } | null;
if (e.source !== frame.current?.contentWindow || e.origin !== origin || !m || typeof m !== "object" || m.blip !== "threads") return;
if (typeof m.unread === "number") {
heard.current.onUnread?.(m.unread);
if (typeof m.waiting === "number") heard.current.onWaiting?.(m.waiting);
} else if (typeof m.message?.thread_id === "string") heard.current.onMessage?.({ thread_id: m.message.thread_id });
else if (m.expired === true) heard.current.onExpired?.();
};
addEventListener("message", on);
return () => removeEventListener("message", on);
}, [origin]);
if (!src) return null;
return (