Initial commit: added frontend and backend

This commit is contained in:
2026-09-22 12:02:38 +05:30
commit cd2770e590
154 changed files with 25892 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+658
View File
@@ -0,0 +1,658 @@
// src/App.jsx
import React, { useState, useRef, useEffect } from 'react';
import JSZip from 'jszip';
import { IoMdAperture } from 'react-icons/io';
import API_BASE_URL from './config';
import * as skinapi from 'face-api.js';
import { useNavigate } from 'react-router';
import { saveToDB } from './db';
import buildInfo from './build-info.json';
const App = () => {
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 [view, setView] = useState('landing');
const [file, setFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState('');
const [videoStream, setVideoStream] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [skinBox, setskinBox] = useState(null);
const [modelsLoaded, setModelsLoaded] = useState(false);
const [analysisResults, setAnalysisResults] = useState(null);
const [maskMap, setMaskMap] = useState({});
const [zipUrl, setZipUrl] = useState(null);
const [cropEnabled, setCropEnabled] = useState(true);
const [showHelp, setShowHelp] = useState(false);
const [landmarks, setLandmarks] = useState(null);
const navigate = useNavigate();
const videoRef = useRef(null);
const canvasRef = useRef(null);
// ========== CONFIGURATION ==========
// --- Load skin-api models ---
useEffect(() => {
console.log(`%c Build Version: ${buildInfo.version} | Date: ${buildInfo.date}`, 'color: #0097C3; font-weight: bold; font-size: 14px;');
const loadModels = async () => {
await Promise.all([
skinapi.nets.tinyFaceDetector.loadFromUri(process.env.PUBLIC_URL + '/models'),
skinapi.nets.faceLandmark68TinyNet.loadFromUri(process.env.PUBLIC_URL + '/models')
]);
setModelsLoaded(true);
};
loadModels();
// Set flag for new analysis to allow signup even if number exists
sessionStorage.setItem('isNewAnalysis', 'true');
}, []);
// --- Real-time skin detection ---
useEffect(() => {
let intervalId;
if (view === 'camera' && videoRef.current && modelsLoaded) {
const detectskin = async () => {
const detections = await skinapi.detectSingleFace(videoRef.current, new skinapi.TinyFaceDetectorOptions());
if (detections) setskinBox(detections.box);
else setskinBox(null);
};
intervalId = setInterval(detectskin, 300);
}
return () => clearInterval(intervalId);
}, [view, modelsLoaded]);
// --- Video setup ---
useEffect(() => {
if (videoRef.current && videoStream) videoRef.current.srcObject = videoStream;
}, [videoStream]);
// Auto-start camera when view is 'camera'
useEffect(() => {
let localStream = null;
let isMounted = true;
if (view === 'camera' && !videoStream) {
const startCamera = async () => {
setError(null);
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
if (isMounted) setError('Camera API not accessible. Ensure you are using HTTPS.');
return;
}
try {
// Attempt High-Res User Facing Camera
localStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'user',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
if (!isMounted) {
localStream.getTracks().forEach(t => t.stop());
return;
}
setVideoStream(localStream);
} catch (err) {
console.warn('Primary camera request failed, attempting fallback...', err);
if (!isMounted) return;
try {
// Fallback to basic constraints
localStream = await navigator.mediaDevices.getUserMedia({ video: true });
if (!isMounted) {
localStream.getTracks().forEach(t => t.stop());
return;
}
setVideoStream(localStream);
} catch (err2) {
console.error('All camera requests failed:', err2);
if (!isMounted) return;
let msg = 'Could not access the camera.';
if (err2.name === 'NotAllowedError') msg = 'Camera access denied. check permissions.';
else if (err2.name === 'NotFoundError') msg = 'No camera found. Connect a webcam.';
else if (err2.name === 'NotReadableError') msg = 'Camera is in use by another app or tab.';
setError(msg);
}
}
};
startCamera();
}
// CLEANUP FUNCTION
return () => {
isMounted = false;
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
};
}, [view]);
useEffect(() => {
if (view !== 'camera' && videoStream) {
videoStream.getTracks().forEach(track => track.stop());
setVideoStream(null);
}
}, [view, videoStream]);
// --- Helper functions ---
const safeJsonParse = async (res) => {
const text = await res.text();
try {
return JSON.parse(text);
} catch (e) {
console.error("Non-JSON API response:", text.substring(0, 200));
throw new Error("Server returned an invalid non-JSON response. Please check your proxy or API URL configuration.");
}
};
const resizeImageFile = async (file, maxWidth = 2048, maxHeight = 2048, minShortSide = 1080) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
let scaleRatio = 1;
if (width > maxWidth || height > maxHeight) scaleRatio = Math.min(maxWidth / width, maxHeight / height);
const shortSide = Math.min(width, height);
if (shortSide * scaleRatio < minShortSide) scaleRatio = Math.max(minShortSide / shortSide, scaleRatio);
const targetWidth = Math.round(width * scaleRatio);
const targetHeight = Math.round(height * scaleRatio);
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
canvas.toBlob(blob => {
if (!blob) return reject(new Error('Canvas empty'));
resolve(new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: 'image/jpeg' }));
}, 'image/jpeg', 0.9);
};
img.onerror = () => reject(new Error('Image load failed'));
const reader = new FileReader();
reader.onload = e => img.src = e.target.result;
reader.readAsDataURL(file);
});
};
const blobToBase64 = blob => new Promise(resolve => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
setPreviewUrl(URL.createObjectURL(file));
setView('preview');
setError(null);
}
};
const handleCameraClick = () => {
// Reset to trigger camera initialization via useEffect
if (videoStream) {
videoStream.getTracks().forEach(t => t.stop());
setVideoStream(null);
}
setFile(null);
setPreviewUrl('');
setView('camera');
setError(null);
};
const handleCaptureskin = () => {
if (!videoRef.current) return;
const video = videoRef.current;
// --- Scale Resolution Logic ---
const videoWidth = video.videoWidth;
const videoHeight = video.videoHeight;
// We want higher resolution capture.
// Usually video element size is smaller than stream size.
// We are using videoHeight/Width which gives the intrinsic size of the stream.
const canvas = document.createElement("canvas");
canvas.width = videoWidth;
canvas.height = videoHeight;
const ctx = canvas.getContext("2d");
// Flip horizontally to match the mirrored viewfinder display
ctx.translate(videoWidth, 0);
ctx.scale(-1, 1);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
let finalCanvas;
if (cropEnabled) {
// Define crop area (match your overlay)
// The overlay is hardcoded as w-40 h-56 in Tailwind (160px x 224px) inside a h-80/96 container.
// We need to calculate the proportional area from the actual video video stream.
// Since the overlay CSS is relative to the displayed video size, we must compute ratios.
// However, for simplicity and robustness in this demo, let's just take a center crop
// which is approx 50% width and 70% height of the frame
const cropWidth = canvas.width * 0.55;
const cropHeight = canvas.height * 0.75;
const startX = (canvas.width - cropWidth) / 2;
const startY = (canvas.height - cropHeight) / 2;
finalCanvas = document.createElement("canvas");
finalCanvas.width = cropWidth;
finalCanvas.height = cropHeight;
const croppedCtx = finalCanvas.getContext("2d");
croppedCtx.drawImage(
canvas,
startX, startY, cropWidth, cropHeight,
0, 0, cropWidth, cropHeight
);
} else {
finalCanvas = canvas;
}
finalCanvas.toBlob((blob) => {
const capturedFile = new File([blob], "captured-skin.jpg", { type: "image/jpeg" });
setFile(capturedFile);
setPreviewUrl(URL.createObjectURL(capturedFile));
if (videoStream) videoStream.getTracks().forEach((t) => t.stop());
setVideoStream(null);
setView("preview");
}, "image/jpeg", 0.95);
};
const handleRetakeClick = () => {
setView('camera');
setFile(null);
setPreviewUrl('');
setAnalysisResults(null);
setMaskMap({});
setError(null);
};
const handleSubmitClick = async () => {
if (!file) return;
setIsLoading(true);
setError(null);
try {
// --- Ensure image meets API min size ---
let resized;
try {
resized = await resizeImageFile(file, 2048, 2048, 1080);
} catch (e) {
throw new Error(`Image resizing failed: ${e.message}`);
}
// --- Upload & Analyze via Backend Proxy ---
let result;
try {
const formData = new FormData();
formData.append('image', resized);
const response = await fetch(`${API_BASE_URL}/api/perfectcorp/analyze`, {
method: 'POST',
body: formData
});
const responseData = await safeJsonParse(response);
if (!response.ok) {
throw new Error(responseData.error || 'Backend perfectcorp proxy failed');
}
result = { result: responseData.result }; // Wrap to match old structure
} catch (e) {
throw new Error(`Backend Analysis Request failed: ${e.message}`);
}
// --- Process ZIP & save analysis ---
try {
const maskZipUrl = result.result?.results?.[0]?.data?.[0]?.url || result.result?.url;
if (!maskZipUrl) throw new Error("No mask ZIP URL found in result");
setZipUrl(maskZipUrl);
const zipRes = await fetch(maskZipUrl);
if (!zipRes.ok) throw new Error(`Failed to download ZIP file (status: ${zipRes.status})`);
const zipBlob = await zipRes.blob();
const zip = await JSZip.loadAsync(zipBlob);
const scoreFile = zip.file("skinanalysisResult/score_info.json") || zip.file("score_info.json");
const scores = scoreFile ? JSON.parse(await scoreFile.async("text")) : result.result?.results?.[0]?.scores || {};
if (Object.keys(scores).length === 0) throw new Error("Could not extract scores from analysis result.");
const newMaskMap = {};
for (const key of Object.keys(scores)) {
const maskName = scores[key].output_mask_name;
if (!maskName) continue;
const f = zip.file(`skinanalysisResult/${maskName}`) || zip.file(`masks/${maskName}`) || zip.file(maskName);
if (!f) continue;
const b = await f.async('blob');
newMaskMap[maskName] = await blobToBase64(b);
}
const originalBase64 = await blobToBase64(await fetch(previewUrl).then(r => r.blob()));
await saveToDB('analysisData', {
scoreData: scores,
maskImages: newMaskMap,
originalImage: originalBase64,
zipDownloadUrl: maskZipUrl
});
setAnalysisResults(scores);
setMaskMap(newMaskMap);
setView('results');
} catch (e) {
throw new Error(`Processing analysis result failed: ${e.message}`);
}
} catch (e) {
console.error(e);
setError(`Analysis failed: ${e.message}`);
setView('preview');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (view === 'results') navigate('/result');
}, [view, navigate]);
// Auto-start analysis removed to allow manual confirmation with Tick button
// --- Render ---
return (
<div className="min-h-screen relative overflow-hidden bg-[#F8F9FA]">
<div className="container mx-auto px-4 py-10 md:py-16 relative z-10 min-h-screen flex flex-col items-center justify-start gap-4 md:gap-8">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-[60] py-4 px-6 flex items-center justify-between bg-[#0097C3] shadow-lg">
<div className="flex items-center gap-4">
<img src={process.env.PUBLIC_URL + '/assets/images/elixify_logo.png'} alt="Elixify Logo" className="h-12 md:h-16 w-auto brightness-0 invert" />
</div>
<div className="flex items-center gap-4">
<button
className="text-white hover:text-white/80 transition-colors"
onClick={() => setShowHelp(!showHelp)}
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{view !== 'landing' && (
<button
className="text-white hover:text-white/80 transition-colors"
onClick={() => {
if (videoStream) {
videoStream.getTracks().forEach(t => t.stop());
setVideoStream(null);
}
setskinBox(null);
setView('landing');
setFile(null);
setPreviewUrl('');
setAnalysisResults(null);
}}
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</header>
<main className="w-full max-w-2xl mx-auto flex flex-col items-center justify-center relative">
{/* Landing View */}
{view === 'landing' && (
<div className="flex flex-col items-center justify-center w-full px-4 gap-8 fade-in mt-20 md:mt-32">
<div className="text-center space-y-4 mb-2">
<h1 className="text-4xl md:text-5xl font-bold text-[#0e5e73] leading-tight tracking-[0.02em] uppercase" style={{ fontFamily: "'Oswald', sans-serif" }}>
AI-POWERED<br />SKIN ANALYSER
</h1>
<p className="text-lg text-gray-700 font-medium tracking-wide max-w-sm mx-auto">
Experience professional-grade skin analysis in seconds
</p>
</div>
{/* Action Buttons Container */}
<div className="w-full flex flex-col gap-4">
{/* Take Photo Card */}
<div
onClick={() => setView('camera')}
className="bg-white border-2 border-dashed border-[#0097C3]/30 p-8 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-[#0097C3] transition-all duration-300 shadow-xl group"
>
<div className="w-16 h-16 bg-[#0097C3]/10 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
<svg className="w-8 h-8 text-[#0097C3]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div className="text-center">
<p className="text-xl font-bold text-black uppercase tracking-widest">Take a Photo</p>
<p className="text-gray-500 text-sm mt-1">Capture using your camera</p>
</div>
</div>
{/* Upload Photo Card */}
<div
onClick={() => document.getElementById('uploadInput').click()}
className="bg-white border-2 border-dashed border-[#0097C3]/30 p-8 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-[#0097C3] transition-all duration-300 shadow-xl group"
>
<div className="w-16 h-16 bg-[#0097C3]/10 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
<svg className="w-8 h-8 text-[#0097C3]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<div className="text-center">
<p className="text-xl font-bold text-black uppercase tracking-widest">Upload Photo</p>
<p className="text-gray-500 text-sm mt-1">Select from your gallery</p>
</div>
<input
type="file"
id="uploadInput"
accept="image/*"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</div>
{/* Admin Link */}
<div className="mt-8">
<button
onClick={() => navigate('/login')}
className="text-gray-400 hover:text-[#0097C3] transition-colors text-xs uppercase tracking-widest font-bold"
>
Admin Login
</button>
</div>
</div>
)}
{
view === 'camera' && (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="relative flex-1 overflow-hidden">
<video ref={videoRef} autoPlay playsInline className="w-full h-full object-cover transform scale-x-[-1]"></video>
{/* skin Mesh / Wireframe Overlay Placeholder */}
<div className="absolute inset-0 z-10 pointer-events-none">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/40"></div>
</div>
{/* Shutter Button container */}
<div className="absolute bottom-12 left-0 right-0 flex justify-center items-center z-20">
<button
onClick={handleCaptureskin}
className="transform active:scale-95 transition-all cursor-pointer bg-[#0097C3] w-36 h-18 py-4 px-10 rounded-full shadow-[0_0_20px_rgba(0,151,195,0.3)] flex items-center justify-center border-none"
>
<IoMdAperture className="w-12 h-12 text-white" />
</button>
</div>
</div>
</div>
)
}
{/* Preview View */}
{
view === 'preview' && (
<div className="fixed inset-0 z-50 bg-black flex flex-col fade-in">
<div className="relative flex-1 overflow-hidden">
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">No Image</div>
)}
{/* Scanning Animation only when loading */}
{isLoading && (
<div className="absolute inset-0 z-20 pointer-events-none overflow-hidden">
<div className="w-full h-[2px] bg-[#0097C3] shadow-[0_0_20px_rgba(0,151,195,0.8)] absolute top-0 animate-[scan_3s_linear_infinite]"></div>
<div className="w-full h-32 bg-gradient-to-t from-[#0097C3]/20 to-transparent absolute top-0 -translate-y-full animate-[scanTrace_3s_linear_infinite]"></div>
</div>
)}
{/* Bottom Controls */}
<div className="absolute bottom-10 left-0 right-0 flex flex-col items-center justify-center gap-4 z-30 px-6">
<div className="flex items-center gap-4 w-full justify-center">
<button
onClick={handleRetakeClick}
disabled={isLoading}
className="h-14 px-6 bg-white text-black rounded-none flex items-center justify-center gap-2 shadow-lg hover:bg-gray-200 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Retake"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span className="font-bold text-sm tracking-widest uppercase">Retake</span>
</button>
<button
onClick={handleSubmitClick}
disabled={isLoading}
className="h-14 w-20 bg-[#0097C3] text-white rounded-none flex items-center justify-center shadow-lg hover:bg-[#007ba0] transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Confirm"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M1 13l4 4L15 7m-6 6l4 4L23 7" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}
{/* Error Popup */}
{
error && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-6 animate-[fadeIn_0.2s_ease-out]">
<div className="bg-white rounded-none shadow-2xl w-full max-w-sm p-8 text-center">
<div className="w-16 h-16 bg-cyan-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-[#0097C3]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">Analysis Failed</h3>
<p className="text-gray-600 mb-8 leading-relaxed text-sm">
{(() => {
const foundCode = Object.keys(errorDescriptions).find(code => error && error.includes(code));
return foundCode ? errorDescriptions[foundCode] : error;
})()}
</p>
<button
onClick={handleRetakeClick}
className="w-full bg-[#0097C3] text-white font-bold py-4 px-6 rounded-none hover:bg-[#007ba0] transition-all active:scale-95 uppercase tracking-widest text-sm"
>
Retake Photo
</button>
</div>
</div>
)
}
</main >
{/* Help Popup */}
{showHelp && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-[fadeIn_0.2s_ease-out]">
<div className="bg-white rounded-none shadow-2xl w-full max-w-sm p-8 relative">
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-bold text-black">Help</h2>
<button
onClick={() => setShowHelp(false)}
className="p-2 -mr-2 text-black hover:bg-gray-100 rounded-full transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Content... */}
</div>
<div className="space-y-6">
<div className="flex items-start gap-4">
<div className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-2.5 shrink-0"></div>
<p className="text-lg text-gray-700 leading-relaxed">
To access previous reports, please contact us at <a href="mailto:hello@elixify.com" className="font-bold text-black">hello@elixify.com</a>
</p>
</div>
<div className="flex items-start gap-4">
<div className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-2.5 shrink-0"></div>
<p className="text-lg text-gray-700 leading-relaxed">
If you experience any technical issues, please contact us at <a href="mailto:support@example.com" className="font-bold text-black">support@example.com</a>
</p>
</div>
</div>
</div>
</div>
)}
</div >
</div >
);
};
export default App;
+8
View File
@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+338
View File
@@ -0,0 +1,338 @@
import React, { useState, useEffect } from 'react';
import API_BASE_URL from './config';
import { useNavigate } from 'react-router';
import { FiSearch, FiSettings, FiHelpCircle, FiChevronLeft, FiChevronRight, FiLogOut, FiArrowLeft, FiArrowRight } from 'react-icons/fi';
import { HiOutlineDocumentText, HiOutlineUserGroup } from 'react-icons/hi';
const DashboardPage = () => {
const [searchTerm, setSearchTerm] = useState('');
const [users, setUsers] = useState([]);
const navigate = useNavigate();
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/dashboard/users`);
const data = await res.json();
setUsers(data);
} catch (err) {
console.error("Failed to fetch users", err);
}
};
fetchUsers();
}, []);
const handleLogout = () => {
// Here you would clear any persisted auth state if it existed
navigate('/login');
};
const [showSupportModal, setShowSupportModal] = useState(false);
const [activeTab, setActiveTab] = useState('reports');
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 8;
// Settings state
const [settings, setSettings] = useState({
siteName: 'Elixify Skin Analyser',
adminEmail: 'olivia@elixify.com',
whatsappNotifications: true,
autoArchive: false
});
// Load users from backend
const reports = users.map(user => ({
id: user.phone,
name: user.name,
phone: user.phone,
report: 'View Link',
link: user.lastLink
}));
const filteredReports = reports.filter(report =>
report.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
report.phone.includes(searchTerm)
);
const totalPages = Math.ceil(filteredReports.length / ITEMS_PER_PAGE);
const paginatedReports = filteredReports.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
);
const handlePageChange = (newPage) => {
if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage);
}
};
const renderPageNumbers = () => {
const pages = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
if (currentPage <= 4) {
pages.push(1, 2, 3, 4, 5, '...', totalPages);
} else if (currentPage >= totalPages - 3) {
pages.push(1, '...', totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages);
} else {
pages.push(1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages);
}
}
return pages.map((page, index) => (
<button
key={index}
onClick={() => typeof page === 'number' && handlePageChange(page)}
className={`min-w-[40px] h-10 flex items-center justify-center text-sm font-semibold transition-all duration-200 rounded-lg ${currentPage === page
? 'bg-gray-50 text-gray-900'
: typeof page === 'number'
? 'text-gray-500 hover:text-gray-900 hover:bg-gray-50'
: 'text-gray-400 cursor-default'
}`}
>
{page}
</button>
));
};
return (
<div className="flex h-screen bg-[#F8F9FA] font-sans">
{/* Sidebar */}
<aside className="w-64 bg-[#0097C3] text-white flex flex-col shrink-0">
<div className="p-6 mb-4">
<img src={process.env.PUBLIC_URL + '/assets/images/elixify_logo.png'} alt="ELIXIFY" className="h-10 w-auto brightness-0 invert" />
</div>
<nav className="flex-1 px-0 mt-4">
<div
onClick={() => setActiveTab('reports')}
className={`flex items-center gap-3 px-6 py-4 cursor-pointer transition-colors ${activeTab === 'reports' ? 'bg-white text-[#0097C3]' : 'text-white hover:bg-white/10'}`}
>
<HiOutlineDocumentText className="text-xl" />
<span className="font-bold uppercase tracking-wider text-xs">Reports</span>
</div>
<div
onClick={() => setShowSupportModal(true)}
className="text-white hover:bg-white/10 flex items-center gap-3 px-6 py-4 transition-colors cursor-pointer"
>
<FiHelpCircle className="text-xl" />
<span className="font-bold uppercase tracking-wider text-xs">Support</span>
</div>
</nav>
<div className="px-0 space-y-0 mb-6 border-t border-white/10">
<div
onClick={() => setActiveTab('settings')}
className={`flex items-center gap-3 px-6 py-4 cursor-pointer transition-colors ${activeTab === 'settings' ? 'bg-white text-[#0097C3]' : 'text-white hover:bg-white/10'}`}
>
<FiSettings className="text-xl" />
<span className="font-bold uppercase tracking-wider text-xs">Settings</span>
</div>
<div className="hover:bg-white/10 text-white flex items-center gap-3 px-6 py-5 transition-colors cursor-pointer border-t border-white/10">
<div className="flex items-center gap-3 w-full">
<div className="w-10 h-10 bg-[#0086ad] rounded-full flex items-center justify-center overflow-hidden border border-white/30 text-white font-bold text-sm">
LA
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-bold truncate">Elixify Admin</p>
<p className="text-[10px] text-white/70 truncate tracking-wide">8778913301</p>
</div>
<button
onClick={handleLogout}
className="text-lg opacity-70 hover:opacity-100 transition-opacity p-1 ml-auto"
title="Logout"
>
<FiLogOut />
</button>
</div>
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Header */}
<header className="h-16 bg-white border-b flex items-center justify-between px-8 shrink-0">
<h1 className="text-xl font-bold text-gray-800">
{activeTab === 'reports' ? 'Welcome back, Admin' : 'System Settings'}
</h1>
{activeTab === 'reports' && (
<div className="relative w-80">
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search"
className="w-full bg-gray-50 border border-gray-200 rounded-lg py-2 pl-10 pr-4 focus:outline-none focus:ring-1 focus:ring-[#0097C3] text-sm text-gray-900"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
)}
</header>
{/* Content Area */}
<div className="flex-1 overflow-auto p-8">
{activeTab === 'reports' ? (
<div className="bg-white border border-gray-200 shadow-sm overflow-hidden flex flex-col min-h-[600px]">
<div className="p-6 border-b flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-800">Reports</h2>
</div>
<div className="flex-1 overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-[#F9FAFB] border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider w-16">S.No</th>
<th className="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider">Mobile Number</th>
<th className="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider">Report</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{paginatedReports.map((report, index) => (
<tr key={report.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-500 font-medium">{(currentPage - 1) * ITEMS_PER_PAGE + index + 1}</td>
<td className="px-6 py-4 text-sm font-medium text-gray-700">{report.name}</td>
<td className="px-6 py-4 text-sm text-gray-500">{report.phone}</td>
<td className="px-6 py-4 text-sm text-[#0097C3] font-semibold truncate max-w-[200px]">
<a href={report.link} target="_blank" rel="noopener noreferrer" className="hover:underline">
{report.link ? report.link.substring(report.link.indexOf('result')) : '-'}
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="p-6 border-t flex items-center justify-between bg-white shrink-0">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-bold text-black hover:bg-gray-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<FiArrowLeft className="text-lg" />
Previous
</button>
<div className="flex items-center gap-1">
{renderPageNumbers()}
</div>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || totalPages === 0}
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-bold text-black hover:bg-gray-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
Next
<FiArrowRight className="text-lg" />
</button>
</div>
</div>
) : (
<div className="bg-white border border-gray-200 shadow-sm p-8 max-w-2xl">
<h2 className="text-xl font-bold text-gray-800 mb-8 pb-4 border-b">General Settings</h2>
<div className="space-y-8">
<div className="grid grid-cols-3 items-center gap-4">
<label className="text-sm font-bold text-gray-700 uppercase">Site Name</label>
<input
type="text"
className="col-span-2 border-b-2 py-2 focus:outline-none focus:border-[#0097C3] transition-colors"
value={settings.siteName}
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
/>
</div>
<div className="grid grid-cols-3 items-center gap-4">
<label className="text-sm font-bold text-gray-700 uppercase">Admin Email</label>
<input
type="email"
className="col-span-2 border-b-2 py-2 focus:outline-none focus:border-[#C7372C] transition-colors"
value={settings.adminEmail}
onChange={(e) => setSettings({ ...settings, adminEmail: e.target.value })}
/>
</div>
<div className="flex items-center justify-between py-4 border-t">
<div>
<p className="font-bold text-gray-800">WhatsApp Notifications</p>
<p className="text-sm text-gray-500">Send automatic alerts for new analysis signups</p>
</div>
<button
onClick={() => setSettings({ ...settings, whatsappNotifications: !settings.whatsappNotifications })}
className={`w-12 h-6 rounded-full transition-colors relative ${settings.whatsappNotifications ? 'bg-green-500' : 'bg-gray-300'}`}
>
<div className={`absolute top-1 bg-white w-4 h-4 rounded-full transition-transform ${settings.whatsappNotifications ? 'translate-x-7' : 'translate-x-1'}`}></div>
</button>
</div>
<div className="flex items-center justify-between py-4 border-t">
<div>
<p className="font-bold text-gray-800">Auto-Archive Reports</p>
<p className="text-sm text-gray-500">Automatically archive reports older than 30 days</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoArchive: !settings.autoArchive })}
className={`w-12 h-6 rounded-full transition-colors relative ${settings.autoArchive ? 'bg-[#0097C3]' : 'bg-gray-300'}`}
>
<div className={`absolute top-1 bg-white w-4 h-4 rounded-full transition-transform ${settings.autoArchive ? 'translate-x-7' : 'translate-x-1'}`}></div>
</button>
</div>
<div className="pt-6">
<button className="bg-[#0097C3] text-white px-8 py-3 font-bold uppercase tracking-widest hover:bg-[#007ba0] transition-colors">
Save Changes
</button>
</div>
</div>
</div>
)}
</div>
</main>
{/* Support Modal */}
{showSupportModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white w-full max-w-lg p-10 relative shadow-2xl animate-[fadeIn_0.2s_ease-out] rounded-none">
<div className="flex justify-between items-center mb-10">
<h2 className="text-4xl font-bold text-[#18181b]">Help</h2>
<button
onClick={() => setShowSupportModal(false)}
className="text-gray-900 hover:text-black transition-colors"
>
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-8">
<div className="flex items-start gap-4">
<div className="w-2 h-2 rounded-full bg-gray-500 mt-2.5 shrink-0"></div>
<p className="text-xl text-gray-600 leading-relaxed">
To access previous reports, please contact us at <i className="text-gray-800 font-medium">hello@elixify.com</i>
</p>
</div>
<div className="flex items-start gap-4">
<div className="w-2 h-2 rounded-full bg-gray-500 mt-2.5 shrink-0"></div>
<p className="text-xl text-gray-600 leading-relaxed">
If you experience any technical issues, please contact us at <i className="text-gray-800 font-medium">support@example.com</i>
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default DashboardPage;
+77
View File
@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router';
import { FiEye, FiEyeOff } from 'react-icons/fi';
const LoginPage = () => {
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = (e) => {
e.preventDefault();
setError('');
if (credentials.email === 'admin@elixify.com' && credentials.password === 'admin@2026') {
navigate('/dashboard');
} else {
setError('Incorrect admin credentials');
}
};
return (
<div className="min-h-screen bg-[#18181b] flex items-center justify-center p-4 font-sans">
<div className="bg-white w-full max-w-md p-10 shadow-2xl rounded-none">
<div className="flex flex-col items-center mb-10">
<div className="bg-[#0097C3] p-4 mb-6">
<img src={process.env.PUBLIC_URL + '/assets/images/elixify_logo.png'} alt="Elixify" className="h-8 brightness-0 invert" />
</div>
</div>
{error && <p className="text-[#0097C3] text-sm font-bold text-center mb-6 animate-shake">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-bold text-black uppercase tracking-wider mb-2">Email Address</label>
<input
type="email"
required
className="w-full border-b-2 border-gray-200 py-3 focus:outline-none focus:border-[#0097C3] transition-colors text-black"
placeholder="olivia@elixify.com"
value={credentials.email}
onChange={(e) => setCredentials({ ...credentials, email: e.target.value })}
/>
</div>
<div className="relative">
<label className="block text-sm font-bold text-black uppercase tracking-wider mb-2">Password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
required
className="w-full border-b-2 border-gray-200 py-3 pr-10 focus:outline-none focus:border-[#0097C3] transition-colors text-black"
placeholder="••••••••"
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
/>
<button
type="button"
className="absolute right-0 top-1/2 -translate-y-1/2 text-gray-400 hover:text-black transition-colors"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <FiEye size={20} /> : <FiEyeOff size={20} />}
</button>
</div>
</div>
<button
type="submit"
className="w-full bg-[#0097C3] text-white py-4 font-bold uppercase tracking-widest hover:bg-[#007ba0] transition-colors mt-4"
>
Login
</button>
</form>
</div>
</div>
);
};
export default LoginPage;
+1
View File
@@ -0,0 +1 @@
{"version":1774451399318,"date":"2026-03-25T15:09:59.318Z"}
+119
View File
@@ -0,0 +1,119 @@
// src/pages/UploadAnalysis.jsx
import React, { useState } from "react";
import JSZip from "jszip";
import { useNavigate } from "react-router-dom";
// Component now accepts the URL of the original user image as a prop
const UploadAnalysis = ({ originalImageUrl }) => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleProcess = async () => {
setLoading(true);
sessionStorage.removeItem("analysisData");
try {
const API_ENDPOINT = "https://yce-api-01.perfectcorp.com/s2s/v1.0/task/skin-analysis";
const response = await fetch(API_ENDPOINT);
const data = await response.json();
const results = data?.result?.results;
const zipUrl = results?.[0]?.data?.[0]?.url;
if (!zipUrl) throw new Error("No ZIP URL found in API response");
const zipResponse = await fetch(zipUrl);
if (!zipResponse.ok) throw new Error("Failed to download ZIP");
const zipBlob = await zipResponse.blob();
const zip = await JSZip.loadAsync(zipBlob);
const maskImages = {};
let dynamicScoreData = null; // Use a variable that will be reliably set
let finalOriginalImage = originalImageUrl || "/assets/originalImage/WIN_20250813_12_56_33_Pro.jpg";
// 1a. Fetch static template for structural fields (like 'takeaways')
const staticScoreResponse = await fetch("/assets/data/score_info.json");
console.log("Static score fetch status:", staticScoreResponse.status);
const staticScoreData = await staticScoreResponse.json();
// 2. EXTRACT AND CONVERT FILES from the ZIP
await Promise.all(
Object.keys(zip.files).map(async (filename) => {
const file = zip.files[filename];
if (file.dir) return;
const lower = filename.toLowerCase();
// A. score_info.json: Store the data in the local variable 'dynamicScoreData'
if (lower.includes("score_info.json")) {
const text = await file.async("text");
// Set the dynamic data for sequential merging later
dynamicScoreData = JSON.parse(text);
}
// B. Mask Images (Logic remains the same)
else if (lower.endsWith("_output.png")) {
const blob = await file.async("blob");
const filenameOnly = filename.split('/').pop();
maskImages[filenameOnly] = URL.createObjectURL(blob);
}
})
);
// 3. SEQUENTIAL MERGING: This is the definitive fix for score update failure
let scoreMetrics = dynamicScoreData || {}; // Only dynamic data
// If you still want optional static fields for fallback, merge like this:
if (dynamicScoreData && staticScoreData) {
scoreMetrics = {
...staticScoreData, // fallback structure only
...dynamicScoreData, // overwrite with API values
};
}
// 4. ASSEMBLE the final data object
const analysisData = {
scoreData: scoreMetrics, // This now contains the reliably merged data
maskImages: maskImages,
originalImage: finalOriginalImage,
};
// 5. SAVE and NAVIGATE
sessionStorage.setItem("analysisData", JSON.stringify(analysisData));
// Navigate without passing data in state
navigate("/analysis-result");
} catch (err) {
console.error(err);
alert("Error processing analysis: " + err.message);
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "20px" }}>
<button
onClick={handleProcess}
disabled={loading}
style={{
padding: "10px 20px",
fontSize: "16px",
cursor: "pointer",
borderRadius: "8px",
backgroundColor: "#0097C3",
border: "none",
color: "white",
}}
>
{loading ? "Processing..." : "Start Analysis"}
</button>
</div>
);
};
export default UploadAnalysis;
+3
View File
@@ -0,0 +1,3 @@
const API_BASE_URL = process.env.REACT_APP_API_URL || 'https://digifox.studio';
export default API_BASE_URL;
+42
View File
@@ -0,0 +1,42 @@
export const saveToDB = (key, value) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ElixifyDB', 1);
request.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains('analysisStore')) {
db.createObjectStore('analysisStore');
}
};
request.onsuccess = e => {
const db = e.target.result;
const tx = db.transaction('analysisStore', 'readwrite');
const store = tx.objectStore('analysisStore');
const req = value === null ? store.delete(key) : store.put(value, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
};
request.onerror = () => reject(request.error);
});
};
export const loadFromDB = (key) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ElixifyDB', 1);
request.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains('analysisStore')) {
db.createObjectStore('analysisStore');
}
};
request.onsuccess = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains('analysisStore')) return resolve(null);
const tx = db.transaction('analysisStore', 'readonly');
const store = tx.objectStore('analysisStore');
const req = store.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
};
request.onerror = () => reject(request.error);
});
};
View File
+143
View File
@@ -0,0 +1,143 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Albert+Sans:wght@300;400;500;600;700&family=Inter:wght@400;700;800;900&family=Lexend:wght@400;700;800;900&family=Montserrat:wght@700;800;900&family=Oswald:wght@500;600;700&family=Outfit:wght@500;700;800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Syne:wght@700;800&family=Tilt+Warp&display=swap');
:root {
--primary-gradient: linear-gradient(135deg, #0097C3 0%, #00C6FF 100%);
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
}
body {
margin: 0;
font-family: 'Albert Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Background is handled in React components (App.jsx, etc) */
background-color: #05141a;
color: #ffffff;
overflow-x: hidden;
}
#root {
min-height: 100vh;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #05141a;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Utilities */
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
.text-gradient {
background: var(--primary-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulseGlow {
0%,
100% {
box-shadow: 0 0 20px rgba(0, 151, 195, 0.2);
}
50% {
box-shadow: 0 0 30px rgba(0, 151, 195, 0.5);
}
}
@keyframes scan {
0% {
top: 0%;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
top: 100%;
opacity: 0;
}
}
@keyframes scanTrace {
0% {
top: 0%;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
top: 100%;
opacity: 0;
}
}
.fade-in {
animation: fadeIn 0.8s ease-out forwards;
opacity: 0;
}
#root {
width: 100%;
min-height: 100vh;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import '@fontsource/albert-sans';
import ReactDOM from 'react-dom/client';
import App from './App.js'
import './index.css';
import { BrowserRouter, Route, Routes } from 'react-router';
import AnalysisResultPage from './AnalysisResultPage.jsx';
import LoginPage from './LoginPage.jsx';
import DashboardPage from './DashboardPage.jsx';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter basename='/elixifyskinanalyser'>
<Routes>
<Route path='/' element={<App />} />
<Route path='/result' element={<AnalysisResultPage />} />
<Route path='/login' element={<LoginPage />} />
<Route path='/dashboard' element={<DashboardPage />} />
</Routes>
</BrowserRouter>
{/* <ResultsPage /> */}
</React.StrictMode>
);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+13
View File
@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+33
View File
@@ -0,0 +1,33 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
'/wacto-api',
createProxyMiddleware({
target: 'https://api.wacto.app',
changeOrigin: true,
pathRewrite: {
'^/wacto-api': '', // remove /wacto-api from the request URL
},
onProxyReq: function(proxyReq, req, res) {
// Bypass Origin headers so Wacto API thinks the request is local to itself
proxyReq.setHeader('Origin', 'https://api.wacto.app');
}
})
);
app.use(
'/perfectcorp-api',
createProxyMiddleware({
target: 'https://yce-api-01.perfectcorp.com',
changeOrigin: true,
pathRewrite: {
'^/perfectcorp-api': '', // remove prefix from proxied request
},
onProxyReq: function(proxyReq, req, res) {
// Spoof the origin similarly for PerfectCorp's S2S security wall
proxyReq.setHeader('Origin', 'https://yce-api-01.perfectcorp.com');
}
})
);
};
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';