// src/pages/AnalysisResultPage.jsx import React, { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { loadFromDB, saveToDB } from './db'; import jsPDF from 'jspdf'; import html2canvas from 'html2canvas'; import API_BASE_URL from './config'; const AnalysisResultPage = () => { const navigate = useNavigate(); const errorDescriptions = { 'error_below_min_image_size': 'Input image resolution is too small', 'error_exceed_max_image_size': 'Input image resolution is too large', 'error_src_skin_too_small': 'The skin area in the uploaded image is too small. The width of the skin needs to be greater than 60% of the width of the image.', 'error_src_skin_out_of_bound': 'The skin area in the uploaded image is out of bound', 'error_lighting_dark': 'The lighting in the uploaded image is too dark' }; const [analysisData, setAnalysisData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [selectedMetric, setSelectedMetric] = useState(null); const [activeMask, setActiveMask] = useState(null); const [showSignupModal, setShowSignupModal] = useState(false); const [showHelpModal, setShowHelpModal] = useState(false); const [isVerified, setIsVerified] = useState(false); // Form State const [formData, setFormData] = useState({ name: '', phone: '' }); const [formErrors, setFormErrors] = useState({ name: '', phone: '' }); // Report Ref const reportRef = useRef(null); // OTP State const [showOtpModal, setShowOtpModal] = useState(false); const [otp, setOtp] = useState(new Array(6).fill('')); const [otpError, setOtpError] = useState(''); const [isVerifying, setIsVerifying] = useState(false); const [isSendingReport, setIsSendingReport] = useState(false); const [isSubmittingSignup, setIsSubmittingSignup] = useState(false); const [toast, setToast] = useState({ show: false, message: '' }); // WhatsApp API configuration has been moved to backend // Track the generated OTP for verification (frontend-only simulation) const [generatedOtp, setGeneratedOtp] = useState(''); // Validation Regex const nameRegex = /^[a-zA-Z\s]*$/; const phoneRegex = /^\d{0,10}$/; // Ref for PDF capture const pdfRef = useRef(null); useEffect(() => { const loadAnalysisData = async () => { try { const urlParams = new URLSearchParams(window.location.search); const phone = urlParams.get('phone'); const reportId = urlParams.get('report'); if (phone && reportId) { // We are coming from a verification link try { const res = await fetch(`${API_BASE_URL}/api/reports/${reportId}`); if (res.ok) { const savedReport = await res.json(); setAnalysisData(savedReport.data); setFormData(prev => ({ ...prev, phone: phone, name: savedReport.userName || '' })); // Skip OTP when clicking the direct report link or from admin panel setIsVerified(true); setIsLoading(false); return; } } catch (e) { console.error("Failed to load report", e); } } const storedData = await loadFromDB('analysisData'); if (!storedData) { throw new Error('No analysis data found. Please start a new analysis.'); } setAnalysisData(storedData); setIsLoading(false); } catch (err) { setError(err.message); setIsLoading(false); } }; loadAnalysisData(); }, []); const handleNewAnalysis = async () => { await saveToDB('analysisData', null); navigate('/'); }; const getScoreColor = (score) => { if (!score && score !== 0) return 'bg-gray-600'; if (score >= 70) return 'bg-emerald-500'; if (score >= 40) return 'bg-yellow-500'; return 'bg-[#0097C3]'; }; const getMetricDisplayName = (metricKey) => { const displayNames = { wrinkle: 'Wrinkles', radiance: 'Radiance', firmness: 'Firmness', oiliness: 'Oiliness', redness: 'Redness', dark_circle_v2: 'Dark Circles', pore: 'Pores', eye_bag: 'Eye Bags', texture: 'Texture', age_spot: 'Age Spots', droopy_lower_eyelid: 'Droopy Lower Eyelid', moisture: 'Moisture', droopy_upper_eyelid: 'Droopy Upper Eyelid', acne: 'Acne' }; return displayNames[metricKey] || metricKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); }; const handleMetricClick = (metric) => { if (metric.output_mask_name) { const maskData = analysisData?.maskImages?.[metric.output_mask_name]; if (maskData) { setActiveMask(maskData); setSelectedMetric(metric.key); } else { setActiveMask(null); setSelectedMetric(null); } } else { setActiveMask(null); setSelectedMetric(null); } }; const handleClearMask = () => { setActiveMask(null); setSelectedMetric(null); }; const handleNameChange = (e) => { const val = e.target.value; if (nameRegex.test(val)) { setFormData(prev => ({ ...prev, name: val })); if (formErrors.name) setFormErrors(prev => ({ ...prev, name: '' })); } }; const handlePhoneChange = (e) => { const val = e.target.value; if (phoneRegex.test(val)) { setFormData(prev => ({ ...prev, phone: val })); if (formErrors.phone) setFormErrors(prev => ({ ...prev, phone: '' })); } }; // OTP Handlers const handleOtpChange = (element, index) => { if (isNaN(element.value)) return false; setOtp([...otp.map((d, idx) => (idx === index ? element.value : d))]); // Focus next input if (element.nextSibling && element.value !== "") { element.nextSibling.focus(); } }; const sendWhatsappOtp = async (phone) => { // Generate a unique ID for this specific analysis report const reportId = Math.random().toString(36).substring(7); const currentUrl = window.location.origin + window.location.pathname; const verificationLink = `${currentUrl}?phone=${phone}&report=${reportId}`; let otpToUse = ''; let isExisting = false; try { // 1. Create/Update User & Get OTP from Backend const userRes = await fetch(`${API_BASE_URL}/api/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: formData.name, phone: phone }) }); const userResText = await userRes.text(); let userData; try { userData = JSON.parse(userResText); } catch (e) { console.error('User API returned non-JSON:', userResText); throw new Error('Could not connect to user API properly. Please try again.'); } if (userData.success) { otpToUse = userData.user.otp; isExisting = userData.user.isExisting || false; setGeneratedOtp(otpToUse); } else if (userData.message && userData.message.includes('already registered')) { // If backend says already registered, we treat as existing user isExisting = true; console.log("User already registered, proceeding as existing user"); } else { const errorMsg = userData.message || 'Failed to register user'; throw new Error(errorMsg); } // 2. Save Report to Backend await fetch(`${API_BASE_URL}/api/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reportId, phone, userName: formData.name, data: analysisData, link: verificationLink }) }); console.log(`Sending WhatsApp message request to backend for ${phone}`); const response = await fetch(`${API_BASE_URL}/api/whatsapp/send-otp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: phone, name: formData.name, otpToUse: otpToUse, verificationLink: verificationLink, isExisting: isExisting }) }); if (!response.ok) { const waErrText = await response.text(); let errorData; try { errorData = JSON.parse(waErrText); } catch (e) { errorData = { message: waErrText.substring(0, 100) }; } console.error('Backend WhatsApp API Error:', errorData); throw new Error(errorData.message || 'Failed to send WhatsApp message'); } console.log('WhatsApp message sent successfully via backend'); return { ...(userData.user || {}), isExisting: isExisting }; } catch (err) { console.error('Error in sendWhatsappOtp:', err); throw err; } }; const verifyWhatsappOtp = async (enteredOtp) => { try { const res = await fetch(`${API_BASE_URL}/api/verify-otp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: formData.phone, otp: enteredOtp }) }); const data = await res.json(); return data.success; } catch (e) { console.error("OTP verification error", e); return false; } }; const sendReportToWhatsapp = async () => { setIsSendingReport(true); try { // Sending a follow-up message with the overall score via backend const response = await fetch(`${API_BASE_URL}/api/whatsapp/send-report`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: formData.phone, name: formData.name, overallScore: overallScore, origin: window.location.origin }) }); if (!response.ok) throw new Error('Failed to send report'); alert("Report details sent to your WhatsApp!"); } catch (err) { console.error('Error sending report:', err); alert("Failed to send report. Please try again."); } finally { setIsSendingReport(false); } }; const handleSignupSubmit = async () => { // const db = JSON.parse(localStorage.getItem('limelite_users') || '{}'); // const existingUser = db[formData.phone]; const existingUser = false; // logic moved to backend, or we can check via api but it's fine let errors = {}; if (!formData.name.trim()) errors.name = 'Name is required'; if (formData.phone.length !== 10) errors.phone = 'Number must be exactly 10 digits'; // We allow both new and existing users to proceed // If they are existing, the backend call in sendWhatsappOtp will handle it if (Object.keys(errors).length > 0) { setFormErrors(errors); return; } setIsSubmittingSignup(true); try { const user = await sendWhatsappOtp(formData.phone); setShowSignupModal(false); if (user && user.isExisting) { setIsVerified(true); setToast({ show: true, message: "You're already an user, welcome back!" }); setTimeout(() => setToast({ show: false, message: '' }), 4000); } else { setShowOtpModal(true); setOtp(new Array(6).fill('')); } } catch (err) { setFormErrors({ phone: err.message || 'Failed to send message. Please try again.' }); } finally { setIsSubmittingSignup(false); } }; const handleVerifyOtp = async () => { const enteredOtp = otp.join(''); if (enteredOtp.length !== 6) { setOtpError('Please enter a valid 6-digit OTP'); return; } setIsVerifying(true); setOtpError(''); try { const isValid = await verifyWhatsappOtp(enteredOtp); if (isValid) { alert("OTP Verified Successfully!"); setShowOtpModal(false); setIsVerified(true); } else { setOtpError('Invalid OTP. Please try again.'); } } catch (error) { setOtpError('Verification failed. Please try again.'); } finally { setIsVerifying(false); } }; const getScoreValue = (data, key) => { if (!data || typeof data !== 'object') return null; if (data[key] && typeof data[key] === 'object') return data[key].ui_score || data[key].score || data[key].raw_score || null; if (data[key] !== undefined && data[key] !== null) return data[key]; return null; }; const hasDetailedData = (data, key) => { return data && data[key] && typeof data[key] === 'object' && data[key].ui_score !== undefined; }; // --- START OF MODIFICATION --- /** * Adjusts the score by -5 and ensures it's not negative. * @param {number|string} score The original score. * @returns {number|null} The adjusted score or null. */ const getAdjustedScore = (score) => { const numScore = Number(score); if (isNaN(numScore) || score === null || score === undefined) return null; // Apply -5 adjustment and ensure the score is not negative. return Math.max(0, numScore - 5); }; // --- END OF MODIFICATION --- const getDisplayMetrics = (scoreData) => { if (!scoreData) return []; return Object.keys(scoreData) .filter(key => !['all', 'skin_age', 'takeaways', 'resize', 'image', 'url', 'resize_image', 'image_size'].includes(key)) .map(key => { const rawScore = hasDetailedData(scoreData, key) ? scoreData[key].ui_score : getScoreValue(scoreData, key); // --- START OF MODIFICATION --- const adjustedUiScore = getAdjustedScore(rawScore); // --- END OF MODIFICATION --- return { key, name: getMetricDisplayName(key), // Use the adjusted score for display ui_score: adjustedUiScore, raw_score: hasDetailedData(scoreData, key) ? scoreData[key].raw_score : null, output_mask_name: hasDetailedData(scoreData, key) ? scoreData[key].output_mask_name : null, hasDetails: hasDetailedData(scoreData, key) }; }); }; const renderUserImage = () => { const originalImage = analysisData?.originalImage; if (!originalImage) return null; return (
Skin Analysis {activeMask && ( Analysis Mask )}
); }; const handleDownloadPDF = async () => { if (!reportRef.current) return; // Show the hidden report briefly for capture const reportElement = reportRef.current; reportElement.style.display = 'block'; try { const canvas = await html2canvas(reportElement, { scale: 2, useCORS: true, allowTaint: true, backgroundColor: '#ffffff' }); const imgData = canvas.toDataURL('image/png'); const pdf = new jsPDF('p', 'mm', 'a4'); const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfHeight = (canvas.height * pdfWidth) / canvas.width; pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight); pdf.save(`Limelite_Skin_Report_${formData.name || 'User'}.pdf`); } catch (err) { console.error("PDF Generation Error:", err); alert("Failed to generate PDF. Please try again."); } finally { reportElement.style.display = 'none'; } }; if (isLoading) return (

Analyzing skin profile...

); if (error) return (

Analysis Failed

{errorDescriptions[error] || error}

); if (!analysisData || !analysisData.scoreData) return (

No analysis data available.

); const { scoreData } = analysisData; const displayMetrics = getDisplayMetrics(scoreData); // --- START OF MODIFICATION --- const rawOverallScore = getScoreValue(scoreData, 'all'); const overallScore = getAdjustedScore(rawOverallScore); // Adjusted overall score // --- END OF MODIFICATION --- const skinAge = getScoreValue(scoreData, 'skin_age'); // Displayed skin age is 5 years younger than the calculated skin_age. // Ensure it's a number and not negative. Round to nearest integer for display. const displayedSkinAge = (skinAge !== null && skinAge !== undefined && !isNaN(Number(skinAge))) ? Math.max(0, Math.round(Number(skinAge) - 5)) : null; const takeaways = scoreData.takeaways || []; // Calculate stroke dasharray for the main score circle const radius = 54; const circumference = 2 * Math.PI * radius; const strokeDashoffset = circumference - ((overallScore || 0) / 100) * circumference; // --- SORTING FOR TOP CONCERNS --- // Filter to get valid scores and sort by lowest score first const displayedMetricsList = isVerified ? displayMetrics.filter(m => m.ui_score !== null).sort((a, b) => a.ui_score - b.ui_score) : displayMetrics.filter(m => m.ui_score !== null).sort((a, b) => a.ui_score - b.ui_score).slice(0, 3); return (
{/* Header (Matching App.js) */}
Limelite Logo
{/* Main Content */}
{/* Image Section */}
{renderUserImage()}
{/* Results Panel - Dark Theme (Rectangular) */}
{/* Main Score Row - With Border Separator */}
{overallScore?.toFixed(0)}
Skin Health
{activeMask && ( )}
{/* Top Concerns */}

{isVerified ? 'ANALYSIS METRICS' : 'TOP CONCERNS'}

{displayedMetricsList.map((metric, idx) => (
isVerified && handleMetricClick(metric)} className={`bg-[#2B2B2B] rounded-none flex flex-col items-center justify-between p-2 h-[145px] flex-shrink-0 w-[30%] relative group transition-all duration-300 ${isVerified ? 'cursor-pointer hover:bg-[#333]' : ''}`} style={{ border: selectedMetric === metric.key ? '1px solid transparent' : '1px solid #333', backgroundImage: selectedMetric === metric.key ? 'linear-gradient(#2B2B2B, #2B2B2B), linear-gradient(135deg, #ffffff 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.1) 60%, #ffffff 100%)' : 'none', backgroundOrigin: 'border-box', backgroundClip: 'padding-box, border-box' }} >
{metric.ui_score} {metric.name}
{/* Badge */}
= 70 ? 'bg-emerald-500/20 text-[#4ade80] border border-emerald-500/30' : metric.ui_score >= 40 ? 'bg-amber-500/20 text-[#fbbf24] border border-amber-500/30' : 'bg-[#0097C3]/20 text-[#0097C3] border border-[#0097C3]/30' }`}> {metric.ui_score >= 70 ? 'Good' : metric.ui_score >= 40 ? 'Average' : 'Bad'}
))} {!isVerified && (
)}
{/* Footer CTA Section */} {!isVerified ? (

Sign up to access overall analysis report

) : (
)}
{/* Signup Modal */} {showSignupModal && (

Sign Up

{formErrors.name &&

{formErrors.name}

}
{formErrors.phone &&

{formErrors.phone}

}
)} {/* OTP Modal */} {showOtpModal && (

OTP Verification

Please enter the OTP sent to you on whatsapp.

{otp.map((data, index) => ( handleOtpChange(e.target, index)} onFocus={e => e.target.select()} /> ))}
{otpError &&

{otpError}

}
)} {/* Help Modal */} {showHelpModal && (

Help

•

To access previous reports, please contact us at hello@limelite.com

•

If you experience any technical issues, please contact us at support@example.com

)} {/* Toast Notification */} {toast.show && (
{toast.message}
)} {/* Premium Hidden PDF Report Template */}
{/* Header Section */}
Logo

Skin Analysis Report

{/* Accent Line */}
{/* Info Row (Name, Phone, Date) */}

Name

{formData.name || 'Anonymous'}

Contact Number

+91 {formData.phone || 'N/A'}

Analysis Date

{(() => { const d = new Date(); const day = d.getDate(); const suffix = (day > 3 && day < 21) || day % 10 > 3 ? 'th' : ['th', 'st', 'nd', 'rd'][day % 10]; return `${day}${suffix} ${d.toLocaleString('en-GB', { month: 'long' })} ${d.getFullYear()}`; })()}

{/* Primary Imaging */}
Original Scan
Analyzed skin Profile
{/* Overall Score Column */}

Skin Health

{Math.round(overallScore)}

{/* Estimated Skin Age removed as requested */}
{/* Parameters Section */}

Diagnostic Deep-Dive

{displayMetrics.filter(m => m.key !== 'all').map((metric, idx) => (
{metric.name} = 70 ? '#10b981' : metric.ui_score >= 40 ? '#f59e0b' : '#ef4444' }}>{metric.ui_score}%
= 70 ? '#10b981' : metric.ui_score >= 40 ? '#f59e0b' : '#0097C3' }}>
))}
{/* Specialist Recommendations */}

Prescriptive Guidance

STEP 01

Advanced Hydration: Apply hyaluronic acid serum daily to stabilize moisture levels.

STEP 02

Dermal Protection: Broad-spectrum physical sunshade is mandatory for UV protection.

STEP 03

Clinical Intervention: Visit your specialist for focused treatment on top priority zones.

{/* Minimalist Footer */}

* This diagnostic data is interpreted by Limelite AI Skin Vision. For detailed medical prescriptions, please book a 1-on-1 therapist consultation.

LIMELITE CLINICAL SYSTEMS

support@limelite.com | limelite.com

); }; export default AnalysisResultPage;