import React, { useState, useRef, useEffect } from "react"; import { createRoot } from "react-dom/client"; // --- Icons --- const UploadIcon = () => ( ); const XIcon = () => ( ); const FileImageIcon = () => ( ); const PdfIcon = () => ( ); const HtmlIcon = () => ( ); const LoaderIcon = () => ( ); const LensIcon = () => ( ); const PrintIcon = () => ( ); const DownloadIcon = () => ( ); const HistoryIcon = () => ( ); const BrandLogo = ({ className = "h-9 w-auto" }: { className?: string }) => ( TRD Studios ); const InfoIcon = () => ( ); // --- Constants --- const APP_GRADIENT = "linear-gradient(126deg, #c3e0d4 -20%, rgb(230, 244, 255) 35.69%, rgb(221, 203, 247) 75.03%, rgb(250, 230, 243) 100%)"; // --- Types --- interface Report { id: string; type: 'heuristics' | 'laws'; html: string; url: string; timestamp: Date; } interface Asset { file: File; preview: string; base64: string; mimeType: string; name: string; } declare global { interface Window { Razorpay?: any; } } type Pricing = { amount: number; currency: "INR" | "USD"; displayAmount: string; country: string; orderId: string; keyId: string; }; const isValidEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); const HISTORY_STORAGE_KEY = "ux-lens.history.v1"; const MAX_HISTORY = 5; const checkCookiesEnabled = (): boolean => { if (typeof document === "undefined") return false; if (typeof navigator !== "undefined" && navigator.cookieEnabled === false) return false; try { const probe = `__uxlens_cookie_probe_${Date.now()}`; document.cookie = `${probe}=1; path=/; SameSite=Lax`; const ok = document.cookie.includes(`${probe}=1`); if (ok) { // Clear probe. document.cookie = `${probe}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`; } return ok; } catch { return false; } }; const loadHistoryFromStorage = (): Report[] => { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(HISTORY_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; const revived: Report[] = parsed.map((r: any): Report => ({ id: String(r.id || ""), type: r.type === "laws" ? "laws" : "heuristics", html: typeof r.html === "string" ? r.html : "", url: typeof r.url === "string" ? r.url : "", timestamp: r.timestamp ? new Date(r.timestamp) : new Date(), })); return revived.filter((r) => r.id && r.html).slice(0, MAX_HISTORY); } catch { return []; } }; const saveHistoryToStorage = (reports: Report[]): void => { if (typeof window === "undefined") return; try { const trimmed = reports.slice(0, MAX_HISTORY); window.localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(trimmed)); } catch (err) { console.warn("Could not save history to localStorage:", err); } }; const isValidUrl = (v: string) => { try { const u = new URL(v); return u.protocol === "http:" || u.protocol === "https:"; } catch { return false; } }; const MAX_TOTAL_UPLOAD_BYTES = 10 * 1024 * 1024; const MAX_PER_FILE_UPLOAD_BYTES = 5 * 1024 * 1024; const MAX_CONTEXT_CHARS = 500; const formatBytes = (n: number) => { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; }; const loadRazorpayScript = (): Promise => new Promise((resolve) => { if (window.Razorpay) return resolve(true); const s = document.createElement("script"); s.src = "https://checkout.razorpay.com/v1/checkout.js"; s.onload = () => resolve(true); s.onerror = () => resolve(false); document.body.appendChild(s); }); // --- Main App Component --- const App = () => { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [url, setUrl] = useState(""); const [context, setContext] = useState(""); const [auditType, setAuditType] = useState<"heuristics" | "laws">("heuristics"); const [assets, setAssets] = useState([]); const [assetsError, setAssetsError] = useState(null); const [loading, setLoading] = useState(false); const [loadingLogs, setLoadingLogs] = useState([]); const [showDocumentation, setShowDocumentation] = useState(false); // History State const [reports, setReports] = useState([]); const [activeReportId, setActiveReportId] = useState(null); // Cookies / localStorage availability gate. We persist history to // localStorage but treat cookie consent as the user-facing toggle. const [cookiesEnabled, setCookiesEnabled] = useState(true); const [error, setError] = useState(null); const fileInputRef = useRef(null); // On mount: detect cookie support and load any persisted history. useEffect(() => { const ok = checkCookiesEnabled(); setCookiesEnabled(ok); if (ok) { const persisted = loadHistoryFromStorage(); if (persisted.length) { setReports(persisted); setActiveReportId(persisted[0].id); } } }, []); // Persist whenever reports change. useEffect(() => { if (!cookiesEnabled) return; saveHistoryToStorage(reports); }, [reports, cookiesEnabled]); // --- Loading Simulator Effect --- useEffect(() => { if (!loading) { setLoadingLogs([]); return; } const heuristicSteps = [ "Initialising AI vision core...", "Processing assets. Normalising dimensions and colour...", "Building DOM tree representation...", "Checking Visibility of system status...", "Checking Match between system and real world...", "Checking User control and freedom...", "Checking Consistency and standards...", "Checking Error prevention...", "Checking Recognition vs recall...", "Checking Flexibility and efficiency...", "Checking Aesthetic and minimalist design...", "Checking Error handling...", "Checking Help and documentation...", "Synthesising violations into risk matrix...", "Building user journey maps...", "Compiling final HTML report..." ]; const lawsSteps = [ "Initialising AI vision core...", "Processing assets. Normalising dimensions and colour...", "Building DOM tree representation...", "Checking Jakob’s Law...", "Calculating Hick’s Law...", "Measuring Fitts’s Law...", "Analysing Miller’s Law...", "Reviewing Law of Proximity...", "Checking Law of Similarity...", "Evaluating Law of Common Region...", "Applying Von Restorff Effect...", "Assessing Aesthetic Usability Effect...", "Checking Peak End Rule...", "Synthesising violations into risk matrix...", "Building user journey maps...", "Compiling final HTML report..." ]; const steps = auditType === 'heuristics' ? heuristicSteps : lawsSteps; // Initial State setLoadingLogs([steps[0]]); let currentStep = 0; const intervalId = setInterval(() => { currentStep++; if (currentStep < steps.length) { setLoadingLogs(prev => { // Keep the last 4 logs for the fading effect const newLogs = [...prev, steps[currentStep]]; return newLogs.slice(-4); }); } }, 1500); // Advance step every 1.5 seconds return () => clearInterval(intervalId); }, [loading, auditType]); const handleFileChange = async (e: React.ChangeEvent) => { if (!e.target.files) return; const files = Array.from(e.target.files) as File[]; // Validate file types before reading any of them. Only PDF, JPG, PNG. const ALLOWED_MIME = new Set(["image/jpeg", "image/jpg", "image/png", "application/pdf"]); const wrongType = files.find((f) => !ALLOWED_MIME.has(f.type)); if (wrongType) { setAssetsError( `${wrongType.name || "Unsupported file"} is not allowed. Only PDF, JPG, and PNG are accepted.` ); e.target.value = ""; return; } // Validate per-file size before reading any of them. const oversized = files.find((f) => f.size > MAX_PER_FILE_UPLOAD_BYTES); if (oversized) { setAssetsError( `${oversized.name} is ${formatBytes(oversized.size)}. Each file must be ${formatBytes(MAX_PER_FILE_UPLOAD_BYTES)} or smaller.` ); e.target.value = ""; return; } // Validate combined size of (existing + new) before reading any of them. const existingTotal = assets.reduce((sum: number, a: Asset) => sum + a.file.size, 0); const incomingTotal = files.reduce((sum: number, f: File) => sum + f.size, 0); const projectedTotal = existingTotal + incomingTotal; if (projectedTotal > MAX_TOTAL_UPLOAD_BYTES) { setAssetsError( `Combined size would be ${formatBytes(projectedTotal)}. Total uploads must stay under ${formatBytes(MAX_TOTAL_UPLOAD_BYTES)}.` ); e.target.value = ""; return; } setAssetsError(null); const newAssets: Asset[] = []; for (const file of files) { const base64 = await fileToBase64(file); const preview = file.type.startsWith("image/") ? URL.createObjectURL(file) : ""; newAssets.push({ file, preview, base64: base64.split(",")[1], // Remove data URL prefix mimeType: file.type, name: file.name, }); } setAssets((prev) => [...prev, ...newAssets]); // Reset the input so the user can re-pick the same file if they remove it. e.target.value = ""; }; const fileToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = () => resolve(reader.result as string); reader.onerror = (error) => reject(error); }); }; const removeAsset = (index: number) => { setAssets((prev: Asset[]) => prev.filter((_, i) => i !== index)); setAssetsError(null); }; const totalAssetBytes = assets.reduce((sum: number, a: Asset) => sum + a.file.size, 0); const sizeLimitsOk = totalAssetBytes <= MAX_TOTAL_UPLOAD_BYTES && assets.every((a: Asset) => a.file.size <= MAX_PER_FILE_UPLOAD_BYTES); const generateReport = async () => { setError(null); const trimmedName = name.trim(); const trimmedEmail = email.trim(); const trimmedUrl = url.trim(); if (trimmedName.length < 4) { setError("Name must be at least 4 characters."); return; } if (!isValidEmail(trimmedEmail)) { setError("Please enter a valid email address."); return; } if (!trimmedUrl && assets.length === 0) { setError("Please enter a website URL or upload at least one screenshot/file."); return; } if (trimmedUrl && !isValidUrl(trimmedUrl)) { setError("Please enter a valid URL (e.g. https://example.com)."); return; } if (assets.length > 0 && !context.trim()) { setError("Additional context is required when files are uploaded."); return; } if (!sizeLimitsOk) { setError( `Uploads exceed limits (max ${formatBytes(MAX_PER_FILE_UPLOAD_BYTES)} per file, ${formatBytes(MAX_TOTAL_UPLOAD_BYTES)} total).` ); return; } setLoading(true); let auditId = ""; const reportCancelled = async (status: "cancelled" | "failed", errorMessage?: string) => { if (!auditId) return; try { await fetch("/api/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ auditId, status, errorMessage }), }); } catch (e) { console.warn("Failed to record cancel/fail status:", e); } }; try { // 1. Create Razorpay order — server validates inputs again, derives // pricing from the trusted IP header, and inserts the initial DB row. const orderResp = await fetch("/api/order", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: trimmedName, email: trimmedEmail, auditType, url: trimmedUrl, context, assetFilenames: assets.map((a) => a.name), }), }); if (!orderResp.ok) { let detail = ""; try { const errBody = await orderResp.json(); detail = errBody?.error || JSON.stringify(errBody); } catch { detail = (await orderResp.text().catch(() => "")) || "no response body"; } console.error("/api/order failed:", orderResp.status, detail); throw new Error(`Could not create payment order (HTTP ${orderResp.status}): ${detail}`); } const order: Pricing & { auditId: string } = await orderResp.json(); auditId = order.auditId; // 2. Load Razorpay checkout const ok = await loadRazorpayScript(); if (!ok || !window.Razorpay) { await reportCancelled("failed", "Razorpay script failed to load."); throw new Error("Could not load Razorpay checkout. Please try again."); } // 3. Open checkout and wait for payment const payment = await new Promise<{ razorpay_payment_id: string; razorpay_order_id: string; razorpay_signature: string; }>((resolve, reject) => { const rzp = new window.Razorpay({ key: order.keyId, amount: order.amount, currency: order.currency, order_id: order.orderId, name: "TRD Studios", description: `UX Lens audit — ${order.displayAmount}`, prefill: { name: trimmedName, email: trimmedEmail }, theme: { color: "#4338ca" }, handler: (resp: any) => resolve(resp), modal: { ondismiss: () => reject(new Error("Payment was cancelled.")), }, }); rzp.on("payment.failed", (resp: any) => reject(new Error(resp?.error?.description || "Payment failed.")) ); rzp.open(); }).catch(async (err: Error) => { const isCancel = /cancel/i.test(err.message); await reportCancelled(isCancel ? "cancelled" : "failed", err.message); throw err; }); // 4. Generate report with payment proof const today = new Date().toLocaleDateString("en-GB", { year: "numeric", month: "long", day: "numeric", }); const response = await fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ auditId, razorpayPaymentId: payment.razorpay_payment_id, razorpaySignature: payment.razorpay_signature, today, assets: assets.map((a: Asset) => ({ mimeType: a.mimeType, base64: a.base64, name: a.name, })), }), }); if (!response.ok) { const errBody = await response.json().catch(() => ({} as any)); throw new Error(errBody?.error || `Request failed with status ${response.status}`); } const result = await response.json(); const { html, warnings } = result || {}; if (Array.isArray(warnings) && warnings.length) { console.warn("Post-generation warnings from /api/generate:", warnings); } const newReport: Report = { id: Date.now().toString(), type: auditType, html: html || "", url: trimmedUrl, timestamp: new Date(), }; setReports((prev: Report[]) => [newReport, ...prev].slice(0, MAX_HISTORY)); setActiveReportId(newReport.id); } catch (err: any) { console.error(err); setError(err.message || "Failed to generate report. Please try again."); } finally { setLoading(false); } }; const openPrintWindow = (html: string) => { const printWindow = window.open('', '_blank'); if (printWindow) { printWindow.document.write(html); printWindow.document.close(); setTimeout(() => { printWindow.print(); }, 500); } }; const downloadHtml = (html: string) => { const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `UX_Lens_Report_${new Date().toISOString().slice(0, 10)}.html`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const activeReport = reports.find(r => r.id === activeReportId); return (
{/* Documentation Modal */} {showDocumentation && (
{/* Modal Header */}

UX Lens Documentation

Made by TRD Studios

{/* Modal Scrollable Content */}
{/* Introduction */}

UX Lens is an automated audit tool. It acts as a research partner for product teams. It combines vision analysis with industry standards like Nielsen’s Heuristics and UX Laws. It identifies friction points in designs systematically. The tool turns static files and URLs into strategic reports. This saves time between discovery and fixing issues.

{/* System Workflow Visualization */}

1
System Workflow

{/* Connecting Line */}
{/* Step 1: Input */}
📂

Input Data

The system takes URLs, static images, and context. It prepares this data for analysis.

{/* Step 2: Vision AI */}
🧠

Vision AI

Gemini Vision simulates eye-tracking. It scans the layout for visual hierarchy.

{/* Step 3: Logic Layer (Split) */}
H

UX Heuristics

Checks Nielsen's 10 Principles.

L

Laws of UX

Checks Gestalt laws.

{/* Step 4: Output */}
📊

Strategic Output

It generates an HTML report. This includes charts and prioritised issues.

{/* Data Types */}

2
Multi-modal Inputs

🔗 URL and Context

The URL provides domain knowledge. Context helps calibrate the score for specific users.

🖼️ Visual Assets

Upload Screenshots, PDFs, or HTML files. The model identifies contrast issues and visual clutter.

📈 Actionable Output

The report gives code-ready advice. It separates critical blockers from cosmetic fixes.

{/* Modal Footer */}
)} {/* Header */}

UX Lens

Intelligent Website Audit Platform

{/* Left Column: Inputs */}
{/* Context Card */}

Audit Configuration

Setup your analysis parameters

{/* Method Selection */}
{/* Name and Email */}
setName(e.target.value)} disabled={loading} aria-invalid={name.length > 0 && name.trim().length < 4} className={`w-full px-3 py-2 border rounded-lg focus:ring-2 outline-none transition-all disabled:opacity-50 disabled:bg-slate-100 bg-white ${ name.length > 0 && name.trim().length < 4 ? "border-red-400 focus:ring-red-300 focus:border-red-500" : "border-slate-300 focus:ring-indigo-500 focus:border-indigo-500" }`} /> {name.length > 0 && name.trim().length < 4 && (

Name must be at least 4 characters.

)}
setEmail(e.target.value)} disabled={loading} aria-invalid={email.length > 0 && !isValidEmail(email)} className={`w-full px-3 py-2 border rounded-lg focus:ring-2 outline-none transition-all disabled:opacity-50 disabled:bg-slate-100 bg-white ${ email.length > 0 && !isValidEmail(email) ? "border-red-400 focus:ring-red-300 focus:border-red-500" : "border-slate-300 focus:ring-indigo-500 focus:border-indigo-500" }`} /> {email.length > 0 && !isValidEmail(email) && (

Please enter a valid email address.

)}
{/* URL Input */}
setUrl(e.target.value)} disabled={loading} aria-invalid={url.length > 0 && !isValidUrl(url.trim())} className={`w-full px-3 py-2 border rounded-lg focus:ring-2 outline-none transition-all disabled:opacity-50 disabled:bg-slate-100 bg-white ${ url.length > 0 && !isValidUrl(url.trim()) ? "border-red-400 focus:ring-red-300 focus:border-red-500" : "border-slate-300 focus:ring-indigo-500 focus:border-indigo-500" }`} /> {url.length > 0 && !isValidUrl(url.trim()) ? (

Please enter a valid URL starting with http:// or https://.

) : (

Provide a URL or upload screenshots/files below.

)}
{/* Context Input */}