diff --git a/apps/web/drizzle/0004_chubby_toxin.sql b/apps/web/drizzle/0004_chubby_toxin.sql new file mode 100644 index 0000000..5d28185 --- /dev/null +++ b/apps/web/drizzle/0004_chubby_toxin.sql @@ -0,0 +1,2 @@ +DROP TABLE "beta_feedback" CASCADE;--> statement-breakpoint +DROP TABLE "beta_tester" CASCADE; \ No newline at end of file diff --git a/apps/web/drizzle/meta/0004_snapshot.json b/apps/web/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..6e0affd --- /dev/null +++ b/apps/web/drizzle/meta/0004_snapshot.json @@ -0,0 +1,737 @@ +{ + "id": "547570c7-dbf8-449d-9697-ce2e24e31406", + "prevId": "e721ae78-37fd-4b1a-b8ca-c02d4074f540", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_notification_settings": { + "name": "account_notification_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_response_completed": { + "name": "agent_response_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agent_question_interruption": { + "name": "agent_question_interruption", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agent_permission_interruption": { + "name": "agent_permission_interruption", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agent_error_interruption": { + "name": "agent_error_interruption", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_notification_settings_user_id_user_id_fk": { + "name": "account_notification_settings_user_id_user_id_fk", + "tableFrom": "account_notification_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_session": { + "name": "device_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_session_user_id_user_id_fk": { + "name": "device_session_user_id_user_id_fk", + "tableFrom": "device_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_session_token_unique": { + "name": "device_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dodo_webhook_event": { + "name": "dodo_webhook_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_device": { + "name": "push_device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_session_id": { + "name": "device_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_device_user_id_user_id_fk": { + "name": "push_device_user_id_user_id_fk", + "tableFrom": "push_device", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "push_device_device_session_id_device_session_id_fk": { + "name": "push_device_device_session_id_device_session_id_fk", + "tableFrom": "push_device", + "tableTo": "device_session", + "columnsFrom": [ + "device_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_device_expo_push_token_unique": { + "name": "push_device_expo_push_token_unique", + "nullsNotDistinct": false, + "columns": [ + "expo_push_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dodo_customer_id": { + "name": "dodo_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dodo_subscription_id": { + "name": "dodo_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_product_id": { + "name": "subscription_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_renews_at": { + "name": "subscription_renews_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "subscription_cancel_at_period_end": { + "name": "subscription_cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_api_key_unique": { + "name": "user_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + }, + "user_dodo_customer_id_unique": { + "name": "user_dodo_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "dodo_customer_id" + ] + }, + "user_dodo_subscription_id_unique": { + "name": "user_dodo_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "dodo_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 4644a57..a91c778 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1788543710879, "tag": "0003_dusty_betty_ross", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1790789424625, + "tag": "0004_chubby_toxin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/web/src/app/api/beta/feedback/route.ts b/apps/web/src/app/api/beta/feedback/route.ts deleted file mode 100644 index cb74d65..0000000 --- a/apps/web/src/app/api/beta/feedback/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { db } from "@/lib/db" -import { betaFeedback, betaTester } from "@/lib/db/schema" -import { eq } from "drizzle-orm" -import { NextResponse } from "next/server" -import crypto from "crypto" -import { logger } from "@/lib/logger" -import { checkRateLimit, getClientIp, rateLimitedResponse } from "@/lib/rate-limit" -import { isTurnstileEnforced, turnstileTokenFromBody, verifyTurnstileToken } from "@/lib/turnstile" - -const FLOWS = [ - "onboarding", - "connect-cli", - "chat-session", - "diff-view", - "background-reconnect", - "billing-view", - "offline-network", -] - -function clampRating(v: unknown) { - const n = Number(v) - if (!Number.isFinite(n)) return null - return Math.min(5, Math.max(1, Math.round(n))) -} - -function normalizeBugs(v: unknown) { - if (Array.isArray(v)) { - const text = v - .map((b) => { - if (typeof b === "string") return b.trim() - if (typeof b === "object" && b !== null) { - const area = String((b as Record).area ?? "").slice(0, 50) - const desc = String((b as Record).desc ?? (b as Record).description ?? "").trim() - if (!area && !desc) return "" - return `[${area || "Other"}] ${desc}`.trim() - } - return "" - }) - .filter(Boolean) - .join("\n\n") - return text.slice(0, 5000) || null - } - return String(v ?? "").slice(0, 5000) || null -} - -export async function POST(request: Request) { - try { - const rl = await checkRateLimit(request, "sensitiveWrite") - if (!rl.success) return rateLimitedResponse(rl) - - const body = await request.json() - if (isTurnstileEnforced() && !(await verifyTurnstileToken(turnstileTokenFromBody(body), getClientIp(request)))) { - return NextResponse.json({ error: "Bot verification failed. Complete the captcha or disable your ad-blocker and retry." }, { status: 403 }) - } - const email = body?.email?.trim().toLowerCase() - const appVersion = body?.appVersion?.trim().slice(0, 50) - const deviceModel = body?.deviceModel?.trim().slice(0, 100) - const androidVersion = body?.androidVersion?.trim().slice(0, 50) - const pcOs = body?.pcOs?.trim().slice(0, 100) - const flowsTested: string[] = Array.isArray(body?.flowsTested) - ? body.flowsTested.filter((f: unknown) => FLOWS.includes(String(f))) - : [] - const ratingOverall = clampRating(body?.ratingOverall) - const ratingUx = clampRating(body?.ratingUx) - const ratingPerf = clampRating(body?.ratingPerf) - - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return NextResponse.json({ error: "Invalid email" }, { status: 400 }) - } - if (!appVersion || !deviceModel || !androidVersion) { - return NextResponse.json({ error: "Device and app version are required" }, { status: 400 }) - } - if (!pcOs) { - return NextResponse.json({ error: "PC operating system is required" }, { status: 400 }) - } - if (flowsTested.length === 0) { - return NextResponse.json({ error: "Select at least one flow you tested" }, { status: 400 }) - } - if (!ratingOverall || !ratingUx || !ratingPerf) { - return NextResponse.json({ error: "Ratings 1-5 are required" }, { status: 400 }) - } - - const testers = await db - .select({ id: betaTester.id }) - .from(betaTester) - .where(eq(betaTester.email, email)) - .limit(1) - - let testerId: string | null = testers[0]?.id ?? null - if (!testerId) { - testerId = crypto.randomUUID() - await db.insert(betaTester).values({ id: testerId, email, deviceModel, androidVersion, status: "submitted" }) - } else { - await db.update(betaTester).set({ status: "submitted", deviceModel, androidVersion }).where(eq(betaTester.id, testerId)) - } - - await db.insert(betaFeedback).values({ - id: crypto.randomUUID(), - testerId, - email, - appVersion, - deviceModel, - androidVersion, - pcOs, - flowsTested: JSON.stringify(flowsTested), - ratingOverall, - ratingUx, - ratingPerf, - bugs: normalizeBugs(body?.bugs), - fav: String(body?.fav ?? "").slice(0, 2000) || null, - missing: String(body?.missing ?? "").slice(0, 2000) || null, - keepUsing: ["yes", "maybe", "no"].includes(body?.keepUsing) ? body.keepUsing : null, - projectName: String(body?.projectName ?? "").slice(0, 100) || null, - projectDesc: String(body?.projectDesc ?? "").slice(0, 2000) || null, - projectLink: String(body?.projectLink ?? "").slice(0, 500) || null, - testimonial: String(body?.testimonial ?? "").slice(0, 2000) || null, - testimonialOptIn: Boolean(body?.testimonialOptIn), - }) - - logger.info("API", `POST /api/beta/feedback - ${email}`) - return NextResponse.json({ success: true }) - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - logger.error("API", `POST /api/beta/feedback - Error: ${msg}`) - return NextResponse.json({ error: "Something went wrong" }, { status: 500 }) - } -} diff --git a/apps/web/src/app/api/beta/stats/route.ts b/apps/web/src/app/api/beta/stats/route.ts deleted file mode 100644 index 9b65920..0000000 --- a/apps/web/src/app/api/beta/stats/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { db } from "@/lib/db" -import { betaFeedback } from "@/lib/db/schema" -import { NextResponse } from "next/server" -import { logger } from "@/lib/logger" -import { checkRateLimit, rateLimitedResponse } from "@/lib/rate-limit" - -export const revalidate = 60 - -function avg(values: number[]) { - if (values.length === 0) return null - return Number((values.reduce((s, v) => s + v, 0) / values.length).toFixed(1)) -} - -export async function GET(request: Request) { - try { - const rl = await checkRateLimit(request, "publicRead") - if (!rl.success) return rateLimitedResponse(rl) - const feedbacks = await db - .select({ - ratingOverall: betaFeedback.ratingOverall, - ratingUx: betaFeedback.ratingUx, - ratingPerf: betaFeedback.ratingPerf, - bugs: betaFeedback.bugs, - deviceModel: betaFeedback.deviceModel, - testimonial: betaFeedback.testimonial, - testimonialOptIn: betaFeedback.testimonialOptIn, - }) - .from(betaFeedback) - .limit(500) - - const submissions = feedbacks.length - const bugs = feedbacks.filter((f) => f.bugs && f.bugs.trim() !== "").length - const devices = new Set(feedbacks.map((f) => f.deviceModel)).size - const testimonials = feedbacks - .filter((f) => f.testimonialOptIn && f.testimonial && f.testimonial.trim() !== "") - .slice(0, 6) - .map((t) => ({ quote: t.testimonial, device: t.deviceModel })) - - return NextResponse.json({ - submissions, - avgOverall: avg(feedbacks.map((f) => f.ratingOverall)), - avgUx: avg(feedbacks.map((f) => f.ratingUx)), - avgPerf: avg(feedbacks.map((f) => f.ratingPerf)), - bugs, - devices, - testimonials, - }) - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - logger.error("API", `GET /api/beta/stats - Error: ${msg}`) - return NextResponse.json({ error: "Something went wrong" }, { status: 500 }) - } -} diff --git a/apps/web/src/app/stats/page.tsx b/apps/web/src/app/stats/page.tsx deleted file mode 100644 index 6b75dd5..0000000 --- a/apps/web/src/app/stats/page.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Navbar } from "@/components/landing/navbar" -import { Footer } from "@/components/landing/footer" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { db } from "@/lib/db" -import { betaFeedback } from "@/lib/db/schema" - -export const revalidate = 60 - -function avg(values: number[]) { - if (values.length === 0) return null - return Number((values.reduce((s, v) => s + v, 0) / values.length).toFixed(1)) -} - -async function getStats() { - try { - const feedbacks = await db - .select({ - ratingOverall: betaFeedback.ratingOverall, - ratingUx: betaFeedback.ratingUx, - ratingPerf: betaFeedback.ratingPerf, - bugs: betaFeedback.bugs, - deviceModel: betaFeedback.deviceModel, - testimonial: betaFeedback.testimonial, - testimonialOptIn: betaFeedback.testimonialOptIn, - }) - .from(betaFeedback) - .limit(500) - return { - submissions: feedbacks.length, - avgOverall: avg(feedbacks.map((f) => f.ratingOverall)), - avgUx: avg(feedbacks.map((f) => f.ratingUx)), - avgPerf: avg(feedbacks.map((f) => f.ratingPerf)), - bugs: feedbacks.filter((f) => f.bugs && f.bugs.trim() !== "").length, - devices: new Set(feedbacks.map((f) => f.deviceModel)).size, - testimonials: feedbacks - .filter((f) => f.testimonialOptIn && f.testimonial && f.testimonial.trim() !== "") - .slice(0, 6) - .map((f) => ({ quote: f.testimonial as string, device: f.deviceModel })), - } - } catch { - return { - submissions: 0, - avgOverall: null as number | null, - avgUx: null as number | null, - avgPerf: null as number | null, - bugs: 0, - devices: 0, - testimonials: [] as { quote: string; device: string }[], - } - } -} - -export default async function StatsPage() { - const stats = await getStats() - const cards = [ - { v: String(stats.submissions), l: "Responses" }, - { v: stats.avgOverall ? `${stats.avgOverall}★` : "-", l: "Avg overall" }, - { v: stats.avgUx ? `${stats.avgUx}★` : "-", l: "Avg ease of use" }, - { v: stats.avgPerf ? `${stats.avgPerf}★` : "-", l: "Avg performance" }, - { v: String(stats.bugs), l: "Bugs reported" }, - { v: String(stats.devices), l: "Devices covered" }, - ] - - return ( -
- -
-
-
-

- Feedback Stats -

-

- Live results from submitted feedback forms. -

-
- -
- {cards.map((s) => ( - - -

{s.v}

-

{s.l}

-
-
- ))} -
- - {stats.testimonials.length > 0 && ( -
- - - What testers say - - - {stats.testimonials.map((t, i) => ( -
- “{t.quote}” -
{t.device}
-
- ))} -
-
-
- )} -
-
-
-
- ) -} diff --git a/apps/web/src/app/tester/feedback/page.tsx b/apps/web/src/app/tester/feedback/page.tsx deleted file mode 100644 index 4001e76..0000000 --- a/apps/web/src/app/tester/feedback/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Navbar } from "@/components/landing/navbar" -import { Footer } from "@/components/landing/footer" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { FeedbackForm } from "@/components/beta/feedback-form" -import { Suspense } from "react" -import Link from "next/link" - -export default function TesterFeedbackPage() { - return ( -
- -
-
-
-

- - Back to the Tester Guide - -

- - - Beta Review Feedback - - Share what you tested, what worked, and what broke. Fill every section that applies to your run. - - - - - - - - -
-
-
-
-
- ) -} diff --git a/apps/web/src/app/tester/page.tsx b/apps/web/src/app/tester/page.tsx deleted file mode 100644 index 5244810..0000000 --- a/apps/web/src/app/tester/page.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { Navbar } from "@/components/landing/navbar" -import { Footer } from "@/components/landing/footer" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { BETA_APP_VERSION } from "@/lib/beta" -import { Check, X } from "lucide-react" -import Link from "next/link" -import type { ReactNode } from "react" - -const SETUP = [ - "Real Android device (not an emulator)", - `Beta build ${BETA_APP_VERSION} installed from the Expo link we sent you`, - "PC with the CLI running (npx crosscode)", - "Internet on both phone and PC (any network works)", -] as const - -const STEPS: { title: string; body: ReactNode }[] = [ - { - title: "Read the GitHub README", - body: ( - <> - Read the README on GitHub end to end and set up from it alone. If anything feels like rocket science, note it in your review. - - ), - }, - { - title: "Install the beta build", - body: "Install from the Expo link we sent you. Open Settings or About in the app and confirm the version matches the build above.", - }, - { - title: "Pair with your PC", - body: "Start the CLI on your PC, open the app, and scan the QR code. Confirm the connection succeeds and the session list loads.", - }, - { - title: "Run a chat session", - body: "Start a session and send several prompts. Check replies stream in, history persists when you leave and return, and long outputs render fully.", - }, - { - title: "Review code and diffs", - body: "Ask for a code change and open the diff view. Check it is readable, scrolls well, and matches the chat content.", - }, - { - title: "Background, kill, reconnect", - body: "Background the app mid session, then force kill and reopen it. Confirm the session resumes or reconnects without data loss.", - }, - { - title: "Test the paywall", - body: "Open the paywall and apply the 100% coupon code from the DM to complete the flow end to end. If you cannot (no card or other issue), just confirm plans and prices render correctly and move on.", - }, - { - title: "Go offline and slow", - body: "Turn on airplane mode, then try a weak connection. Confirm clear error states appear and the app recovers when the network returns.", - }, -] as const - -const SKIP = [ - "Do not spend real money. Only use the coupon code from the DM.", - "Do not test iOS builds. This round is Android only.", - "Do not rely on an emulator. Use a real device as your main run.", - "Do not test old versions. Only the build listed above counts.", -] as const - -export default function TesterPage() { - return ( -
- -
-
-
-

- Tester Guide -

-

- Everything to test before launch, step by step. -

-
- -
- - - Before you start - You need all four ready. - - -
    - {SETUP.map((s) => ( -
  • - - - - {s} -
  • - ))} -
-
-
- - - - Test steps - Go in order. Note anything odd as you go. - - - {STEPS.map((step, i) => ( -
- - {i + 1} - -
-

{step.title}

-

{step.body}

-
-
- ))} -
-
- - - - Do not test - Out of scope for this round. - - -
    - {SKIP.map((s) => ( -
  • - - - - {s} -
  • - ))} -
-
-
- - - - Final step - Done with every step above? Send your review. - - - - - -
-
-
-
-
- ) -} diff --git a/apps/web/src/components/beta/feedback-form.tsx b/apps/web/src/components/beta/feedback-form.tsx deleted file mode 100644 index 8471337..0000000 --- a/apps/web/src/components/beta/feedback-form.tsx +++ /dev/null @@ -1,251 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useSearchParams } from "next/navigation" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { BETA_APP_VERSION, BETA_FLOWS, BUG_AREAS } from "@/lib/beta" -import { TurnstileWidget } from "@/components/turnstile-widget" -import { useFormDraft } from "./use-draft" - -const BUGS_DRAFT_KEY = "beta-feedback-bugs-draft" -const MAX_BUGS = 10 - -type BugEntry = { area: string; desc: string } - -function Rating({ name, label }: { name: string; label: string }) { - return ( -
- {label} * -
- {[1, 2, 3, 4, 5].map((n) => ( - - ))} -
-
- ) -} - -export function FeedbackForm() { - const params = useSearchParams() - const [loading, setLoading] = useState(false) - const [done, setDone] = useState(false) - const [error, setError] = useState(null) - const { ref, save, clear, hasDraft } = useFormDraft("beta-feedback-draft") - const [bugs, setBugs] = useState([]) - const [bugsLoaded, setBugsLoaded] = useState(false) - const [turnstileToken, setTurnstileToken] = useState("") - - useEffect(() => { - try { - const raw = localStorage.getItem(BUGS_DRAFT_KEY) - if (raw) { - const parsed = JSON.parse(raw) as unknown - if (Array.isArray(parsed)) { - const clean = parsed - .filter((b): b is BugEntry => typeof b === "object" && b !== null) - .map((b) => ({ area: String(b.area ?? "").slice(0, 50), desc: String(b.desc ?? "").slice(0, 1000) })) - .slice(0, MAX_BUGS) - // eslint-disable-next-line react-hooks/set-state-in-effect -- restoring persisted bug drafts after mount needs setState - if (clean.length > 0) setBugs(clean) - } - } - } catch {} - setBugsLoaded(true) - }, []) - - useEffect(() => { - if (!bugsLoaded) return - try { - localStorage.setItem(BUGS_DRAFT_KEY, JSON.stringify(bugs)) - } catch {} - }, [bugs, bugsLoaded]) - - useEffect(() => { - const email = params.get("email") - const form = ref.current - if (email && form) { - const input = form.querySelector('input[name="email"]') - if (input && !input.value) { - input.value = email - save() - } - } - }, [params, ref, save]) - - async function onSubmit(e: React.FormEvent) { - e.preventDefault() - setLoading(true) - setError(null) - const data = new FormData(e.currentTarget) - const bugsText = bugs - .map((b) => b.area || b.desc ? `[${b.area || "Other"}] ${b.desc}`.trim() : "") - .filter(Boolean) - .join("\n\n") - const res = await fetch("/api/beta/feedback", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: data.get("email"), - appVersion: BETA_APP_VERSION, - deviceModel: data.get("deviceModel"), - androidVersion: data.get("androidVersion"), - pcOs: data.get("pcOs"), - flowsTested: data.getAll("flows"), - ratingOverall: Number(data.get("ratingOverall")), - ratingUx: Number(data.get("ratingUx")), - ratingPerf: Number(data.get("ratingPerf")), - bugs: bugsText, - fav: data.get("fav"), - missing: data.get("missing"), - keepUsing: data.get("keepUsing"), - projectName: data.get("projectName"), - projectDesc: data.get("projectDesc"), - projectLink: data.get("projectLink"), - testimonial: data.get("testimonial"), - testimonialOptIn: true, - turnstileToken, - }), - }) - const json = await res.json().catch(() => ({})) - setLoading(false) - if (res.ok) { - setDone(true) - clear() - try { - localStorage.removeItem(BUGS_DRAFT_KEY) - } catch {} - } else setError(json.error ?? "Something went wrong") - } - - if (done) { - return ( -
-

Thank you! Review submitted.

-

- You unlocked 3 months of Builder Plan. We will email your reward shortly. -

-
- ) - } - - return ( -
- {hasDraft &&

Draft restored. Pick up where you left off.

} -
- - -
-
- - -
- -
-

Flows tested *

-
- {BETA_FLOWS.map((f) => ( - - ))} -
-
-
- - - -
-
-
-

Bugs and issues

-

Found something broken? Add each bug with its area and details. No bugs? Leave empty.

- {bugs.length === 0 ? ( - - ) : ( -
- {bugs.map((bug, i) => ( -
-
- Bug {i + 1} - -
- -