/** * Day 4 — local, device-only beta feedback. * * Lets a beta student tell the builder what confused them or what broke, stored * only in localStorage. No backend, no external service. Deliberately collects * NO contact/personal data: no name, email, phone, school, or location fields. * The message is capped and trimmed; empty messages are rejected. */ import { getBetaSessionId, trackBetaEvent } from "./betaAnalytics"; export type BetaFeedbackKind = | "helped_understand" | "still_confusing" | "wrong_or_doubtful" | "too_long" | "need_more_examples" | "class_confusing" | "bug" | "content_issue" | "ui_issue" | "too_hard" | "too_easy" | "liked" | "other"; export type BetaFeedbackRating = 1 | 2 | 3 | 4 | 5; export type BetaFeedbackDevice = { viewportWidth?: number; viewportHeight?: number; userAgent?: string; }; export type BetaFeedbackItem = { id: string; createdAt: string; sessionId: string; kind: BetaFeedbackKind; rating?: BetaFeedbackRating; message: string; route: string; subject?: string; chapterTitle?: string; missionTitle?: string; stepLabel?: string; device?: BetaFeedbackDevice; status: "new" | "reviewed"; }; export type BetaFeedbackContext = { kind: BetaFeedbackKind; rating?: BetaFeedbackRating; message: string; route?: string; subject?: string; chapterTitle?: string; missionTitle?: string; stepLabel?: string; includeDevice?: boolean; }; export type BetaFeedbackSubmitResult = | { ok: true; item: BetaFeedbackItem } | { ok: false; error: "empty_message" | "storage_unavailable" }; export type BetaFeedbackSummary = { total: number; newCount: number; reviewedCount: number; byKind: Array<{ kind: BetaFeedbackKind; count: number }>; bugCount: number; lastAt: string | null; }; export const BETA_FEEDBACK_STORAGE_KEY = "docdoe:beta-feedback"; export const MAX_FEEDBACK_MESSAGE_LENGTH = 500; const MAX_STORED_FEEDBACK = 200; export const BETA_FEEDBACK_KIND_LABELS: Record = { helped_understand: "This helped me understand", still_confusing: "Still confusing", wrong_or_doubtful: "Wrong or doubtful answer", too_long: "Too long", need_more_examples: "Need more examples", class_confusing: "Class was confusing", bug: "Something broke (bug)", content_issue: "Content looks wrong", ui_issue: "Layout / UI problem", too_hard: "Too hard", too_easy: "Too easy", liked: "I liked this", other: "Other", }; function hasStorage(): boolean { return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; } function randomId(): string { try { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID(); } catch { // fall through } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function currentRoute(): string { if (typeof window === "undefined") return ""; try { return window.location?.pathname ?? ""; } catch { return ""; } } function isFeedbackLike(value: unknown): value is BetaFeedbackItem { if (!value || typeof value !== "object") return false; const item = value as Record; return typeof item.id === "string" && typeof item.kind === "string" && typeof item.message === "string"; } export function loadBetaFeedback(): BetaFeedbackItem[] { if (!hasStorage()) return []; try { const raw = window.localStorage.getItem(BETA_FEEDBACK_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter(isFeedbackLike); } catch { return []; } } export function saveBetaFeedback(items: BetaFeedbackItem[]): void { if (!hasStorage()) return; try { window.localStorage.setItem(BETA_FEEDBACK_STORAGE_KEY, JSON.stringify(items.slice(-MAX_STORED_FEEDBACK))); } catch { // best-effort } } function minimalDevice(): BetaFeedbackDevice | undefined { if (typeof window === "undefined") return undefined; try { return { viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, // Coarse UA only; no fingerprinting, no PII. userAgent: (navigator?.userAgent ?? "").slice(0, 180), }; } catch { return undefined; } } /** * Validate + store one feedback item, and record analytics events * (feedback_submitted, plus bug_report_submitted when kind === "bug"). */ export function submitBetaFeedback(context: BetaFeedbackContext): BetaFeedbackSubmitResult { const message = (context.message ?? "").trim().slice(0, MAX_FEEDBACK_MESSAGE_LENGTH); if (!message) return { ok: false, error: "empty_message" }; if (!hasStorage()) return { ok: false, error: "storage_unavailable" }; const item: BetaFeedbackItem = { id: randomId(), createdAt: new Date().toISOString(), sessionId: getBetaSessionId(), kind: context.kind, rating: context.rating, message, route: context.route ?? currentRoute(), subject: context.subject, chapterTitle: context.chapterTitle, missionTitle: context.missionTitle, stepLabel: context.stepLabel, device: context.includeDevice === false ? undefined : minimalDevice(), status: "new", }; const items = [...loadBetaFeedback(), item]; saveBetaFeedback(items); const analyticsContext = { subject: context.subject, chapterTitle: context.chapterTitle, missionTitle: context.missionTitle, stepLabel: context.stepLabel, route: context.route, }; trackBetaEvent({ name: "feedback_submitted", metadata: { kind: item.kind, rating: item.rating ?? null }, ...analyticsContext }); if (item.kind === "bug") { trackBetaEvent({ name: "bug_report_submitted", metadata: { rating: item.rating ?? null }, ...analyticsContext }); } if (item.kind === "still_confusing") { trackBetaEvent({ name: "confusing_answer_reported", metadata: { kind: item.kind }, ...analyticsContext }); } if (item.kind === "wrong_or_doubtful") { trackBetaEvent({ name: "wrong_answer_reported", metadata: { kind: item.kind }, ...analyticsContext }); } return { ok: true, item }; } export function markBetaFeedbackReviewed(id: string): BetaFeedbackItem[] { const items = loadBetaFeedback().map((item) => (item.id === id ? { ...item, status: "reviewed" as const } : item)); saveBetaFeedback(items); return items; } export function clearBetaFeedback(): void { if (!hasStorage()) return; try { window.localStorage.removeItem(BETA_FEEDBACK_STORAGE_KEY); } catch { // ignore } } export function getBetaFeedbackSummary(items: BetaFeedbackItem[] = loadBetaFeedback()): BetaFeedbackSummary { const byKind = new Map(); for (const item of items) byKind.set(item.kind, (byKind.get(item.kind) ?? 0) + 1); return { total: items.length, newCount: items.filter((item) => item.status === "new").length, reviewedCount: items.filter((item) => item.status === "reviewed").length, byKind: Array.from(byKind.entries()).map(([kind, count]) => ({ kind, count })), bugCount: items.filter((item) => item.kind === "bug").length, lastAt: items.length > 0 ? items[items.length - 1].createdAt : null, }; }