"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 (