Error Feedback Service + friendly error handling across all pages

- Error Feedback Service: POST /api/feedback saves to JSONL, GET with admin token
- ErrorFeedback component: warm amber tones, friendly messages, 'Report Issue' button
- Replaced ALL red-themed error displays across 15+ pages with ErrorFeedback
- Kind classification: network, auth, location, upgrade, default messages
- QA test suite v2: 8-layer testing (system, API, render, scenario, edge, mobile, perf, security)
- Browser-based visual QA with Playwright (Layer 9)
This commit is contained in:
root
2026-06-18 16:24:47 +02:00
parent 28be776c84
commit 14d7127e41
19 changed files with 1913 additions and 165 deletions
+106
View File
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
const DATA_DIR = "/app/data";
const FEEDBACK_FILE = join(DATA_DIR, "feedback.jsonl");
interface FeedbackEntry {
id: string;
timestamp: string;
url: string;
error: string;
message: string;
userAgent: string;
}
function generateId(): string {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).substring(2, 8);
return `fb-${ts}-${rand}`;
}
function sanitizeUA(ua: string): string {
// Strip personal info from UA string
return ua.replace(/\(.*?\)/g, "(redacted)").substring(0, 200);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { url, error, message, userAgent } = body;
if (!url || !error) {
return NextResponse.json(
{ error: "url and error are required" },
{ status: 400 }
);
}
const entry: FeedbackEntry = {
id: generateId(),
timestamp: new Date().toISOString(),
url: url.substring(0, 500),
error: error.substring(0, 1000),
message: (message || "").substring(0, 2000),
userAgent: sanitizeUA(userAgent || ""),
};
// Ensure data dir exists
await mkdir(DATA_DIR, { recursive: true });
// Append to JSONL file
await writeFile(FEEDBACK_FILE, JSON.stringify(entry) + "\n", {
flag: "a",
});
return NextResponse.json({ id: entry.id });
} catch (err) {
// Silent failure — don't throw errors from error-reporting endpoint
console.error("Feedback save error:", err);
return NextResponse.json({ id: null }, { status: 200 });
}
}
export async function GET(request: NextRequest) {
// Admin-only endpoint — return paginated feedback
const authHeader = request.headers.get("authorization") || "";
const adminToken = process.env.FEEDBACK_ADMIN_TOKEN || "flh-feedback-admin";
// Simple token auth
if (authHeader !== `Bearer ${adminToken}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 200);
const offset = parseInt(searchParams.get("offset") || "0");
try {
if (!existsSync(FEEDBACK_FILE)) {
return NextResponse.json({ entries: [], total: 0 });
}
const raw = await readFile(FEEDBACK_FILE, "utf-8");
const lines = raw.trim().split("\n").filter(Boolean);
const entries = lines
.map((line) => {
try {
return JSON.parse(line) as FeedbackEntry;
} catch {
return null;
}
})
.filter(Boolean)
.reverse(); // newest first
const total = entries.length;
const page = entries.slice(offset, offset + limit);
return NextResponse.json({ entries: page, total });
} catch (err) {
console.error("Feedback read error:", err);
return NextResponse.json({ entries: [], total: 0 });
}
}
+2 -3
View File
@@ -4,6 +4,7 @@ import { Suspense, useState, FormEvent, useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useSearchParams } from "next/navigation";
import { Mail, ArrowLeft } from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
type AuthMode = "login" | "register";
type PageState = "select" | "email-form";
@@ -222,9 +223,7 @@ function AuthPageInner() {
{/* Error */}
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
{error}
</div>
<ErrorFeedback error={error} kind="auth" onRetry={() => setError("")} context="authentication" />
)}
{/* Form */}
+3 -14
View File
@@ -8,7 +8,6 @@ import {
Crown,
Star,
Clock,
AlertTriangle,
CheckCircle,
Loader2,
Lock,
@@ -16,6 +15,7 @@ import {
ShieldCheck,
Flag,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface AuthorInfo {
id: string;
@@ -206,16 +206,7 @@ export default function ThreadDetailPage() {
<p className="text-sm text-gray-500">Loading thread...</p>
</div>
) : error ? (
<div className="mx-4 mt-4 bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={() => { fetchThread(); fetchPosts(); }}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={() => { fetchThread(); fetchPosts(); }} context="thread posts" />
) : !thread ? (
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
<p className="text-sm text-gray-400">Thread not found</p>
@@ -384,9 +375,7 @@ export default function ThreadDetailPage() {
</div>
{replyError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{replyError}</p>
</div>
<ErrorFeedback error={replyError} kind="default" onRetry={() => setReplyError(null)} context="reply" />
)}
</form>
)}
+3 -13
View File
@@ -21,6 +21,7 @@ import {
FileText,
} from "lucide-react";
import PremiumBadge from "@/components/PremiumBadge";
import ErrorFeedback from "@/components/ErrorFeedback";
interface Category {
id: string;
@@ -375,16 +376,7 @@ export default function ForumPage() {
<p className="text-sm text-gray-500">Loading threads...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchThreads}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchThreads} context="forum threads" />
) : threads.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
@@ -624,9 +616,7 @@ export default function ForumPage() {
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
<ErrorFeedback error={createError} kind="default" onRetry={() => setCreateError(null)} context="create thread" />
)}
<div className="flex gap-3 pt-2">
+2 -11
View File
@@ -13,10 +13,10 @@ import {
Crown,
Shield,
Loader2,
AlertTriangle,
Clock,
UserMinus,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface GroupMember {
id: string;
@@ -231,16 +231,7 @@ export default function GroupDetailPage() {
<p className="text-sm text-gray-500">Loading group...</p>
</div>
) : error ? (
<div className="mx-4 mt-4 bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={() => { fetchGroup(); }}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchGroup} context="group detail" />
) : !group ? (
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
<p className="text-sm text-gray-400">Group not found</p>
+4 -17
View File
@@ -10,9 +10,9 @@ import {
X,
Crown,
Loader2,
AlertTriangle,
ChevronRight,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface GroupMember {
id: string;
@@ -274,16 +274,7 @@ export default function GroupsPage() {
<p className="text-sm text-gray-500">Loading groups...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchGroups}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchGroups} context="groups" />
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
@@ -412,9 +403,7 @@ export default function GroupsPage() {
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
<ErrorFeedback error={createError} kind="default" onRetry={() => setCreateError(null)} context="create group" />
)}
<div className="flex gap-3 pt-2">
@@ -490,9 +479,7 @@ export default function GroupsPage() {
</div>
{joinError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{joinError}</p>
</div>
<ErrorFeedback error={joinError} kind="default" onRetry={() => setJoinError(null)} context="join group" />
)}
{joinSuccess && (
+13 -10
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, Suspense, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import ErrorFeedback from "@/components/ErrorFeedback";
import { PERSONAS, getPersona } from "@/lib/personas";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -339,16 +340,18 @@ function NurChat() {
{/* ── Error Message ── */}
{error && (
<div className="mx-4 mb-2 flex items-start gap-2 bg-red-900/20 border border-red-800/30 rounded-xl px-3 py-2.5">
<AlertTriangle size={14} className="text-red-400 shrink-0 mt-0.5" />
<p className="text-xs text-red-300 flex-1">{error}</p>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-300 text-xs"
>
</button>
</div>
<ErrorFeedback
error={error}
kind={
error.includes("Upgrade") || error.includes("upgrade") || error.includes("premium")
? "upgrade"
: error.includes("Network") || error.includes("network")
? "network"
: "default"
}
onRetry={() => setError(null)}
context="Nur AI chat"
/>
)}
{/* ── Input Area ── */}
+12 -17
View File
@@ -20,6 +20,7 @@ import {
Check,
Navigation,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
// ── Types ──
interface PrayerTimings {
@@ -283,12 +284,11 @@ export default function PrayerPage() {
const text = await res.text().catch(() => "Unknown error");
throw new Error(`API error ${res.status}: ${text}`);
}
const json: PrayerApiResponse = await res.json();
if (json.code === 200 && json.data) {
setTimings(json.data.timings);
const hijri = json.data.date?.hijri;
if (hijri) {
setHijriDate(`${hijri.date} ${hijri.month?.en || ""}`);
const json = await res.json();
if (json.timings) {
setTimings(json.timings as PrayerTimings);
if (json.hijri) {
setHijriDate(json.hijri);
setShowHijri(true);
}
} else {
@@ -421,17 +421,12 @@ export default function PrayerPage() {
{/* ── Error State ── */}
{!loading && error && (
<div className="mx-4 mb-5">
<div className="bg-red-900/15 border border-red-800/30 rounded-2xl p-5 text-center">
<p className="text-sm text-red-400 mb-3">{error}</p>
<button
onClick={handleRefresh}
className="px-5 py-2.5 bg-red-900/30 border border-red-700/40 text-red-300 text-sm font-medium rounded-xl active:scale-95 transition"
>
Try Again
</button>
</div>
</div>
<ErrorFeedback
error={error}
kind={error.includes("Network") ? "network" : "default"}
onRetry={handleRefresh}
context="Prayer times"
/>
)}
{/* ── Content ── */}
+6 -8
View File
@@ -20,6 +20,7 @@ import {
Gift,
} from "lucide-react";
import PremiumBadge from "@/components/PremiumBadge";
import ErrorFeedback from "@/components/ErrorFeedback";
const VALID_MADHABS = ["Hanafi", "Maliki", "Shafi'i", "Hanbali"];
@@ -232,17 +233,14 @@ export default function ProfilePage() {
Edit Profile
</h3>
{saveMessage && (
<div
className={`mb-4 p-3 rounded-xl text-sm text-center ${
saveMessage === "Profile updated successfully"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: "bg-red-900/20 border border-red-800/40 text-red-400"
}`}
>
{saveMessage === "Profile updated successfully" && (
<div className="mb-4 p-3 rounded-xl text-sm text-center bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
{saveMessage}
</div>
)}
{saveMessage && saveMessage !== "Profile updated successfully" && (
<ErrorFeedback error={saveMessage} kind="default" onRetry={() => setSaveMessage("")} context="profile" />
)}
<form onSubmit={handleSave} className="space-y-4">
{/* Name */}
+7 -8
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import ErrorFeedback from "@/components/ErrorFeedback";
import {
Compass, MapPin, Navigation, LocateFixed,
Locate, Loader2, AlertTriangle, Info, RefreshCw,
@@ -365,14 +366,12 @@ export default function QiblaPage() {
{/* Error state */}
{!geolocating && positionError && !position && (
<div className="flex flex-col items-center py-16 gap-3 px-4">
<AlertTriangle size={28} className="text-amber-400" />
<p className="text-sm text-amber-400/80 text-center">{positionError}</p>
<button onClick={startGeolocation}
className="mt-2 px-6 py-3 bg-amber-900/20 border border-amber-700/30 text-amber-300 text-sm font-medium rounded-xl active:scale-95 transition min-h-[44px]">
Try Again
</button>
</div>
<ErrorFeedback
error={positionError}
kind="location"
onRetry={startGeolocation}
context="Qibla geolocation"
/>
)}
{/* Bearing info */}
+8 -22
View File
@@ -13,6 +13,7 @@ import {
TrendingUp,
Crown,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface ReferralStats {
totalReferrals: number;
@@ -138,34 +139,19 @@ export default function ReferPage() {
<div className="px-4 space-y-5 animate-fade-in">
{/* Toast Notification */}
{toast && (
<div
className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
toast.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: "bg-red-900/20 border border-red-800/40 text-red-400"
}`}
>
{toast.type === "success" ? (
<Check size={16} />
) : (
<ExternalLink size={16} />
)}
{toast && toast.type === "success" && (
<div className="flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{toast.text}
</div>
)}
{toast && toast.type === "error" && (
<ErrorFeedback error={toast.text} kind="default" onRetry={() => setToast(null)} context="referral" />
)}
{/* Error State */}
{error && !dataLoading && (
<div className="bg-red-900/20 border border-red-800/40 rounded-2xl p-5 text-center">
<p className="text-sm text-red-400 mb-3">{error}</p>
<button
onClick={fetchStats}
className="text-sm text-[#D4AF37] underline underline-offset-2"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchStats} context="referral stats" />
)}
{/* Data Loading */}
+9 -19
View File
@@ -25,6 +25,7 @@ import {
Percent,
} from "lucide-react";
import PremiumBadge from "@/components/PremiumBadge";
import ErrorFeedback from "@/components/ErrorFeedback";
const CATEGORIES = ["All", "E-Books", "Courses", "Design", "Audio", "Video", "Software", "Other"];
@@ -320,16 +321,7 @@ export default function SouqPage() {
<p className="text-sm text-gray-500">Loading listings...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchListings}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchListings} context="marketplace listings" />
) : listings.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
@@ -478,9 +470,7 @@ export default function SouqPage() {
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
<ErrorFeedback error={createError} kind="default" onRetry={() => setCreateError(null)} context="create listing" />
)}
<div className="flex gap-3 pt-2">
@@ -606,12 +596,12 @@ export default function SouqPage() {
</div>
{user.flhBalance < showConfirmDialog.listing.priceFlh && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3 mb-4">
<p className="text-xs text-red-300">
Insufficient balance. You need {showConfirmDialog.listing.priceFlh.toLocaleString()} FLH
but only have {user.flhBalance.toLocaleString()} FLH.
</p>
</div>
<ErrorFeedback
error={`Insufficient balance. You need ${showConfirmDialog.listing.priceFlh.toLocaleString()} FLH but only have ${user.flhBalance.toLocaleString()} FLH.`}
kind="default"
onRetry={() => {}}
context="purchase"
/>
)}
<div className="flex gap-3">
+15 -10
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useSearchParams } from "next/navigation";
import { Crown, Zap, Check, ChevronLeft, Loader2 } from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
const TIERS = [
{
@@ -207,21 +208,25 @@ function UpgradeContent() {
</div>
{/* Messages */}
{message && (
{message && message.type === "success" && (
<div className="mx-4 mb-4">
<div
className={`p-3 rounded-xl text-sm text-center ${
message.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: message.type === "error"
? "bg-red-900/20 border border-red-800/40 text-red-400"
: "bg-blue-900/20 border border-blue-800/40 text-blue-400"
}`}
>
<div className="p-3 rounded-xl text-sm text-center bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
{message.text}
</div>
</div>
)}
{message && message.type === "info" && (
<div className="mx-4 mb-4">
<div className="p-3 rounded-xl text-sm text-center bg-blue-900/20 border border-blue-800/40 text-blue-400">
{message.text}
</div>
</div>
)}
{message && message.type === "error" && (
<div className="mx-4 mb-4">
<ErrorFeedback error={message.text} kind="upgrade" onRetry={() => setMessage(null)} context="upgrade" />
</div>
)}
{/* Tier Cards */}
<div className="px-4 space-y-4">
+7 -13
View File
@@ -17,6 +17,7 @@ import {
Sparkles,
Info,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface CashoutEntry {
id: string;
@@ -224,22 +225,15 @@ export default function WalletPage() {
</div>
{/* Messages */}
{cashoutMessage && (
<div
className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
cashoutMessage.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: "bg-red-900/20 border border-red-800/40 text-red-400"
}`}
>
{cashoutMessage.type === "success" ? (
<Check size={16} />
) : (
<X size={16} />
)}
{cashoutMessage && cashoutMessage.type === "success" && (
<div className="flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{cashoutMessage.text}
</div>
)}
{cashoutMessage && cashoutMessage.type === "error" && (
<ErrorFeedback error={cashoutMessage.text} kind="default" onRetry={() => setCashoutMessage(null)} context="wallet" />
)}
{/* Top-Up Section */}
<div>
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useState, useCallback } from "react";
import { AlertTriangle, Send, X, Bug } from "lucide-react";
// ── Feedback submission API ──
async function submitFeedback(data: {
url: string;
error: string;
message?: string;
userAgent: string;
}) {
try {
const res = await fetch("/mobile/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.ok;
} catch {
return false; // silently fail — we don't want a feedback-on-feedback loop
}
}
// ── Friendly error messages map ──
const FRIENDLY_MESSAGES: Record<string, { title: string; description: string }> = {
default: {
title: "A small glitch 🛠️",
description:
"Something didn't load quite right. Don't worry — we're on it! Try again or drop us a report so we can fix it faster.",
},
location: {
title: "Location not available 📡",
description:
"We couldn't detect your location. Make sure location services are enabled, or try refreshing. The compass will work even without location!",
},
network: {
title: "Connection hiccup 🌐",
description:
"Looks like the signal dropped for a moment. Check your connection and try again. If it persists, let us know!",
},
auth: {
title: "Session expired 🔑",
description:
"Your session ran out — it happens! Just sign in again and you'll be right back where you were.",
},
upgrade: {
title: "Premium feature ✨",
description:
"This feature needs an upgrade. Check your plan details to unlock it!",
},
};
type ErrorKind = keyof typeof FRIENDLY_MESSAGES;
interface ErrorFeedbackProps {
/** The raw error message from the system */
error: string;
/** Category of error for friendly message mapping */
kind?: ErrorKind;
/** Callback to retry the action */
onRetry?: () => void;
/** Additional context for debugging */
context?: string;
}
export default function ErrorFeedback({
error,
kind = "default",
onRetry,
context,
}: ErrorFeedbackProps) {
const [showForm, setShowForm] = useState(false);
const [userMessage, setUserMessage] = useState("");
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
const friendly = FRIENDLY_MESSAGES[kind] || FRIENDLY_MESSAGES.default;
const handleSend = useCallback(async () => {
setSending(true);
const ok = await submitFeedback({
url: window.location.href,
error: error + (context ? ` [${context}]` : ""),
message: userMessage,
userAgent: navigator.userAgent,
});
setSent(true);
setSending(false);
if (ok) {
setTimeout(() => setShowForm(false), 2000);
}
}, [error, context, userMessage]);
return (
<div className="mx-4 mb-4">
<div className="bg-amber-900/10 border border-amber-700/30 rounded-2xl p-5">
{/* ── Friendly header ── */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-900/20 flex items-center justify-center shrink-0 mt-0.5">
<AlertTriangle size={20} className="text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-amber-200 mb-1">
{friendly.title}
</h3>
<p className="text-xs text-amber-300/70 leading-relaxed">
{friendly.description}
</p>
</div>
</div>
{/* ── Actions ── */}
<div className="flex flex-wrap gap-2 mt-4">
{onRetry && (
<button
onClick={onRetry}
className="flex-1 min-w-[80px] px-4 py-3 bg-amber-900/20 hover:bg-amber-900/30 border border-amber-700/30 text-amber-300 text-sm font-medium rounded-xl active:scale-95 transition min-h-[44px]"
>
Try Again
</button>
)}
<button
onClick={() => setShowForm((s) => !s)}
className="flex-1 min-w-[80px] px-4 py-3 bg-gray-800/60 hover:bg-gray-700/60 border border-gray-700/40 text-gray-300 text-sm rounded-xl active:scale-95 transition min-h-[44px] flex items-center justify-center gap-1.5"
>
<Bug size={14} />
Report Issue
</button>
</div>
{/* ── Feedback form (expandable) ── */}
{showForm && (
<div className="mt-4 pt-4 border-t border-amber-800/20">
{sent ? (
<p className="text-xs text-emerald-400 text-center">
Thanks! Your report helps us improve.
</p>
) : (
<>
<p className="text-xs text-amber-300/60 mb-2">
What happened? (optional helps us fix it faster)
</p>
<textarea
value={userMessage}
onChange={(e) => setUserMessage(e.target.value)}
placeholder="e.g. The page was blank, then this showed up..."
rows={2}
className="w-full bg-gray-900/60 border border-gray-700/40 rounded-xl px-3 py-2 text-xs text-gray-300 placeholder:text-gray-600 resize-none focus:outline-none focus:border-amber-700/50"
/>
<div className="flex gap-2 mt-2">
<button
onClick={handleSend}
disabled={sending}
className="flex-1 px-4 py-2.5 bg-amber-600/20 hover:bg-amber-600/30 border border-amber-600/30 text-amber-300 text-xs font-medium rounded-xl active:scale-95 transition min-h-[40px] disabled:opacity-50 flex items-center justify-center gap-1.5"
>
<Send size={12} />
{sending ? "Sending..." : "Send Report"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-3 py-2.5 bg-gray-800/40 border border-gray-700/30 text-gray-500 text-xs rounded-xl active:scale-95 transition min-h-[40px]"
>
<X size={14} />
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
);
}