/** * Wandamark SDK: batches events and sends them to the Wandamark ingest API. * Works in browsers, React Native, Node 18+ and edge runtimes (anything with fetch). * * const wm = new Wandamark({ key: "wm_pub_…", endpoint: "https://your-wandamark-host" }); * wm.session(user.id); * wm.reportBug({ title: "Crash on launch", severity: "critical", userId: user.id }); * wm.reportUser({ reportedUserId: "u_456", reason: "harassment", userId: user.id }); * wm.startTest(); // paid user testing: records only testers who opened a Wandamark test link */ export type Severity = "critical" | "high" | "medium" | "low"; export type ReportReason = | "harassment" | "scam" | "impersonation" | "inappropriate" | "hate" | "spam" | "other" | (string & {}); export interface WandamarkOptions { /** wm_pub_… (client, safe to ship) or wm_live_… (server only). */ key: string; /** Your Wandamark host, e.g. https://wandamark.example.com */ endpoint: string; /** Flush when this many events are queued (default 20, max 500). */ batchSize?: number; /** Flush at least this often in ms (default 5000). Set 0 to only flush manually. */ flushInterval?: number; /** Retries per batch on network or 5xx errors (default 3). */ maxRetries?: number; /** Timeout for each request, in milliseconds (default 10000). */ requestTimeout?: number; /** Called when a batch is permanently dropped or events are rejected. */ onError?: (error: Error, rejected?: { index: number; error: string }[]) => void; fetch?: typeof fetch; /** * Browsers: report clicks on elements with data-wm-action="name" (and submits of such forms) as actions for * the user set by session()/signup()/identify(). Analytics only; referral rewards need your server key. */ autoActions?: boolean; } export type WandamarkEvent = { type: string; timestamp?: string; [k: string]: unknown }; export interface IngestResult { accepted: number; rejected: { index: number; error: string }[]; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export class Wandamark { private queue: WandamarkEvent[] = []; private timer: ReturnType | null = null; private flushing: Promise | null = null; private test: WandamarkTest | null = null; private actions: { name: string; user_id: string; value?: number; buyerEmail?: string; timestamp: string; event_id: string }[] = []; private userId: string | null = null; private hiding = false; private readonly onPageHide = () => { this.hiding = true; void this.flush(); }; private readonly opts: Required> & Pick; constructor(options: WandamarkOptions) { if (!options?.key || !/^wm_(pub|live)_/.test(options.key)) { throw new Error("Wandamark: pass your app key (wm_pub_… or wm_live_…)"); } if (!options.endpoint || !/^https?:\/\//.test(options.endpoint)) { throw new Error("Wandamark: pass endpoint, the URL of your Wandamark host"); } this.opts = { batchSize: 20, flushInterval: 5000, maxRetries: 3, requestTimeout: 10000, ...options, }; for (const key of ["batchSize", "flushInterval", "maxRetries", "requestTimeout"] as const) { const value = this.opts[key]; if (!Number.isInteger(value) || value < (key === "batchSize" || key === "requestTimeout" ? 1 : 0)) { throw new Error(`Wandamark: ${key} must be a valid integer`); } } this.opts.endpoint = this.opts.endpoint.replace(/\/$/, ""); this.opts.batchSize = Math.min(500, Math.max(1, this.opts.batchSize)); if (this.opts.flushInterval > 0) { this.timer = setInterval(() => void this.flush(), this.opts.flushInterval); (this.timer as { unref?: () => void }).unref?.(); } if (typeof window !== "undefined" && typeof window.addEventListener === "function") { // Keep the referral code from a ?wm_ref= landing even if referralCode() is only called on a later page. readReferralCode(); window.addEventListener("pagehide", this.onPageHide); if (options.autoActions && typeof document !== "undefined") { const fromElement = (el: Element | null) => { const name = el?.closest("[data-wm-action]")?.getAttribute("data-wm-action"); if (name && this.userId) this.action(name); }; document.addEventListener("click", (e) => { const el = e.target instanceof Element ? e.target : null; if (el && !el.closest("form[data-wm-action]")) fromElement(el); }, { capture: true, passive: true }); document.addEventListener("submit", (e) => fromElement(e.target instanceof Element ? e.target : null), { capture: true, passive: true }); } } } /** Queue any event. */ track(event: WandamarkEvent): void { this.queue.push({ timestamp: new Date().toISOString(), ...event, event_id: event.event_id ?? globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` }); if (this.queue.length >= this.opts.batchSize) void this.flush(); } /** A user opened the app (drives DAU, MAU, retention). */ session(userId: string, extra: { userName?: string; userCreatedAt?: string } = {}) { this.userId = userId; this.track({ type: "session", user_id: userId, user_name: extra.userName, user_created_at: extra.userCreatedAt }); } signup(userId: string, extra: { userName?: string } = {}) { this.userId = userId; this.track({ type: "signup", user_id: userId, user_name: extra.userName }); } identify(userId: string, traits: { userName?: string; createdAt?: string }) { this.userId = userId; this.track({ type: "identify", user_id: userId, user_name: traits.userName, user_created_at: traits.createdAt }); } crash(extra: { userId?: string } = {}) { this.track({ type: "crash", user_id: extra.userId }); } release(version: string) { this.track({ type: "release", version }); } reportBug(bug: { title: string; description?: string; severity?: Severity; userId?: string; contact?: string; device?: string; appVersion?: string; fingerprint?: string; }) { this.track({ type: "bug_report", title: bug.title, description: bug.description, severity: bug.severity, user_id: bug.userId, contact: bug.contact, device: bug.device ?? defaultDevice(), app_version: bug.appVersion, fingerprint: bug.fingerprint, }); } reportUser(report: { reportedUserId: string; reason: ReportReason; content?: string; userId?: string; reporterName?: string; reportedUserName?: string; reportedUserCreatedAt?: string; priority?: "high" | "medium" | "low"; context?: Record; externalId?: string; }) { this.track({ type: "user_report", reported_user_id: report.reportedUserId, reason: report.reason, content: report.content, user_id: report.userId, reporter_name: report.reporterName, reported_user_name: report.reportedUserName, reported_user_created_at: report.reportedUserCreatedAt, priority: report.priority, context: report.context, external_id: report.externalId, }); } /** * Server key only: the current state of one subscription (call on create, change, cancel). * Powers subscribers per plan, MRR and churn. Prices in USD. */ subscription(sub: { customerId: string; subscriptionId?: string; plan: string; price: number; interval?: "day" | "week" | "month" | "year"; intervalCount?: number; status?: "trialing" | "active" | "past_due" | "paused" | "canceled" | "expired"; startedAt?: string; currentPeriodEnd?: string; }) { this.track({ type: "subscription", customer_id: sub.customerId, subscription_id: sub.subscriptionId, plan: sub.plan, price: sub.price, interval: sub.interval, interval_count: sub.intervalCount, status: sub.status, started_at: sub.startedAt, current_period_end: sub.currentPeriodEnd, }); } /** Server key only. */ revenue( amount: number, currency = "USD", details: { customerId?: string; subscriptionId?: string; plan?: string; fee?: number; periodStart?: string; periodEnd?: string } = {}, ) { // A service period (e.g. an annual plan) is recognized month by month in financial statements. this.track({ type: "revenue", amount, currency, customer_id: details.customerId, subscription_id: details.subscriptionId, plan: details.plan, fee: details.fee, period_start: details.periodStart, period_end: details.periodEnd, }); } /** Server key only: dau, mau, store_rating, crash_rate, uptime, retention_d30. */ metric(name: string, value: number) { this.track({ type: "metric", name, value }); } /** * Paid user testing. When this device belongs to a tester in an active Wandamark test, starts recording * the session and returns it; otherwise returns null (call it on every launch, it's a no-op for everyone else). * In browsers the tester's code comes from the ?wm_test= link they opened; in native apps pass `code` * (from your deep link or a "tester code" field). */ startTest(options: StartTestOptions = {}): WandamarkTest | null { const code = options.code ?? readTestCode(); if (!code || !TEST_CODE.test(code)) return null; if (this.test && this.test.code === code && !this.test.ended) return this.test; void this.test?.end(); this.test = new WandamarkTest(this.opts, code, options); return this.test; } /** * Paid referrals, browser side: the referral code of the link this visitor arrived from (?wm_ref=), kept for * 30 days. Send it to your backend with the sign-up so your server can call referralSignup(). */ referralCode(): string | null { return readReferralCode(); } /** * Cash referrals, server key only: attribute a verified signup to the referrer for zero cash. * Confirm subsequent paid purchases with referralSale(); the campaign applies its holdback. Pass the friend's email to block self-referrals and duplicate accounts * (only its SHA-256 is sent). Never call it for installs, store ratings or page views. */ async referralSignup(signup: { code: string; userId: string; email: string; event?: string; signedUpAt?: string | Date }): Promise { if (!this.opts.key.startsWith("wm_live_")) throw new Error("Wandamark: referralSignup() requires your server key (wm_live_…)"); if (!signup.email?.trim()) throw new Error("Wandamark: a verified email is required to prevent self-referrals and duplicate accounts"); const f = this.opts.fetch ?? globalThis.fetch; const res = await f(`${this.opts.endpoint}/api/v1/referrals/conversions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.key}` }, // Only a SHA-256 of the email is sent: enough to catch self-referrals and duplicate accounts. // signedUpAt: when the account was really created (e.g. before email verification), up to 7 days back. body: JSON.stringify({ code: signup.code, user_id: signup.userId, email_sha256: await sha256Hex(signup.email.trim().toLowerCase()), event: signup.event ?? "signup", signed_up_at: signup.signedUpAt ? new Date(signup.signedUpAt).toISOString() : undefined }), signal: AbortSignal.timeout?.(this.opts.requestTimeout), }); const body = (await res.json().catch(() => ({}))) as ReferralResult & { error?: { message?: string } }; if (!res.ok) throw new Error(body.error?.message ?? `Wandamark responded ${res.status}`); return body; } /** * A user did something meaningful, e.g. wm.action("event_created") or wm.action("payment", { value: 1200 }). * Wandamark learns which actions new users reach and which predict they stay, and recommends what a referral * ladder should pay for. With your server key, actions by referred friends also complete ladder steps * (value adds up for "spent at least $X" steps). Pass buyerEmail for purchases so a sharer can't buy from * themselves; only its SHA-256 is sent. Use lowercase names with underscores. Server actions require * a stable eventId: reuse it across retries, including webhook redelivery. Use referralAction() for durable * server work so failures can be retried by your job runner rather than sent to the analytics error callback. */ action(name: string, details: { userId?: string; value?: number; buyerEmail?: string; eventId?: string; timestamp?: string } = {}) { const userId = details.userId ?? this.userId; if (!/^[a-z][a-z0-9_]{1,39}$/.test(name) || !userId) { this.reportError(new Error(`Wandamark: action "${name}" needs a lowercase_name and a userId (or call session() first)`)); return; } if (this.opts.key.startsWith("wm_live_") && !details.eventId?.trim()) { this.reportError(new Error("Wandamark: server actions require a stable eventId from your order/action record")); return; } this.actions.push({ name, user_id: userId, value: details.value, buyerEmail: details.buyerEmail, timestamp: details.timestamp ?? new Date().toISOString(), event_id: details.eventId ?? newId() }); if (this.actions.length >= this.opts.batchSize) void this.flush(); } /** * Server key: report a referred friend's action now and get the ladder outcome back * (pending reward, progress toward a value threshold, out_of_order, already_rewarded…). * eventId must identify the source business event and stay the same on retries. timestamp, when supplied, * is when the action occurred. Repeated event IDs return the original result without counting again. */ async referralAction(a: { userId: string; name: string; eventId: string; timestamp?: string; value?: number; buyerEmail?: string }): Promise { if (!this.opts.key.startsWith("wm_live_")) throw new Error("Wandamark: referralAction() requires your server key (wm_live_…)"); if (!a.eventId?.trim()) throw new Error("Wandamark: referralAction() requires a stable eventId from your order/action record"); const body = await this.postActions([{ name: a.name, user_id: a.userId, value: a.value, buyerEmail: a.buyerEmail, timestamp: a.timestamp ?? new Date().toISOString(), event_id: a.eventId }]); if (body.rejected?.length) throw new Error(body.rejected[0].error); return body.rewards ?? []; } /** Backend corroboration for a paid testing task. Authenticate the app user and bind the test code * to their session first; derive the outcome from your own data, never a browser's success claim. */ async testEvidence(e: {code:string;taskId:string;eventId:string;outcome:"completed"|"partial"|"failed"}): Promise<{recorded:boolean;replayed:boolean}> { if (!this.opts.key.startsWith("wm_live_")) throw new Error("Wandamark: testEvidence requires a server key"); const response=await (this.opts.fetch ?? globalThis.fetch)(`${this.opts.endpoint}/api/v1/tests/evidence`,{ method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.opts.key}`}, body:JSON.stringify({code:e.code,task_id:e.taskId,event_id:e.eventId,outcome:e.outcome}),signal:AbortSignal.timeout?.(this.opts.requestTimeout), }); const body=await response.json() as {recorded:boolean;replayed:boolean;error?:{message:string}}; if(!response.ok)throw new Error(body.error?.message ?? "Task evidence failed; retry with the same eventId"); return {recorded:body.recorded,replayed:body.replayed}; } /** Confirm an actual paid USD sale from your backend, after checking your payment processor. * Signup is attribution only. Reuse the same orderId on webhook retries. Never call from a browser. */ async referralSale(a: { userId: string; orderId: string; amount: number; currency: "USD"; buyerEmail: string; name?: "purchase" | "payment" | "subscription_paid" | "ticket_sold"; timestamp?: string }): Promise { if (!this.opts.key.startsWith("wm_live_")) throw new Error("Wandamark: referralSale requires a server key"); if (!a.orderId?.trim() || a.orderId.length > 195 || a.currency !== "USD" || !Number.isFinite(a.amount) || a.amount <= 0 || !a.buyerEmail?.trim()) throw new Error("Wandamark: a paid USD sale needs an order, positive amount and buyer email"); const response = await (this.opts.fetch ?? globalThis.fetch)(`${this.opts.endpoint}/api/v1/actions`, { method:"POST", headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.opts.key}`}, body:JSON.stringify({actions:[{name:a.name ?? "purchase",user_id:a.userId,event_id:`sale:${a.orderId}`,order_id:a.orderId, value:a.amount,currency:a.currency,paid:true,buyer_email_sha256:await sha256Hex(a.buyerEmail.trim().toLowerCase()),timestamp:a.timestamp ?? new Date().toISOString()}]}), signal:AbortSignal.timeout?.(this.opts.requestTimeout), }); const body = await response.json() as {rewards?:ActionReward[];rejected?:{error:string}[];error?:{message:string}}; if (!response.ok || body.rejected?.length) throw new Error(body.rejected?.[0]?.error ?? body.error?.message ?? "Sale confirmation failed; retry with the same orderId"); return body.rewards ?? []; } /** Report any actual refund, including partial refunds. Conservatively invalidates the customer's * commission ladder. review_required means a transfer may already exist and needs provider recovery. */ async referralRefund(r: {orderId:string;eventId:string}): Promise<{recorded:boolean;reversed:number;review_required:boolean}> { if (!this.opts.key.startsWith("wm_live_")) throw new Error("Wandamark: referralRefund requires a server key"); const response=await (this.opts.fetch ?? globalThis.fetch)(`${this.opts.endpoint}/api/v1/referrals/refunds`,{ method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.opts.key}`}, body:JSON.stringify({order_id:r.orderId,event_id:r.eventId}),signal:AbortSignal.timeout?.(this.opts.requestTimeout), }); const body=await response.json() as {recorded:boolean;reversed:number;review_required:boolean;error?:{message:string}}; if(!response.ok)throw new Error(body.error?.message ?? "Refund report failed; retry with the same eventId"); return body; } /** Authenticated Shoploop invitations. The public SDK key never authorizes cash. */ async shoploopInvite(accessToken: string): Promise { return this.inviteRequest(accessToken); } async createShoploopInvite(accessToken: string, disclosure: boolean): Promise { return this.inviteRequest(accessToken, {action: "create", disclosure}); } async acceptShoploopInvite(accessToken: string, code: string): Promise<{status: "attributed" | "already_attributed"}> { return this.inviteRequest(accessToken, {action: "accept", code}); } private async inviteRequest(accessToken: string, body?: Record): Promise { if (!accessToken.trim()) throw new Error("Sign in before using invitations."); const response = await (this.opts.fetch ?? globalThis.fetch)(`${this.opts.endpoint}/api/v1/shoploop/invites`, { method: body ? "POST" : "GET", headers: {Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json"}, ...(body ? {body: JSON.stringify(body)} : {}), cache: "no-store", redirect: "error", signal: AbortSignal.timeout(this.opts.requestTimeout), }); const data = await response.json() as T & {error?: {message?: string}}; if (!response.ok) throw new Error(data.error?.message ?? "Invitations are temporarily unavailable."); return data; } private async postActions(items: Wandamark["actions"]): Promise<{ accepted?: number; rejected?: { index: number; error: string }[]; rewards?: ActionReward[] }> { const f = this.opts.fetch ?? globalThis.fetch; const actions = await Promise.all(items.map(async ({ buyerEmail, ...a }) => ({ ...a, buyer_email_sha256: buyerEmail ? await sha256Hex(buyerEmail.trim().toLowerCase()) : undefined }))); const res = await f(`${this.opts.endpoint}/api/v1/actions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.key}` }, body: JSON.stringify({ actions }), keepalive: this.hiding, signal: AbortSignal.timeout?.(this.opts.requestTimeout), }); const body = (await res.json().catch(() => ({}))) as { accepted?: number; rejected?: { index: number; error: string }[]; rewards?: ActionReward[]; error?: { message?: string } }; if (!res.ok) { const err = new Error(body.error?.message ?? `Wandamark responded ${res.status}`) as Error & { retryable?: boolean }; err.retryable = res.status === 429 || res.status >= 500; throw err; } return body; } /** * Delivers a batch of actions, retrying network errors, 429/5xx responses and actions refused only by the * temporary referral-step rate limit. Every action keeps its event ID, so retries can't count or pay twice. */ private async deliverActions(batch: Wandamark["actions"]): Promise { let pending = batch; for (let attempt = 0; attempt <= this.opts.maxRetries && pending.length; attempt++) { if (attempt > 0) await sleep(Math.min(30000, 500 * 2 ** attempt) + Math.random() * 250); try { const body = await this.postActions(pending); const rejected = body.rejected ?? []; const transient = rejected.filter((r) => /^(rate_limited|retryable)/.test(r.error)); const final = rejected.filter((r) => !/^(rate_limited|retryable)/.test(r.error)); if (final.length) this.reportError(new Error(`${final.length} action(s) rejected`), final); if (!transient.length) return; pending = transient.map((r) => pending[r.index]).filter(Boolean); if (attempt === this.opts.maxRetries) this.reportError(new Error(`${pending.length} action(s) still rate limited`), transient); } catch (e) { const retryable = !(e instanceof Error) || (e as Error & { retryable?: boolean }).retryable !== false; if (!retryable || attempt === this.opts.maxRetries) { this.reportError(e instanceof Error ? e : new Error(String(e))); return; } } } } /** The running test session, if any. */ get currentTest(): WandamarkTest | null { return this.test && !this.test.ended ? this.test : null; } /** Send everything queued now. Resolves when the queue has been attempted. */ async flush(): Promise { if (this.flushing) return this.flushing; this.flushing = (async () => { while (this.queue.length) { const batch = this.queue.splice(0, this.opts.batchSize); await this.send(batch); } while (this.actions.length) { const batch = this.actions.splice(0, Math.min(this.opts.batchSize, 500)); await this.deliverActions(batch); } })().finally(() => { this.flushing = null; }); return this.flushing; } /** Flush and stop the background timer. */ async shutdown(): Promise { if (this.timer) clearInterval(this.timer); this.timer = null; if (typeof window !== "undefined") window.removeEventListener("pagehide", this.onPageHide); await Promise.all([this.flush(), this.test?.flush()]); } private async send(events: WandamarkEvent[]): Promise { const f = this.opts.fetch ?? globalThis.fetch; const payload = JSON.stringify({ events }); for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.opts.requestTimeout); try { const res = await f(`${this.opts.endpoint}/api/v1/events`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.key}` }, body: payload, keepalive: this.hiding && new TextEncoder().encode(payload).length < 60000, signal: controller.signal, }); if (res.status === 429 || res.status >= 500) throw new Error(`Wandamark responded ${res.status}`); const body = (await res.json().catch(() => ({}))) as Partial & { error?: { message?: string } }; if (!res.ok && !body.rejected?.length) { this.reportError(new Error(body.error?.message ?? `Wandamark responded ${res.status}`)); return; } if (body.rejected?.length) this.reportError(new Error(`${body.rejected.length} event(s) rejected`), body.rejected); return; } catch (e) { if (attempt === this.opts.maxRetries) { this.reportError(e instanceof Error ? e : new Error(String(e))); return; } await sleep(Math.min(30000, 500 * 2 ** attempt) + Math.random() * 250); } finally { clearTimeout(timer); } } } private reportError(error: Error, rejected?: IngestResult["rejected"]) { // A consumer's error handler must not trigger retries of an already accepted batch. try { this.opts.onError?.(error, rejected); } catch { /* consumer callback */ } } } // --------------------------------------------------------------------------- // Paid referrals // --------------------------------------------------------------------------- export interface ReferralResult { /** pending: rewarded after the holdback. Otherwise why no reward was given. */ status: "pending" | "duplicate" | "self_referral" | "event_not_qualified" | "program_closed" | "sharer_cap_reached" | "velocity_limited" | "budget_spent"; rewarded: boolean; /** The friend is now linked to the sharer: later ladder steps can pay. */ attributed?: boolean; reward_cents?: number; eligible_at?: string; /** Later steps only count until then. */ window_ends_at?: string; ladder?: { key: string; label: string; reward_cents: number; holdback_days: number; min_value: number | null; value_mode: "single" | "cumulative" }[]; } /** What one action did to a referred friend's ladder. */ export interface ActionReward { program_id: string; step: string; /** out_of_order: the previous step hasn't arrived yet; the event is held and pays automatically when it does. */ status: "pending" | "progress" | "already_rewarded" | "out_of_order" | "below_minimum" | "self_purchase" | "buyer_required"; /** True when an out-of-order event was held for later. */ deferred?: boolean; /** Set on outcomes of held events that this action released. */ event_id?: string; reward_cents?: number; eligible_at?: string; /** For running-total steps: value so far and the threshold. */ total?: number; needed?: number; } const REF_CODE = /^wmr_[A-Za-z0-9]{8,40}$/; async function sha256Hex(text: string): Promise { const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); } const REF_STORE = "wm_ref"; function readReferralCode(): string | null { if (typeof window === "undefined" || typeof location === "undefined") return null; try { const url = new URL(location.href); const fromUrl = url.searchParams.get("wm_ref"); if (fromUrl && REF_CODE.test(fromUrl)) { store()?.setItem(REF_STORE, JSON.stringify({ code: fromUrl, at: Date.now() })); url.searchParams.delete("wm_ref"); history.replaceState(history.state, "", url.pathname + url.search + url.hash); return fromUrl; } const saved = JSON.parse(store()?.getItem(REF_STORE) ?? "null") as { code?: string; at?: number } | null; if (saved?.code && REF_CODE.test(saved.code) && Date.now() - (saved.at ?? 0) < 30 * 86400_000) return saved.code; } catch { /* no storage or URL support */ } return null; } // --------------------------------------------------------------------------- // Paid user testing // --------------------------------------------------------------------------- export interface StartTestOptions { /** The tester's session code (wmt_…). Browsers read it from the ?wm_test= link the tester opened. */ code?: string; /** Browsers: record pages, clicks, rage and dead clicks, errors and active time (default true). Typed text is never recorded. */ autoCapture?: boolean; /** Browsers: show a small task bar so testers can mark each task done (default true). */ toolbar?: boolean; } export type TestEvent = | "start" | "page" | "screen" | "click" | "rage_click" | "dead_click" | "error" | "task_start" | "task_complete" | "task_fail" | "task_skip" | "note" | "heartbeat" | "end"; const TEST_CODE = /^wmt_[A-Za-z0-9]{8,40}$/; const TEST_STORE = "wm_test"; const RAGE_CLICKS = 3; // clicks on the same element… const RAGE_WINDOW = 1000; // …within this many ms const DEAD_AFTER = 3000; // no page, DOM or scroll change this long after a click on a control const HEARTBEAT = 15000; const IDLE_AFTER = 30000; const INTERACTIVE = "a,button,input,select,textarea,summary,label,[role=button],[role=link],[role=tab],[role=menuitem],[role=checkbox],[role=switch],[onclick]"; type Storage = { getItem(k: string): string | null; setItem(k: string, v: string): void; removeItem(k: string): void }; function store(): Storage | null { try { return typeof localStorage !== "undefined" ? localStorage : null; } catch { return null; } } /** Reads ?wm_test= (and removes it from the address bar) or the code saved on this device in the last 12 hours. */ function readTestCode(): string | null { if (typeof window === "undefined" || typeof location === "undefined") return null; try { const url = new URL(location.href); const fromUrl = url.searchParams.get("wm_test"); if (fromUrl && TEST_CODE.test(fromUrl)) { store()?.setItem(TEST_STORE, JSON.stringify({ code: fromUrl, at: Date.now() })); url.searchParams.delete("wm_test"); history.replaceState(history.state, "", url.pathname + url.search + url.hash); return fromUrl; } const saved = JSON.parse(store()?.getItem(TEST_STORE) ?? "null") as { code?: string; at?: number } | null; if (saved?.code && TEST_CODE.test(saved.code) && Date.now() - (saved.at ?? 0) < 12 * 3600_000) return saved.code; } catch { /* no storage or URL support */ } return null; } /** Keep arbitrary error text out of automatic telemetry. */ function safeErrorName(error: unknown): string { const name = error instanceof Error ? error.name : "Error"; return ["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "URIError", "EvalError", "AggregateError"].includes(name) ? name : "Error"; } const PRIVATE_TARGET = "[data-wm-mask],[data-private],input,textarea,select,[contenteditable]:not([contenteditable='false'])"; function describeTarget(el: Element): string { const tag = el.tagName.toLowerCase(); const masked = el.closest(PRIVATE_TARGET) || (el as HTMLElement).isContentEditable; if (masked) return `${tag} [masked]`; let out = tag; if (el.id) out += `#${el.id.slice(0, 40)}`; const testId = el.getAttribute("data-testid"); if (testId) out += `[data-testid=${testId.slice(0, 40)}]`; // Text content can contain user input echoed by a component. Only explicit telemetry labels are collected. const label = el.getAttribute("data-wm-label") || ""; const text = label.replace(/\s+/g, " ").trim().slice(0, 40); if (text) out += ` "${text}"`; return out.slice(0, 200); } function routePath(): string { // Path only: query strings often carry personal data. const hashRoute = location.hash.startsWith("#/") ? location.hash.split("?")[0] : ""; return (location.pathname + hashRoute).slice(0, 300); } function deviceSummary(): string | undefined { if (typeof navigator === "undefined") return undefined; const ua = navigator.userAgent ?? ""; const os = /iPhone|iPad/.test(ua) ? "iOS" : /Android/.test(ua) ? "Android" : /Mac OS X/.test(ua) ? "macOS" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "Other"; const browser = /Edg\//.test(ua) ? "Edge" : /Firefox\//.test(ua) ? "Firefox" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "Other"; const width = typeof window !== "undefined" ? ` · ${window.innerWidth}px` : ""; return `${browser} · ${os}${width}`; } const newId = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; /** * One tester's session. Behaviour goes to Wandamark tagged with the session code and is only visible to the * app's team for this test. Use the task methods if your app knows when a task succeeds (more precise than * the tester's own report). */ export class WandamarkTest { ended = false; private queue: Record[] = []; private timer: ReturnType | null = null; private beat: ReturnType | null = null; private cleanup: (() => void)[] = []; private lastActivity = Date.now(); private lastChange = 0; private clicks: { el: Element; at: number; n: number } | null = null; private lastDead: { el: Element; at: number } | null = null; private started = new Set(); private finished = new Set(); private bar: HTMLElement | null = null; private tasks: { id: string; title: string }[] = []; private outcomes = new Map(); /** Recent error messages, kept on this device only. A tester can attach them to a bug report they review first. */ private recentErrors: string[] = []; constructor( private readonly opts: { key: string; endpoint: string; requestTimeout: number; fetch?: typeof fetch; onError?: WandamarkOptions["onError"] }, readonly code: string, options: StartTestOptions, ) { const browser = typeof window !== "undefined" && typeof document !== "undefined"; this.emit("start", { target: deviceSummary() }); this.timer = setInterval(() => void this.flush(), 4000); (this.timer as { unref?: () => void }).unref?.(); if (browser && options.autoCapture !== false) this.capture(); if (browser && options.toolbar !== false) void this.showToolbar(); } /** Record a native screen (React Native and other non-browser apps). */ screen(name: string) { this.emit("screen", { name: String(name).slice(0, 300), path: String(name).slice(0, 300) }); } startTask(taskId: string) { if (this.started.has(taskId)) return; this.started.add(taskId); this.emit("task_start", { name: taskId }); } completeTask(taskId: string) { this.endTask(taskId, "task_complete"); } failTask(taskId: string) { this.endTask(taskId, "task_fail"); } skipTask(taskId: string) { this.endTask(taskId, "task_skip"); } /** A comment the tester leaves while using the app (up to 300 characters). */ note(text: string) { this.emit("note", { name: String(text).slice(0, 300), path: typeof location !== "undefined" ? routePath() : undefined }); } /** Stop recording (the tester finished). Feedback is given on Wandamark. */ async end() { if (this.ended) return; this.emit("end"); this.ended = true; this.stop(); store()?.removeItem(TEST_STORE); await this.flush(); } async flush(): Promise { while (this.queue.length) { const events = this.queue.splice(0, 200); await this.post(events); } } private endTask(taskId: string, type: "task_complete" | "task_fail" | "task_skip") { if (this.finished.has(taskId)) return; this.startTask(taskId); this.finished.add(taskId); this.emit(type, { name: taskId }); } private emit(type: TestEvent, fields: { name?: string; path?: string; target?: string; value?: number; x?: number; y?: number } = {}) { if (this.ended) return; this.queue.push({ type, timestamp: new Date().toISOString(), event_id: newId(), ...fields }); if (this.queue.length >= 100) void this.flush(); } private async post(events: Record[]) { const f = this.opts.fetch ?? globalThis.fetch; const payload = JSON.stringify({ code: this.code, events }); for (let attempt = 0; attempt < 3; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.opts.requestTimeout); try { const res = await f(`${this.opts.endpoint}/api/v1/tests/events`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.key}` }, body: payload, keepalive: payload.length < 60000, signal: controller.signal, }); if (res.status === 409 || res.status === 404) { // The session ended (submitted, expired) or the code isn't for this app: stop recording. this.ended = true; this.stop(); store()?.removeItem(TEST_STORE); return; } if (res.status === 429 || res.status >= 500) throw new Error(`Wandamark responded ${res.status}`); return; } catch (e) { if (attempt === 2) { try { this.opts.onError?.(e instanceof Error ? e : new Error(String(e))); } catch { /* consumer callback */ } return; } await sleep(500 * 2 ** attempt); } finally { clearTimeout(timer); } } } private stop() { if (this.timer) clearInterval(this.timer); if (this.beat) clearInterval(this.beat); this.timer = this.beat = null; for (const fn of this.cleanup.splice(0)) { try { fn(); } catch { /* already removed */ } } this.bar?.remove(); this.bar = null; } private on(target: EventTarget, type: K, fn: (e: Event) => void, opts: AddEventListenerOptions = { capture: true, passive: true }) { target.addEventListener(type, fn, opts); this.cleanup.push(() => target.removeEventListener(type, fn, opts)); } private capture() { let path = routePath(); this.emit("page", { path }); const onRoute = () => { const next = routePath(); if (next === path) return; path = next; this.lastChange = Date.now(); this.emit("page", { path }); }; // Single-page apps change routes through the History API. const push = history.pushState; const replace = history.replaceState; history.pushState = function (this: History, ...args: Parameters) { const r = push.apply(this, args); onRoute(); return r; }; history.replaceState = function (this: History, ...args: Parameters) { const r = replace.apply(this, args); onRoute(); return r; }; this.cleanup.push(() => { history.pushState = push; history.replaceState = replace; }); this.on(window, "popstate", onRoute); this.on(window, "hashchange", onRoute); const active = () => { this.lastActivity = Date.now(); }; for (const t of ["pointerdown", "keydown", "wheel", "touchstart"]) this.on(document, t, active); this.on(window, "scroll", () => { active(); this.lastChange = Date.now(); }); if (typeof MutationObserver !== "undefined") { const mo = new MutationObserver(() => { this.lastChange = Date.now(); }); mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }); this.cleanup.push(() => mo.disconnect()); } this.on(document, "click", (e) => { const raw = e.target as Element | null; if (!raw || !(raw instanceof Element) || (this.bar && e.composedPath().includes(this.bar))) return; const el = raw.closest(PRIVATE_TARGET) ? raw : raw.closest(INTERACTIVE) ?? raw; const target = describeTarget(el); const at = Date.now(); const pos = clickPosition(e as MouseEvent); this.emit("click", { target, path: routePath(), ...pos }); // Rage click: the same element clicked RAGE_CLICKS times within RAGE_WINDOW ms (reported once per burst). if (this.clicks && this.clicks.el === el && at - this.clicks.at <= RAGE_WINDOW) { this.clicks.n += 1; this.clicks.at = at; if (this.clicks.n === RAGE_CLICKS) this.emit("rage_click", { target, path: routePath(), ...pos }); } else { this.clicks = { el, at, n: 1 }; } // Dead click: a control that changed nothing (no route, DOM or scroll change) within DEAD_AFTER ms. if (el.matches(INTERACTIVE) && !/^(input|select|textarea|label)$/i.test(el.tagName)) { const href = location.href; const t = setTimeout(() => { if (this.ended || this.lastChange >= at || location.href !== href) return; // One dead click per burst: repeated clicks on the same unresponsive control count once. if (this.lastDead && this.lastDead.el === el && at - this.lastDead.at < DEAD_AFTER) return; this.lastDead = { el, at }; this.emit("dead_click", { target, path: routePath(), ...pos }); }, DEAD_AFTER); this.cleanup.push(() => clearTimeout(t)); } }); this.on(window, "error", (e) => { const ev = e as ErrorEvent; if (!ev.message) return; // resource load errors carry no message this.emit("error", { name: safeErrorName(ev.error), path: routePath() }); this.keepError(`${safeErrorName(ev.error)}: ${ev.message}`); }); this.on(window, "unhandledrejection", (e) => { const reason = (e as PromiseRejectionEvent).reason; this.emit("error", { name: `Unhandled rejection: ${safeErrorName(reason)}`, path: routePath() }); this.keepError(`Unhandled rejection: ${reason instanceof Error ? `${reason.name}: ${reason.message}` : safeErrorName(reason)}`); }); this.on(document, "visibilitychange", () => { if (document.visibilityState === "hidden") void this.flush(); }); // Active time: 15-second beats while the page is on screen and the tester interacted in the last 30 s. this.beat = setInterval(() => { const onScreen = document.visibilityState === "visible" || (typeof document.hasFocus === "function" && document.hasFocus()); if (onScreen && Date.now() - this.lastActivity < IDLE_AFTER) { this.emit("heartbeat", { value: HEARTBEAT / 1000 }); } }, HEARTBEAT); } private keepError(text: string) { this.recentErrors.push(text.replace(/\s+/g, " ").slice(0, 300)); if (this.recentErrors.length > 10) this.recentErrors.shift(); } private taskIndex(): number { return this.tasks.findIndex((t) => !this.finished.has(t.id)); } private saveProgress() { store()?.setItem(`${TEST_STORE}:${this.code}`, JSON.stringify([...this.outcomes])); } /** Sends a note or bug report (with an optional screenshot) from the task bar. */ private async sendNote(note: Record, screenshot: Blob | null): Promise { const f = this.opts.fetch ?? globalThis.fetch; const headers = { Authorization: `Bearer ${this.opts.key}` }; let media_id: string | undefined; if (screenshot) { const res = await f(`${this.opts.endpoint}/api/v1/tests/media?code=${encodeURIComponent(this.code)}`, { method: "POST", headers: { ...headers, "Content-Type": screenshot.type || "image/jpeg" }, body: screenshot, }); if (!res.ok) throw new Error("The screenshot couldn't be uploaded"); media_id = ((await res.json()) as { id: string }).id; } const res = await f(`${this.opts.endpoint}/api/v1/tests/notes`, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ code: this.code, note: { ...note, media_id, path: routePath(), device: deviceSummary(), viewport: `${window.innerWidth}×${window.innerHeight}` } }), }); if (!res.ok) throw new Error("Your note couldn't be sent"); } private async showToolbar() { try { const f = this.opts.fetch ?? globalThis.fetch; const res = await f(`${this.opts.endpoint}/api/v1/tests/session?code=${encodeURIComponent(this.code)}`, { headers: { Authorization: `Bearer ${this.opts.key}` }, }); if (!res.ok) return; const info = (await res.json()) as { title: string; status: string; tasks: { id: string; title: string }[] }; if (info.status !== "active" || this.ended) return; this.tasks = info.tasks ?? []; // Progress survives reloads on this device. Older SDKs saved a plain list of finished task ids. const saved = JSON.parse(store()?.getItem(`${TEST_STORE}:${this.code}`) ?? "[]") as (string | [string, "done" | "failed" | "skipped"])[]; for (const item of saved) { const [id, outcome] = Array.isArray(item) ? item : [item, "done" as const]; this.finished.add(id); this.outcomes.set(id, outcome); } const host = document.createElement("div"); host.setAttribute("data-wm-test-bar", ""); const root = host.attachShadow({ mode: "open" }); const sheet = typeof CSSStyleSheet !== "undefined" && "replaceSync" in CSSStyleSheet.prototype ? new CSSStyleSheet() : null; if (sheet && "adoptedStyleSheets" in root) { sheet.replaceSync(TOOLBAR_CSS); root.adoptedStyleSheets = [sheet]; } else { const style = document.createElement("style"); style.textContent = TOOLBAR_CSS; root.appendChild(style); } const bar = el("div", { className: "bar", role: "region", "aria-label": "Test checklist" }); root.appendChild(bar); let collapsed = false; let current: string | null = null; let view: "tasks" | "note" | "bug" = "tasks"; let flash = ""; const finish = (id: string, outcome: "done" | "failed" | "skipped") => { if (outcome === "done") this.completeTask(id); else if (outcome === "failed") this.failTask(id); else this.skipTask(id); this.outcomes.set(id, outcome); this.saveProgress(); current = null; render(); if (this.taskIndex() < 0) void this.flush(); }; const tasksView = () => { const doneCount = this.tasks.filter((t) => this.finished.has(t.id)).length; const active = current ?? this.tasks[this.taskIndex()]?.id ?? null; const list = el("ol", { className: "list" }); this.tasks.forEach((t, i) => { const outcome = this.outcomes.get(t.id); const isActive = t.id === active; const row = el("li", { className: `item${isActive ? " active" : ""}${outcome ? ` ${outcome}` : ""}` }); const pick = el("button", { className: "pick", "aria-current": isActive ? "step" : "false" }); pick.append(el("span", { className: "mark", "aria-hidden": "true", textContent: outcome === "done" ? "✓" : outcome === "failed" ? "✕" : outcome === "skipped" ? "–" : String(i + 1) }), el("span", { className: "label", textContent: t.title })); pick.onclick = () => { current = t.id; render(); }; row.append(pick); if (isActive && !outcome) { this.startTask(t.id); const actions = el("div", { className: "row" }); const done = el("button", { className: "go", textContent: "Done" }); done.onclick = () => finish(t.id, "done"); const fail = el("button", { textContent: "I couldn't do it" }); fail.onclick = () => finish(t.id, "failed"); const skip = el("button", { className: "link", textContent: "Skip" }); skip.onclick = () => finish(t.id, "skipped"); actions.append(done, fail, skip); row.append(actions); } list.append(row); }); const footer = el("div", { className: "row foot" }); const noteBtn = el("button", { textContent: "Leave a note" }); noteBtn.onclick = () => { view = "note"; render(); }; const bugBtn = el("button", { textContent: "Report a bug" }); bugBtn.onclick = () => { view = "bug"; render(); }; footer.append(noteBtn, bugBtn); const parts: Node[] = [el("div", { className: "sub", textContent: `${doneCount} of ${this.tasks.length} done` }), list]; if (this.taskIndex() < 0) parts.push(el("p", { className: "done", textContent: "All tasks done. Go back to Wandamark to answer a few questions and get paid." })); parts.push(footer); return parts; }; const field = (label: string, input: HTMLElement) => { const wrap = el("label", { className: "field" }); wrap.append(el("span", { textContent: label }), input); return wrap; }; const noteView = () => { const text = el("textarea", { rows: 4, maxLength: 2000, placeholder: "What did you notice? Be specific: what you tried and what happened." }) as HTMLTextAreaElement; const err = el("p", { className: "err", role: "alert" }); const send = el("button", { className: "go", textContent: "Send note" }) as HTMLButtonElement; const cancel = el("button", { textContent: "Cancel" }); cancel.onclick = () => { view = "tasks"; render(); }; send.onclick = async () => { if (!text.value.trim()) { err.textContent = "Write something first."; return; } send.disabled = true; try { await this.sendNote({ kind: "note", text: text.value, task_id: current ?? this.tasks[this.taskIndex()]?.id }, null); flash = "Note sent. Thanks!"; view = "tasks"; render(); } catch (e) { err.textContent = e instanceof Error ? e.message : "Couldn't send"; send.disabled = false; } }; const row = el("div", { className: "row" }); row.append(send, cancel); return [field("Note for the app team", text), err, row]; }; const bugView = () => { const title = el("input", { maxLength: 140, placeholder: "e.g. Save button does nothing" }) as HTMLInputElement; const severity = el("select") as HTMLSelectElement; for (const [v, l] of [["critical", "Critical: I can't continue"], ["high", "High: a main feature is broken"], ["medium", "Medium: annoying but I can work around it"], ["low", "Low: cosmetic"]]) { severity.append(el("option", { value: v, textContent: l, selected: v === "medium" })); } const steps = el("textarea", { rows: 3, maxLength: 2000, placeholder: "1. Open… 2. Tap… 3. …" }) as HTMLTextAreaElement; const expected = el("textarea", { rows: 2, maxLength: 1000 }) as HTMLTextAreaElement; const actual = el("textarea", { rows: 2, maxLength: 1000 }) as HTMLTextAreaElement; let shot: Blob | null = null; const shotBox = el("div", { className: "shot" }); const showShot = () => { shotBox.replaceChildren(); const row = el("div", { className: "row" }); if (shot) { const img = el("img", { alt: "Screenshot preview", src: URL.createObjectURL(shot) }); const remove = el("button", { className: "link", textContent: "Remove screenshot" }); remove.onclick = () => { shot = null; showShot(); }; const edit = el("button", { className: "link", textContent: "Hide private details" }); edit.onclick = async () => { if (shot) { shot = await redact(shot, root); showShot(); } }; shotBox.append(img); row.append(edit, remove); } else { const take = el("button", { textContent: "Capture screen" }); take.onclick = async () => { host.style.display = "none"; try { shot = await captureScreen(); } catch { /* cancelled or unsupported */ } finally { host.style.display = ""; } showShot(); }; const file = el("input", { type: "file", accept: "image/png,image/jpeg,image/webp", className: "file" }) as HTMLInputElement; file.onchange = () => { const picked = file.files?.[0]; if (picked && picked.size <= 5_000_000) { shot = picked; showShot(); } }; row.append(take, file); } shotBox.append(row); }; showShot(); const errorsBox = el("label", { className: "check" }); const includeErrors = el("input", { type: "checkbox", checked: this.recentErrors.length > 0 }) as HTMLInputElement; errorsBox.append(includeErrors, el("span", { textContent: this.recentErrors.length ? `Include ${this.recentErrors.length} recent error message${this.recentErrors.length === 1 ? "" : "s"}: ${this.recentErrors.slice(-2).join(" · ")}` : "No errors were recorded on this page" })); if (!this.recentErrors.length) includeErrors.disabled = true; const err = el("p", { className: "err", role: "alert" }); const send = el("button", { className: "go", textContent: "Send bug report" }) as HTMLButtonElement; const cancel = el("button", { textContent: "Cancel" }); cancel.onclick = () => { view = "tasks"; render(); }; send.onclick = async () => { if (!title.value.trim()) { err.textContent = "Give the bug a short title."; return; } send.disabled = true; try { await this.sendNote({ kind: "bug", title: title.value, severity: severity.value, steps: steps.value, expected: expected.value, actual: actual.value, errors: includeErrors.checked ? this.recentErrors : undefined, task_id: current ?? this.tasks[this.taskIndex()]?.id, }, shot); flash = "Bug report sent. Thanks!"; view = "tasks"; render(); } catch (e) { err.textContent = e instanceof Error ? e.message : "Couldn't send"; send.disabled = false; } }; const row = el("div", { className: "row" }); row.append(send, cancel); return [field("What went wrong?", title), field("How bad is it?", severity), field("Steps to reproduce", steps), field("What did you expect?", expected), field("What happened instead?", actual), shotBox, errorsBox, err, row]; }; const render = () => { bar.replaceChildren(); const top = el("div", { className: "top" }); const min = el("button", { className: "min", textContent: collapsed ? "Show" : "Hide", "aria-expanded": String(!collapsed) }); min.onclick = () => { collapsed = !collapsed; render(); }; top.append(el("span", { textContent: view === "note" ? "Leave a note" : view === "bug" ? "Report a bug" : info.title || "Test checklist" }), min); bar.append(top); if (collapsed) return; if (flash) { bar.append(el("p", { className: "ok", role: "status", textContent: flash })); flash = ""; } bar.append(...(view === "note" ? noteView() : view === "bug" ? bugView() : tasksView())); }; render(); document.body.appendChild(host); this.bar = host; } catch { /* the toolbar is optional */ } } } const TOOLBAR_CSS = `:host{all:initial}.bar{position:fixed;right:16px;bottom:16px;z-index:2147483647;width:min(340px,calc(100vw - 32px));max-height:min(78vh,640px);overflow:auto;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#0b0d12;background:#fff;border:1px solid #e4e7ec;border-radius:14px;box-shadow:0 10px 30px rgba(10,12,16,.18);padding:12px 14px;display:grid;gap:8px}.top{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;font-weight:700;color:#555c6a;text-transform:uppercase;letter-spacing:.04em}.sub{font-size:13px;color:#555c6a}.list{list-style:none;margin:0;padding:0;display:grid;gap:4px}.item{border:1px solid transparent;border-radius:10px;padding:4px}.item.active{border-color:#c5d0fa;background:#f4f6ff}.pick{display:flex;gap:8px;align-items:flex-start;width:100%;text-align:left;border:0;background:none;padding:4px;font-weight:600}.mark{flex:none;width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:11px;background:#f1f3f6;color:#555c6a}.done .mark{background:#2f9e44;color:#fff}.failed .mark{background:#e03131;color:#fff}.skipped .mark{background:#868e96;color:#fff}.done .label,.skipped .label{color:#555c6a}.row{display:flex;gap:6px;flex-wrap:wrap;align-items:center;padding:4px}.foot{border-top:1px solid #e4e7ec;padding-top:10px}button{font:inherit;font-weight:700;border-radius:10px;padding:7px 11px;cursor:pointer;border:1px solid #e4e7ec;background:#f1f3f6;color:#0b0d12}button:disabled{opacity:.6;cursor:default}.go{background:#3b5bdb;border-color:#3b5bdb;color:#fff}.link{border:0;background:none;padding:4px 6px;color:#3b5bdb;font-weight:600}.min{border:0;background:none;padding:2px 6px;color:#555c6a}.done{margin:0}.field{display:grid;gap:4px;font-size:13px;font-weight:600}input,select,textarea{font:inherit;font-weight:400;border:1px solid #d0d5dd;border-radius:8px;padding:7px 9px;background:inherit;color:inherit;width:100%;box-sizing:border-box}.file{font-size:12px;border:0;padding:0}.check{display:flex;gap:8px;font-size:12px;color:#555c6a;align-items:flex-start}.check input{width:auto;margin-top:2px}.shot img{max-width:100%;border:1px solid #e4e7ec;border-radius:8px}.err{color:#c92a2a;margin:0;font-size:13px}.err:empty{display:none}.ok{color:#2b8a3e;margin:0;font-weight:600}@media (prefers-color-scheme:dark){.bar{background:#121419;color:#f3f4f6;border-color:#252931}button{background:#1a1d23;color:#f3f4f6;border-color:#252931}.go{background:#4c6ef5;border-color:#4c6ef5}.item.active{background:#1a2040;border-color:#364fc7}.top,.min,.sub,.check{color:#a3a9b5}.mark{background:#252931;color:#a3a9b5}input,select,textarea{border-color:#353a45}}`; type Attrs = Record; function el(tag: string, attrs: Attrs = {}): HTMLElement { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v === undefined) continue; if (k in node && !k.includes("-")) (node as unknown as Record)[k] = v; else node.setAttribute(k, String(v)); } return node; } /** Page position as fractions of the whole document, so clicks on long pages land in the right place on a heatmap. */ function clickPosition(e: MouseEvent): { x?: number; y?: number } { if (typeof e.clientX !== "number" || typeof document === "undefined") return {}; const doc = document.documentElement; const width = Math.max(doc.scrollWidth, window.innerWidth, 1); const height = Math.max(doc.scrollHeight, window.innerHeight, 1); const round = (v: number) => Math.min(1, Math.max(0, Math.round(v * 1000) / 1000)); return { x: round((e.clientX + window.scrollX) / width), y: round((e.clientY + window.scrollY) / height) }; } /** One frame of the current tab, after the browser's own share prompt. Nothing is captured without the tester's choice. */ async function captureScreen(): Promise { const media = navigator.mediaDevices as MediaDevices & { getDisplayMedia?: (o: unknown) => Promise }; if (!media?.getDisplayMedia) return null; const stream = await media.getDisplayMedia({ video: { displaySurface: "browser" }, audio: false, preferCurrentTab: true, selfBrowserSurface: "include" }); try { const video = document.createElement("video"); video.muted = true; video.srcObject = stream; await video.play(); await new Promise((r) => setTimeout(r, 250)); const scale = Math.min(1, 1600 / Math.max(1, video.videoWidth)); const canvas = document.createElement("canvas"); canvas.width = Math.round(video.videoWidth * scale); canvas.height = Math.round(video.videoHeight * scale); canvas.getContext("2d")?.drawImage(video, 0, 0, canvas.width, canvas.height); return await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.82)); } finally { stream.getTracks().forEach((t) => t.stop()); } } /** Lets the tester drag boxes over anything private before a screenshot is sent. */ function redact(image: Blob, root: ShadowRoot): Promise { return new Promise((resolve) => { const img = new Image(); img.onload = () => { const overlay = el("div", { style: "position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.6);display:grid;place-items:center;padding:16px" }); const panel = el("div", { className: "bar", style: "position:static;width:min(900px,100%);max-height:none" }); const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.style.cssText = "width:100%;height:auto;cursor:crosshair;border-radius:8px"; const ctx = canvas.getContext("2d"); ctx?.drawImage(img, 0, 0); let start: [number, number] | null = null; const point = (e: PointerEvent): [number, number] => { const r = canvas.getBoundingClientRect(); return [((e.clientX - r.left) / r.width) * canvas.width, ((e.clientY - r.top) / r.height) * canvas.height]; }; canvas.onpointerdown = (e) => { start = point(e); }; canvas.onpointerup = (e) => { if (!start || !ctx) return; const [x, y] = point(e); ctx.fillStyle = "#000"; ctx.fillRect(Math.min(start[0], x), Math.min(start[1], y), Math.abs(x - start[0]), Math.abs(y - start[1])); start = null; }; const done = el("button", { className: "go", textContent: "Done" }); done.onclick = () => canvas.toBlob((b) => { overlay.remove(); resolve(b ?? image); }, "image/jpeg", 0.82); const row = el("div", { className: "row" }); row.append(done); panel.append(el("div", { className: "top", textContent: "Drag over anything private to black it out" }), canvas, row); overlay.append(panel); root.append(overlay); }; img.src = URL.createObjectURL(image); }); } function defaultDevice(): string | undefined { if (typeof navigator === "undefined") return undefined; return navigator.userAgent?.slice(0, 120); } export default Wandamark; export type ShoploopInvite = { connected: boolean; available: boolean; code: string | null; reward_cents: 100; holdback_days: 7; source_kinds: ("test" | "referral")[]; invited: number; earned_cents: number; pending_cents: number; cap: number | null; };