feat: setup S2S integration, tailwind fixes, admin routing, nginx fallback

This commit is contained in:
2026-09-22 16:40:42 +05:30
parent 45d13fa7be
commit 05358be6c1
17 changed files with 522 additions and 149 deletions
+117 -37
View File
@@ -1,5 +1,7 @@
const axios = require('axios'); const axios = require('axios');
const FormData = require('form-data'); const FormData = require('form-data');
const Config = require('../models/Config');
const { decrypt } = require('../utils/crypto');
exports.analyzeImage = async (req, res) => { exports.analyzeImage = async (req, res) => {
try { try {
@@ -7,50 +9,128 @@ exports.analyzeImage = async (req, res) => {
return res.status(400).json({ error: 'No image provided' }); return res.status(400).json({ error: 'No image provided' });
} }
// In a real scenario, we would forward this to the Perfect Corp API // Fetch API Key from DB
// For now, we will return a mock response matching the expected structure const apiKeyConfig = await Config.findOne({ keyName: 'PERFECT_CORP_API_KEY' });
// or you could proxy it like this: if (!apiKeyConfig) {
/* return res.status(500).json({ error: 'API Key configuration missing' });
const formData = new FormData(); }
formData.append('image', req.file.buffer, req.file.originalname); const decryptedApiKey = decrypt(apiKeyConfig.encryptedValue, apiKeyConfig.iv);
const response = await axios.post(process.env.PERFECT_CORP_API_URL, formData, { const baseUrl = process.env.PERFECT_CORP_API_URL || 'https://yce-api-01.makeupar.com/s2s/v2.0';
headers: {
...formData.getHeaders(),
'Authorization': `Bearer ${process.env.PERFECT_CORP_API_KEY}`
}
});
return res.json({ result: response.data });
*/
// MOCK RESPONSE // Step 1: Initialize Upload
console.log('Mocking perfect corp analysis for image:', req.file.originalname); console.log('Step 1: Initializing Upload...');
const initPayload = {
const mockResult = { files: [
results: [
{ {
data: [ content_type: req.file.mimetype || 'image/jpeg',
{ file_name: req.file.originalname || 'image.jpg',
url: 'https://example.com/mock-mask.zip' file_size: req.file.size
}
]
} }
], ]
// Add other mock data that the frontend expects to parse...
face_attributes: {
age: 30,
gender: 'female'
},
skin_issues: {
wrinkles: 10,
spots: 5,
acne: 2
}
}; };
res.json({ result: mockResult }); const initResponse = await axios.post(`${baseUrl}/file`, initPayload, {
headers: {
'Authorization': `Bearer ${decryptedApiKey}`,
'Content-Type': 'application/json'
}
});
const fileData = initResponse.data?.data?.files?.[0];
if (!fileData || !fileData.requests || !fileData.requests[0]) {
throw new Error('Failed to get upload URL from Perfect Corp API');
}
const fileId = fileData.file_id;
const uploadRequest = fileData.requests[0];
// Step 2: S3 Upload
console.log(`Step 2: Uploading image to S3 (fileId: ${fileId})...`);
await axios.put(uploadRequest.url, req.file.buffer, {
headers: uploadRequest.headers
});
// Step 3: Create Task
console.log('Step 3: Creating AI Task...');
const taskPayload = {
src_file_id: fileId,
dst_actions: [
"wrinkle", "pore", "texture", "acne", "moisture", "eye_bag",
"firmness", "age_spot", "radiance", "redness", "oiliness"
],
miniserver_args: {
enable_mask_overlay: false // Generate multiple output masks
},
format: "json"
};
const taskResponse = await axios.post(`${baseUrl}/task/skin-analysis`, taskPayload, {
headers: {
'Authorization': `Bearer ${decryptedApiKey}`,
'Content-Type': 'application/json'
}
});
const taskId = taskResponse.data?.data?.task_id;
if (!taskId) {
throw new Error('Failed to create AI task');
}
// Step 4: Poll Task Status
console.log(`Step 4: Polling task status for taskId: ${taskId}...`);
let taskFinished = false;
let finalResult = null;
let attempts = 0;
const maxAttempts = 30; // 60 seconds max
while (!taskFinished && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds
attempts++;
const pollResponse = await axios.get(`${baseUrl}/task/skin-analysis/${taskId}`, {
headers: {
'Authorization': `Bearer ${decryptedApiKey}`
}
});
const status = pollResponse.data?.data?.task_status;
if (status === 'success') {
taskFinished = true;
finalResult = pollResponse.data;
} else if (status === 'error' || status === 'failed') {
throw new Error(`AI Task failed with status: ${status}`);
} else {
console.log(`Polling... (status: ${status}, attempt: ${attempts})`);
}
}
if (!taskFinished) {
throw new Error('AI Task polling timed out');
}
console.log('Task finished successfully!');
// Format output to mirror the score_info.json structure
const outputArray = finalResult?.data?.results?.output || [];
const formattedScores = {};
for (const item of outputArray) {
if (item.type) {
formattedScores[item.type] = {
value: item.raw_score || item.ui_score || 0,
ui_score: item.ui_score,
raw_score: item.raw_score,
output_mask_name: (item.mask_urls && item.mask_urls.length > 0) ? item.mask_urls[0] : null
};
}
}
// Send formatted scores back to frontend
res.json({ result: { scores: formattedScores, raw_response: finalResult } });
} catch (error) { } catch (error) {
console.error('Error analyzing image:', error); console.error('Error analyzing image:', error.response ? JSON.stringify(error.response.data) : error.message);
res.status(500).json({ error: 'Failed to analyze image' }); res.status(500).json({ error: 'Failed to analyze image' });
} }
}; };
+35
View File
@@ -41,3 +41,38 @@ exports.getReport = async (req, res) => {
res.status(500).json({ success: false, message: 'Server error' }); res.status(500).json({ success: false, message: 'Server error' });
} }
}; };
exports.savePdfReport = async (req, res) => {
try {
const { phone, userName } = req.body;
if (!phone || !req.file) {
return res.status(400).json({ success: false, message: 'Missing phone or PDF file' });
}
// Ensure user exists
let user = await User.findOne({ phone });
if (!user) {
user = await User.create({ phone, name: userName || 'Unknown' });
} else if (userName && userName !== user.name) {
user.name = userName;
await user.save();
}
const reportId = Math.random().toString(36).substring(7);
const link = `/uploads/reports/${req.file.filename}`;
const report = await Report.create({
reportId,
phone,
userName: user.name,
data: { message: "PDF Report generated." }, // Minimal data to satisfy schema
link
});
res.status(201).json({ success: true, link, reportId });
} catch (error) {
console.error('Error saving PDF report:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
+25
View File
@@ -0,0 +1,25 @@
const mongoose = require('mongoose');
const configSchema = new mongoose.Schema(
{
keyName: {
type: String,
required: true,
unique: true,
},
encryptedValue: {
type: String,
required: true,
},
iv: {
type: String, // Initialization vector needed for decryption
required: true,
}
},
{
timestamps: true,
}
);
const Config = mongoose.model('Config', configSchema);
module.exports = Config;
+21 -1
View File
@@ -1,14 +1,33 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path');
const fs = require('fs');
// Configure multer for memory storage (we'll forward the buffer to the external API) // Configure multer for memory storage (we'll forward the buffer to the external API)
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
// Ensure uploads dir exists for PDF reports
const reportsDir = path.join(__dirname, '../uploads/reports');
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir, { recursive: true });
}
const diskStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, reportsDir)
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9)
cb(null, (req.body.phone || 'anonymous') + '-' + uniqueSuffix + '.pdf')
}
});
const uploadPdf = multer({ storage: diskStorage });
// Controllers // Controllers
const { analyzeImage } = require('../controllers/analysisController'); const { analyzeImage } = require('../controllers/analysisController');
const { createUser, verifyOtp } = require('../controllers/userController'); const { createUser, verifyOtp } = require('../controllers/userController');
const { createReport, getReport } = require('../controllers/reportController'); const { createReport, getReport, savePdfReport } = require('../controllers/reportController');
const { sendOtp, sendReport } = require('../controllers/whatsappController'); const { sendOtp, sendReport } = require('../controllers/whatsappController');
const { getDashboardUsers } = require('../controllers/dashboardController'); const { getDashboardUsers } = require('../controllers/dashboardController');
@@ -22,6 +41,7 @@ router.post('/verify-otp', verifyOtp);
// 3. Reports // 3. Reports
router.post('/reports', createReport); router.post('/reports', createReport);
router.get('/reports/:reportId', getReport); router.get('/reports/:reportId', getReport);
router.post('/reports/save-pdf', uploadPdf.single('pdf'), savePdfReport);
// 4. WhatsApp Integration // 4. WhatsApp Integration
router.post('/whatsapp/send-otp', sendOtp); router.post('/whatsapp/send-otp', sendOtp);
+42
View File
@@ -0,0 +1,42 @@
const mongoose = require('mongoose');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const Config = require('../models/Config');
const { encrypt } = require('../utils/crypto');
const PERFECT_CORP_API_KEY = 'sk-tHtPU8rst9cifrM6RhkjCKy9EpNfXBdcoCbzP0QDRhIkHr_InBYPS6g_6XGSumbm';
const PERFECT_CORP_SECRET_KEY = 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCKexD/nyLrGq7jXCxYi/Tjwr297cZS5EUFQbCV9NO2XQU/sJitsT2WBHHS8AYAQrzM56hPgnwpt/B+EuSuLfVbM/ympBUvSrULUNlrJF5lW96JMd1KF8RWC9fICZJuCE2iGpGAd0l9pOCUmYM4JZ/n26HxZxSSramkoOYxmhFYhQIDAQAB';
async function seedKeys() {
try {
await mongoose.connect(process.env.MONGO_URI || 'mongodb://mongo:27017/limelite');
console.log('Connected to DB');
// 1. API KEY
const encryptedApi = encrypt(PERFECT_CORP_API_KEY);
await Config.findOneAndUpdate(
{ keyName: 'PERFECT_CORP_API_KEY' },
{ encryptedValue: encryptedApi.encryptedData, iv: encryptedApi.iv },
{ upsert: true, new: true }
);
// 2. SECRET KEY
const encryptedSecret = encrypt(PERFECT_CORP_SECRET_KEY);
await Config.findOneAndUpdate(
{ keyName: 'PERFECT_CORP_SECRET_KEY' },
{ encryptedValue: encryptedSecret.encryptedData, iv: encryptedSecret.iv },
{ upsert: true, new: true }
);
console.log('Successfully encrypted and stored Perfect Corp API keys in the database.');
process.exit(0);
} catch (error) {
console.error('Error seeding keys:', error);
process.exit(1);
}
}
seedKeys();
+2
View File
@@ -2,6 +2,7 @@ const express = require('express');
const cors = require('cors'); const cors = require('cors');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const connectDB = require('./config/db'); const connectDB = require('./config/db');
const path = require('path');
// Load env vars // Load env vars
dotenv.config(); dotenv.config();
@@ -14,6 +15,7 @@ const app = express();
// Middleware // Middleware
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Routes // Routes
const apiRoutes = require('./routes/api'); const apiRoutes = require('./routes/api');
+34
View File
@@ -0,0 +1,34 @@
const crypto = require('crypto');
// The encryption key must be exactly 32 bytes for AES-256
const getEncryptionKey = () => {
const key = process.env.ENCRYPTION_KEY || 'default_secret_key_needs_32_bytes!';
// Ensure it's 32 bytes by hashing it if it's not exactly 32 bytes
if (key.length !== 32) {
return crypto.createHash('sha256').update(key).digest();
}
return Buffer.from(key);
};
const algorithm = 'aes-256-cbc';
function encrypt(text) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, getEncryptionKey(), iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return {
iv: iv.toString('hex'),
encryptedData: encrypted
};
}
function decrypt(encryptedData, ivHex) {
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(algorithm, getEncryptionKey(), iv);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
module.exports = { encrypt, decrypt };
+1
View File
@@ -19,6 +19,7 @@ RUN npm run build
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80 for Nginx # Expose port 80 for Nginx
EXPOSE 80 EXPOSE 80
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
+88 -7
View File
@@ -17,6 +17,7 @@
"face-api.js": "^0.22.2", "face-api.js": "^0.22.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"html2pdf.js": "^0.14.0",
"js-encrypt": "^2.3.4", "js-encrypt": "^2.3.4",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"jspdf": "^3.0.1", "jspdf": "^3.0.1",
@@ -2053,9 +2054,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.28.2", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -3826,6 +3827,12 @@
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -6891,11 +6898,10 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.2.6", "version": "3.4.15",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
@@ -8100,6 +8106,33 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-png/node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
@@ -9158,6 +9191,34 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/html2pdf.js": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.14.0.tgz",
"integrity": "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0",
"jspdf": "^4.0.0"
}
},
"node_modules/html2pdf.js/node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
@@ -9439,6 +9500,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -16764,6 +16831,20 @@
"is-typedarray": "^1.0.0" "is-typedarray": "^1.0.0"
} }
}, },
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
"face-api.js": "^0.22.2", "face-api.js": "^0.22.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"html2pdf.js": "^0.14.0",
"js-encrypt": "^2.3.4", "js-encrypt": "^2.3.4",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"jspdf": "^3.0.1", "jspdf": "^3.0.1",
+66 -36
View File
@@ -102,7 +102,7 @@ const AnalysisResultPage = () => {
if (!score && score !== 0) return 'bg-gray-600'; if (!score && score !== 0) return 'bg-gray-600';
if (score >= 70) return 'bg-emerald-500'; if (score >= 70) return 'bg-emerald-500';
if (score >= 40) return 'bg-yellow-500'; if (score >= 40) return 'bg-yellow-500';
return 'bg-[limelite-red]'; return 'bg-limelite-red';
}; };
const getMetricDisplayName = (metricKey) => { const getMetricDisplayName = (metricKey) => {
@@ -212,18 +212,48 @@ const AnalysisResultPage = () => {
throw new Error(errorMsg); throw new Error(errorMsg);
} }
// 2. Save Report to Backend // 2. Generate PDF and Save to Backend
await fetch(`${API_BASE_URL}/api/reports`, { let generatedPdfLink = verificationLink;
method: 'POST', if (reportRef.current) {
headers: { 'Content-Type': 'application/json' }, const reportElement = reportRef.current;
body: JSON.stringify({ reportElement.style.display = 'block';
reportId,
phone, try {
userName: formData.name, const canvas = await html2canvas(reportElement, {
data: analysisData, scale: 2,
link: verificationLink 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);
const pdfBlob = pdf.output('blob');
const fd = new FormData();
fd.append('pdf', pdfBlob, `Limelite_Report_${phone}.pdf`);
fd.append('phone', phone);
fd.append('userName', formData.name);
const saveRes = await fetch(`${API_BASE_URL}/api/reports/save-pdf`, {
method: 'POST',
body: fd
});
const saveData = await saveRes.json();
if (saveData.success) {
generatedPdfLink = `${API_BASE_URL}${saveData.link}`;
}
} catch(err) {
console.error("Failed to generate/upload PDF during save:", err);
} finally {
reportElement.style.display = 'none';
}
}
verificationLink = generatedPdfLink;
console.log(`Sending WhatsApp message request to backend for ${phone}`); console.log(`Sending WhatsApp message request to backend for ${phone}`);
@@ -469,7 +499,7 @@ const AnalysisResultPage = () => {
if (isLoading) return ( if (isLoading) return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-white/10 border-t-[limelite-red] rounded-full animate-spin"></div> <div className="w-12 h-12 border-4 border-white/10 border-t-limelite-red rounded-full animate-spin"></div>
<p className="text-gray-300 animate-pulse">Analyzing skin profile...</p> <p className="text-gray-300 animate-pulse">Analyzing skin profile...</p>
</div> </div>
</div> </div>
@@ -478,12 +508,12 @@ const AnalysisResultPage = () => {
if (error) return ( if (error) return (
<div className="min-h-screen flex items-center justify-center p-4"> <div className="min-h-screen flex items-center justify-center p-4">
<div className="glass-panel p-8 rounded-none shadow-xl max-w-md w-full text-center border border-white/10"> <div className="glass-panel p-8 rounded-none shadow-xl max-w-md w-full text-center border border-white/10">
<div className="w-16 h-16 bg-[limelite-red]/20 rounded-none flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-limelite-red/20 rounded-none flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-[limelite-red]" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg className="w-8 h-8 text-limelite-red" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div> </div>
<h2 className="text-xl font-bold text-white mb-2">Analysis Failed</h2> <h2 className="text-xl font-bold text-white mb-2">Analysis Failed</h2>
<p className="text-gray-300 mb-6">{errorDescriptions[error] || error}</p> <p className="text-gray-300 mb-6">{errorDescriptions[error] || error}</p>
<button onClick={handleNewAnalysis} className="w-full py-3 bg-[limelite-red] hover:bg-[limelite-maroon] text-white rounded-none font-bold transition-colors shadow-lg shadow-[limelite-red]/30"> <button onClick={handleNewAnalysis} className="w-full py-3 bg-limelite-red hover:bg-limelite-maroon text-white rounded-none font-bold transition-colors shadow-lg shadow-limelite-red/30">
Try Again Try Again
</button> </button>
</div> </div>
@@ -494,7 +524,7 @@ const AnalysisResultPage = () => {
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<p className="text-gray-300 mb-4">No analysis data available.</p> <p className="text-gray-300 mb-4">No analysis data available.</p>
<button onClick={handleNewAnalysis} className="px-6 py-2 bg-[limelite-red] text-white rounded-none shadow-lg">Start New Analysis</button> <button onClick={handleNewAnalysis} className="px-6 py-2 bg-limelite-red text-white rounded-none shadow-lg">Start New Analysis</button>
</div> </div>
</div> </div>
); );
@@ -530,7 +560,7 @@ const AnalysisResultPage = () => {
<div className="h-screen overflow-hidden bg-[#18181b] font-sans relative flex flex-col"> <div className="h-screen overflow-hidden bg-[#18181b] font-sans relative flex flex-col">
{/* Header (Matching App.js) */} {/* Header (Matching App.js) */}
<header className="fixed top-0 left-0 right-0 z-50 py-4 px-6 flex items-center justify-between bg-[limelite-red] shadow-lg"> <header className="fixed top-0 left-0 right-0 z-50 py-4 px-6 flex items-center justify-between bg-limelite-red shadow-lg">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="Limelite Logo" className="h-12 md:h-16 w-auto brightness-0 invert" /> <img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="Limelite Logo" className="h-12 md:h-16 w-auto brightness-0 invert" />
</div> </div>
@@ -574,7 +604,7 @@ const AnalysisResultPage = () => {
{activeMask && ( {activeMask && (
<button <button
onClick={handleClearMask} onClick={handleClearMask}
className="text-white hover:text-[limelite-red] transition-colors flex items-center mb-2" className="text-white hover:text-limelite-red transition-colors flex items-center mb-2"
title="Show original image" title="Show original image"
> >
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -608,7 +638,7 @@ const AnalysisResultPage = () => {
{/* Badge */} {/* Badge */}
<div className={`px-3 py-1 rounded-full text-[11px] font-bold uppercase min-w-[65px] text-center ${metric.ui_score >= 70 ? 'bg-emerald-500/20 text-[#4ade80] border border-emerald-500/30' : <div className={`px-3 py-1 rounded-full text-[11px] font-bold uppercase min-w-[65px] text-center ${metric.ui_score >= 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' : metric.ui_score >= 40 ? 'bg-amber-500/20 text-[#fbbf24] border border-amber-500/30' :
'bg-[limelite-red]/20 text-[limelite-red] border border-[limelite-red]/30' 'bg-limelite-red/20 text-limelite-red border border-limelite-red/30'
}`}> }`}>
{metric.ui_score >= 70 ? 'Good' : metric.ui_score >= 40 ? 'Average' : 'Bad'} {metric.ui_score >= 70 ? 'Good' : metric.ui_score >= 40 ? 'Average' : 'Bad'}
</div> </div>
@@ -631,7 +661,7 @@ const AnalysisResultPage = () => {
<div className="bg-[#18181b] px-5 py-6 flex flex-col gap-4 relative z-30 shrink-0 border-t border-white/10"> <div className="bg-[#18181b] px-5 py-6 flex flex-col gap-4 relative z-30 shrink-0 border-t border-white/10">
<button <button
onClick={() => setShowSignupModal(true)} onClick={() => setShowSignupModal(true)}
className="w-full bg-[limelite-red] hover:bg-[limelite-maroon] text-white h-14 rounded-none font-bold text-lg shadow-sm flex items-center justify-center gap-3 transition-all transform active:scale-95 relative" className="w-full bg-limelite-red hover:bg-limelite-maroon text-white h-14 rounded-none font-bold text-lg shadow-sm flex items-center justify-center gap-3 transition-all transform active:scale-95 relative"
> >
<span className="text-[1.1rem]">Access Full Report</span> <span className="text-[1.1rem]">Access Full Report</span>
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center"> <div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center">
@@ -646,7 +676,7 @@ const AnalysisResultPage = () => {
<div className="bg-[#18181b] px-5 py-6 flex flex-col gap-4 relative z-30 shrink-0 border-t border-white/10"> <div className="bg-[#18181b] px-5 py-6 flex flex-col gap-4 relative z-30 shrink-0 border-t border-white/10">
<button <button
onClick={handleDownloadPDF} onClick={handleDownloadPDF}
className="w-full bg-[limelite-red] hover:bg-[limelite-maroon] text-white h-14 rounded-none font-bold text-lg shadow-sm flex items-center justify-center gap-3 transition-all transform active:scale-95 relative" className="w-full bg-limelite-red hover:bg-limelite-maroon text-white h-14 rounded-none font-bold text-lg shadow-sm flex items-center justify-center gap-3 transition-all transform active:scale-95 relative"
> >
<span className="text-[1.1rem]">Download Report</span> <span className="text-[1.1rem]">Download Report</span>
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center"> <div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center">
@@ -680,36 +710,36 @@ const AnalysisResultPage = () => {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<label className="block text-gray-700 text-sm font-medium mb-1"> <label className="block text-gray-700 text-sm font-medium mb-1">
Full Name<span className="text-[limelite-red]">*</span> Full Name<span className="text-limelite-red">*</span>
</label> </label>
<input <input
type="text" type="text"
placeholder="Enter your First Name" placeholder="Enter your First Name"
value={formData.name} value={formData.name}
onChange={handleNameChange} onChange={handleNameChange}
className={`w-full border-b border-dashed py-2 text-base text-black placeholder-gray-400 focus:outline-none transition-colors bg-transparent ${formErrors.name ? 'border-[limelite-red]' : 'border-gray-400 focus:border-[limelite-red]'}`} className={`w-full border-b border-dashed py-2 text-base text-black placeholder-gray-400 focus:outline-none transition-colors bg-transparent ${formErrors.name ? 'border-limelite-red' : 'border-gray-400 focus:border-limelite-red'}`}
/> />
{formErrors.name && <p className="text-[limelite-red] text-xs mt-1">{formErrors.name}</p>} {formErrors.name && <p className="text-limelite-red text-xs mt-1">{formErrors.name}</p>}
</div> </div>
<div> <div>
<label className="block text-gray-700 text-sm font-medium mb-1"> <label className="block text-gray-700 text-sm font-medium mb-1">
Whatsapp Number<span className="text-[limelite-red]">*</span> Whatsapp Number<span className="text-limelite-red">*</span>
</label> </label>
<input <input
type="tel" type="tel"
placeholder="Enter your Whatsapp Number" placeholder="Enter your Whatsapp Number"
value={formData.phone} value={formData.phone}
onChange={handlePhoneChange} onChange={handlePhoneChange}
className={`w-full border-b border-dashed py-2 text-base text-black placeholder-gray-400 focus:outline-none transition-colors bg-transparent ${formErrors.phone ? 'border-[limelite-red]' : 'border-gray-400 focus:border-[limelite-red]'}`} className={`w-full border-b border-dashed py-2 text-base text-black placeholder-gray-400 focus:outline-none transition-colors bg-transparent ${formErrors.phone ? 'border-limelite-red' : 'border-gray-400 focus:border-limelite-red'}`}
/> />
{formErrors.phone && <p className="text-[limelite-red] text-[13px] mt-2 font-medium leading-tight">{formErrors.phone}</p>} {formErrors.phone && <p className="text-limelite-red text-[13px] mt-2 font-medium leading-tight">{formErrors.phone}</p>}
</div> </div>
<button <button
onClick={handleSignupSubmit} onClick={handleSignupSubmit}
disabled={isSubmittingSignup} disabled={isSubmittingSignup}
className="w-full bg-[limelite-red] hover:bg-[limelite-maroon] text-white py-3 font-semibold text-lg mt-4 transition-colors disabled:opacity-70 flex items-center justify-center h-14" className="w-full bg-limelite-red hover:bg-limelite-maroon text-white py-3 font-semibold text-lg mt-4 transition-colors disabled:opacity-70 flex items-center justify-center h-14"
> >
{isSubmittingSignup ? ( {isSubmittingSignup ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -748,7 +778,7 @@ const AnalysisResultPage = () => {
<div className="flex justify-between gap-2 mb-8"> <div className="flex justify-between gap-2 mb-8">
{otp.map((data, index) => ( {otp.map((data, index) => (
<input <input
className="w-11 h-11 border border-gray-300 rounded-md text-center text-xl focus:border-[limelite-red] focus:outline-none font-medium text-black" className="w-11 h-11 border border-gray-300 rounded-md text-center text-xl focus:border-limelite-red focus:outline-none font-medium text-black"
type="text" type="text"
name="otp" name="otp"
maxLength="1" maxLength="1"
@@ -760,12 +790,12 @@ const AnalysisResultPage = () => {
))} ))}
</div> </div>
{otpError && <p className="text-[limelite-red] text-sm mb-4 text-center">{otpError}</p>} {otpError && <p className="text-limelite-red text-sm mb-4 text-center">{otpError}</p>}
<button <button
onClick={handleVerifyOtp} onClick={handleVerifyOtp}
disabled={isVerifying} disabled={isVerifying}
className="w-full bg-[limelite-red] hover:bg-[limelite-maroon] text-white py-4 font-semibold text-lg transition-colors disabled:opacity-70 flex items-center justify-center" className="w-full bg-limelite-red hover:bg-limelite-maroon text-white py-4 font-semibold text-lg transition-colors disabled:opacity-70 flex items-center justify-center"
> >
{isVerifying ? ( {isVerifying ? (
<div className="w-6 h-6 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> <div className="w-6 h-6 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
@@ -813,9 +843,9 @@ const AnalysisResultPage = () => {
{/* Toast Notification */} {/* Toast Notification */}
{toast.show && ( {toast.show && (
<div className="fixed top-24 right-6 z-[200] animate-[fadeIn_0.3s_ease-out]"> <div className="fixed top-24 right-6 z-[200] animate-[fadeIn_0.3s_ease-out]">
<div className="bg-white text-[limelite-red] px-6 py-4 shadow-[0_10px_40px_rgba(0,0,0,0.15)] flex items-center gap-3 border-l-4 border-[limelite-red]"> <div className="bg-white text-limelite-red px-6 py-4 shadow-[0_10px_40px_rgba(0,0,0,0.15)] flex items-center gap-3 border-l-4 border-limelite-red">
<div className="bg-[limelite-red]/10 p-1.5 rounded-full"> <div className="bg-limelite-red/10 p-1.5 rounded-full">
<svg className="w-5 h-5 text-[limelite-red]" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-5 h-5 text-limelite-red" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
+41 -45
View File
@@ -61,9 +61,14 @@ const App = () => {
let intervalId; let intervalId;
if (view === 'camera' && videoRef.current && modelsLoaded) { if (view === 'camera' && videoRef.current && modelsLoaded) {
const detectskin = async () => { const detectskin = async () => {
const detections = await skinapi.detectSingleFace(videoRef.current, new skinapi.TinyFaceDetectorOptions()); if (!videoRef.current || videoRef.current.readyState < 2) return;
if (detections) setskinBox(detections.box); try {
else setskinBox(null); const detections = await skinapi.detectSingleFace(videoRef.current, new skinapi.TinyFaceDetectorOptions());
if (detections) setskinBox(detections.box);
else setskinBox(null);
} catch (e) {
console.error("Face detection error:", e);
}
}; };
intervalId = setInterval(detectskin, 300); intervalId = setInterval(detectskin, 300);
} }
@@ -332,38 +337,37 @@ const App = () => {
throw new Error(`Backend Analysis Request failed: ${e.message}`); throw new Error(`Backend Analysis Request failed: ${e.message}`);
} }
// --- Process ZIP & save analysis --- // --- Process New S2S JSON Response ---
try { try {
const maskZipUrl = result.result?.results?.[0]?.data?.[0]?.url || result.result?.url; const scores = result.result?.scores;
if (!maskZipUrl) throw new Error("No mask ZIP URL found in result"); if (!scores) throw new Error("No scores found in result from backend.");
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 originalBase64 = await blobToBase64(await fetch(previewUrl).then(r => r.blob()));
const newMaskMap = {}; const newMaskMap = {};
for (const key of Object.keys(scores)) { for (const key of Object.keys(scores)) {
const maskName = scores[key].output_mask_name; const maskUrl = scores[key].output_mask_name;
if (!maskName) continue; if (!maskUrl) continue;
const f = zip.file(`skinanalysisResult/${maskName}`) || zip.file(`masks/${maskName}`) || zip.file(maskName);
if (!f) continue; try {
const b = await f.async('blob'); // Fetch the image from S3 to cache it locally as base64
newMaskMap[maskName] = await blobToBase64(b); const imgRes = await fetch(maskUrl);
if (imgRes.ok) {
const b = await imgRes.blob();
newMaskMap[maskUrl] = await blobToBase64(b);
} else {
newMaskMap[maskUrl] = maskUrl; // Fallback to URL
}
} catch(err) {
newMaskMap[maskUrl] = maskUrl;
}
} }
const originalBase64 = await blobToBase64(await fetch(previewUrl).then(r => r.blob()));
await saveToDB('analysisData', { await saveToDB('analysisData', {
scoreData: scores, scoreData: scores,
maskImages: newMaskMap, maskImages: newMaskMap,
originalImage: originalBase64, originalImage: originalBase64,
zipDownloadUrl: maskZipUrl zipDownloadUrl: null
}); });
setAnalysisResults(scores); setAnalysisResults(scores);
@@ -395,9 +399,9 @@ const App = () => {
<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"> <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 */}
<header className="fixed top-0 left-0 right-0 z-[60] py-4 px-6 flex items-center justify-between bg-[limelite-red] shadow-lg"> <header className="fixed top-0 left-0 right-0 z-[60] py-4 px-6 flex items-center justify-between bg-transparent">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="Limelite Logo" className="h-12 md:h-16 w-auto " /> <img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="Limelite Logo" className="h-10 md:h-14 w-auto" />
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -442,7 +446,7 @@ const App = () => {
{/* Landing View */} {/* Landing View */}
{view === 'landing' && ( {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="flex flex-col items-center justify-center w-full px-4 gap-8 fade-in mt-16 md:mt-32">
<div className="text-center space-y-4 mb-2"> <div className="text-center space-y-4 mb-2">
<h1 className="text-4xl md:text-5xl font-serif italic text-white leading-tight tracking-[0.02em]"> <h1 className="text-4xl md:text-5xl font-serif italic text-white leading-tight tracking-[0.02em]">
Step into your style Step into your style
@@ -453,11 +457,11 @@ const App = () => {
</div> </div>
{/* Action Buttons Container */} {/* Action Buttons Container */}
<div className="w-full flex flex-row items-center justify-center gap-4 max-w-md mx-auto"> <div className="w-full flex flex-col sm:flex-row items-center justify-center gap-4 max-w-sm sm:max-w-md mx-auto">
{/* Take Photo Card */} {/* Take Photo Card */}
<button <button
onClick={() => setView('camera')} onClick={() => setView('camera')}
className="flex-1 bg-white text-[#1A1A1A] rounded-xl px-8 py-4 font-bold text-sm tracking-widest uppercase shadow-lg hover:bg-gray-100 transition-all text-center border-2 border-transparent" className="w-full sm:flex-1 bg-white text-[#1A1A1A] rounded-xl px-6 py-4 font-bold text-sm tracking-widest uppercase shadow-lg hover:bg-gray-100 transition-all text-center border-2 border-transparent"
> >
Take Photo Take Photo
</button> </button>
@@ -465,7 +469,7 @@ const App = () => {
{/* Upload Photo Card */} {/* Upload Photo Card */}
<button <button
onClick={() => document.getElementById('uploadInput').click()} onClick={() => document.getElementById('uploadInput').click()}
className="flex-1 bg-[#1A1A1A] text-white border border-gray-700 rounded-xl px-8 py-4 font-bold text-sm tracking-widest uppercase shadow-lg hover:bg-black transition-all text-center" className="w-full sm:flex-1 bg-[#1A1A1A] text-white border border-gray-700 rounded-xl px-6 py-4 font-bold text-sm tracking-widest uppercase shadow-lg hover:bg-black transition-all text-center"
> >
Upload Photo Upload Photo
</button> </button>
@@ -478,15 +482,7 @@ const App = () => {
/> />
</div> </div>
{/* Admin Link */}
<div className="mt-8">
<button
onClick={() => navigate('/login')}
className="text-gray-400 hover:text-[limelite-red] transition-colors text-xs uppercase tracking-widest font-bold"
>
Admin Login
</button>
</div>
</div> </div>
)} )}
{ {
@@ -504,7 +500,7 @@ const App = () => {
<div className="absolute bottom-12 left-0 right-0 flex justify-center items-center z-20"> <div className="absolute bottom-12 left-0 right-0 flex justify-center items-center z-20">
<button <button
onClick={handleCaptureskin} onClick={handleCaptureskin}
className="transform active:scale-95 transition-all cursor-pointer bg-[limelite-red] 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" className="transform active:scale-95 transition-all cursor-pointer bg-limelite-red 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" /> <IoMdAperture className="w-12 h-12 text-white" />
</button> </button>
@@ -528,8 +524,8 @@ const App = () => {
{/* Scanning Animation only when loading */} {/* Scanning Animation only when loading */}
{isLoading && ( {isLoading && (
<div className="absolute inset-0 z-20 pointer-events-none overflow-hidden"> <div className="absolute inset-0 z-20 pointer-events-none overflow-hidden">
<div className="w-full h-[2px] bg-[limelite-red] shadow-[0_0_20px_rgba(0,151,195,0.8)] absolute top-0 animate-[scan_3s_linear_infinite]"></div> <div className="w-full h-[2px] bg-limelite-red 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-[limelite-red]/20 to-transparent absolute top-0 -translate-y-full animate-[scanTrace_3s_linear_infinite]"></div> <div className="w-full h-32 bg-gradient-to-t from-limelite-red/20 to-transparent absolute top-0 -translate-y-full animate-[scanTrace_3s_linear_infinite]"></div>
</div> </div>
)} )}
@@ -553,7 +549,7 @@ const App = () => {
<button <button
onClick={handleSubmitClick} onClick={handleSubmitClick}
disabled={isLoading} disabled={isLoading}
className="h-14 w-20 bg-[limelite-red] text-white rounded-none flex items-center justify-center shadow-lg hover:bg-[limelite-maroon] transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed" className="h-14 w-20 bg-limelite-red text-white rounded-none flex items-center justify-center shadow-lg hover:bg-limelite-maroon transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Confirm" 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"> <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -573,7 +569,7 @@ const App = () => {
<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="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="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"> <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-[limelite-red]" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-8 h-8 text-limelite-red" 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" /> <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> </svg>
</div> </div>
@@ -586,7 +582,7 @@ const App = () => {
</p> </p>
<button <button
onClick={handleRetakeClick} onClick={handleRetakeClick}
className="w-full bg-[limelite-red] text-white font-bold py-4 px-6 rounded-none hover:bg-[limelite-maroon] transition-all active:scale-95 uppercase tracking-widest text-sm" className="w-full bg-limelite-red text-white font-bold py-4 px-6 rounded-none hover:bg-limelite-maroon transition-all active:scale-95 uppercase tracking-widest text-sm"
> >
Retake Photo Retake Photo
</button> </button>
+10 -10
View File
@@ -24,7 +24,7 @@ const DashboardPage = () => {
const handleLogout = () => { const handleLogout = () => {
// Here you would clear any persisted auth state if it existed // Here you would clear any persisted auth state if it existed
navigate('/login'); navigate('/admin');
}; };
const [showSupportModal, setShowSupportModal] = useState(false); const [showSupportModal, setShowSupportModal] = useState(false);
const [activeTab, setActiveTab] = useState('reports'); const [activeTab, setActiveTab] = useState('reports');
@@ -101,7 +101,7 @@ const DashboardPage = () => {
return ( return (
<div className="flex h-screen bg-[#F8F9FA] font-sans"> <div className="flex h-screen bg-[#F8F9FA] font-sans">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-64 bg-[limelite-red] text-white flex flex-col shrink-0"> <aside className="w-64 bg-limelite-red text-white flex flex-col shrink-0">
<div className="p-6 mb-4"> <div className="p-6 mb-4">
<img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="LIMELITE" className="h-10 w-auto brightness-0 invert" /> <img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="LIMELITE" className="h-10 w-auto brightness-0 invert" />
</div> </div>
@@ -109,7 +109,7 @@ const DashboardPage = () => {
<nav className="flex-1 px-0 mt-4"> <nav className="flex-1 px-0 mt-4">
<div <div
onClick={() => setActiveTab('reports')} onClick={() => setActiveTab('reports')}
className={`flex items-center gap-3 px-6 py-4 cursor-pointer transition-colors ${activeTab === 'reports' ? 'bg-white text-[limelite-red]' : 'text-white hover:bg-white/10'}`} className={`flex items-center gap-3 px-6 py-4 cursor-pointer transition-colors ${activeTab === 'reports' ? 'bg-white text-limelite-red' : 'text-white hover:bg-white/10'}`}
> >
<HiOutlineDocumentText className="text-xl" /> <HiOutlineDocumentText className="text-xl" />
<span className="font-bold uppercase tracking-wider text-xs">Reports</span> <span className="font-bold uppercase tracking-wider text-xs">Reports</span>
@@ -126,14 +126,14 @@ const DashboardPage = () => {
<div className="px-0 space-y-0 mb-6 border-t border-white/10"> <div className="px-0 space-y-0 mb-6 border-t border-white/10">
<div <div
onClick={() => setActiveTab('settings')} onClick={() => setActiveTab('settings')}
className={`flex items-center gap-3 px-6 py-4 cursor-pointer transition-colors ${activeTab === 'settings' ? 'bg-white text-[limelite-red]' : 'text-white hover:bg-white/10'}`} className={`flex items-center gap-3 px-6 py-4 cursor-pointer transition-colors ${activeTab === 'settings' ? 'bg-white text-limelite-red' : 'text-white hover:bg-white/10'}`}
> >
<FiSettings className="text-xl" /> <FiSettings className="text-xl" />
<span className="font-bold uppercase tracking-wider text-xs">Settings</span> <span className="font-bold uppercase tracking-wider text-xs">Settings</span>
</div> </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="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="flex items-center gap-3 w-full">
<div className="w-10 h-10 bg-[limelite-maroon] rounded-full flex items-center justify-center overflow-hidden border border-white/30 text-white font-bold text-sm"> <div className="w-10 h-10 bg-limelite-maroon rounded-full flex items-center justify-center overflow-hidden border border-white/30 text-white font-bold text-sm">
LA LA
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -166,7 +166,7 @@ const DashboardPage = () => {
<input <input
type="text" type="text"
placeholder="Search" 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-[limelite-red] text-sm text-gray-900" 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-limelite-red text-sm text-gray-900"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
@@ -199,7 +199,7 @@ const DashboardPage = () => {
<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 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 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-gray-500">{report.phone}</td>
<td className="px-6 py-4 text-sm text-[limelite-red] font-semibold truncate max-w-[200px]"> <td className="px-6 py-4 text-sm text-limelite-red font-semibold truncate max-w-[200px]">
<a href={report.link} target="_blank" rel="noopener noreferrer" className="hover:underline"> <a href={report.link} target="_blank" rel="noopener noreferrer" className="hover:underline">
{report.link ? report.link.substring(report.link.indexOf('result')) : '-'} {report.link ? report.link.substring(report.link.indexOf('result')) : '-'}
</a> </a>
@@ -244,7 +244,7 @@ const DashboardPage = () => {
<label className="text-sm font-bold text-gray-700 uppercase">Site Name</label> <label className="text-sm font-bold text-gray-700 uppercase">Site Name</label>
<input <input
type="text" type="text"
className="col-span-2 border-b-2 py-2 focus:outline-none focus:border-[limelite-red] transition-colors" className="col-span-2 border-b-2 py-2 focus:outline-none focus:border-limelite-red transition-colors"
value={settings.siteName} value={settings.siteName}
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })} onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
/> />
@@ -280,14 +280,14 @@ const DashboardPage = () => {
</div> </div>
<button <button
onClick={() => setSettings({ ...settings, autoArchive: !settings.autoArchive })} onClick={() => setSettings({ ...settings, autoArchive: !settings.autoArchive })}
className={`w-12 h-6 rounded-full transition-colors relative ${settings.autoArchive ? 'bg-[limelite-red]' : 'bg-gray-300'}`} className={`w-12 h-6 rounded-full transition-colors relative ${settings.autoArchive ? 'bg-limelite-red' : '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> <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> </button>
</div> </div>
<div className="pt-6"> <div className="pt-6">
<button className="bg-[limelite-red] text-white px-8 py-3 font-bold uppercase tracking-widest hover:bg-[limelite-maroon] transition-colors"> <button className="bg-limelite-red text-white px-8 py-3 font-bold uppercase tracking-widest hover:bg-limelite-maroon transition-colors">
Save Changes Save Changes
</button> </button>
</div> </div>
+6 -6
View File
@@ -13,7 +13,7 @@ const LoginPage = () => {
setError(''); setError('');
if (credentials.email === 'admin@limelite.com' && credentials.password === 'admin@2026') { if (credentials.email === 'admin@limelite.com' && credentials.password === 'admin@2026') {
navigate('/dashboard'); navigate('/admin/dashboard');
} else { } else {
setError('Incorrect admin credentials'); setError('Incorrect admin credentials');
} }
@@ -23,12 +23,12 @@ const LoginPage = () => {
<div className="min-h-screen bg-[#18181b] flex items-center justify-center p-4 font-sans"> <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="bg-white w-full max-w-md p-10 shadow-2xl rounded-none">
<div className="flex flex-col items-center mb-10"> <div className="flex flex-col items-center mb-10">
<div className="bg-[limelite-red] p-4 mb-6"> <div className="bg-limelite-red p-4 mb-6">
<img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="Limelite" className="h-8 brightness-0 invert" /> <img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="Limelite" className="h-8 brightness-0 invert" />
</div> </div>
</div> </div>
{error && <p className="text-[limelite-red] text-sm font-bold text-center mb-6 animate-shake">{error}</p>} {error && <p className="text-limelite-red text-sm font-bold text-center mb-6 animate-shake">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
@@ -36,7 +36,7 @@ const LoginPage = () => {
<input <input
type="email" type="email"
required required
className="w-full border-b-2 border-gray-200 py-3 focus:outline-none focus:border-[limelite-red] transition-colors text-black" className="w-full border-b-2 border-gray-200 py-3 focus:outline-none focus:border-limelite-red transition-colors text-black"
placeholder="olivia@limelite.com" placeholder="olivia@limelite.com"
value={credentials.email} value={credentials.email}
onChange={(e) => setCredentials({ ...credentials, email: e.target.value })} onChange={(e) => setCredentials({ ...credentials, email: e.target.value })}
@@ -48,7 +48,7 @@ const LoginPage = () => {
<input <input
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
required required
className="w-full border-b-2 border-gray-200 py-3 pr-10 focus:outline-none focus:border-[limelite-red] transition-colors text-black" className="w-full border-b-2 border-gray-200 py-3 pr-10 focus:outline-none focus:border-limelite-red transition-colors text-black"
placeholder="••••••••" placeholder="••••••••"
value={credentials.password} value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })} onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
@@ -64,7 +64,7 @@ const LoginPage = () => {
</div> </div>
<button <button
type="submit" type="submit"
className="w-full bg-[limelite-red] text-white py-4 font-bold uppercase tracking-widest hover:bg-[limelite-maroon] transition-colors mt-4" className="w-full bg-limelite-red text-white py-4 font-bold uppercase tracking-widest hover:bg-limelite-maroon transition-colors mt-4"
> >
Login Login
</button> </button>
+2 -2
View File
@@ -16,8 +16,8 @@ root.render(
<Routes> <Routes>
<Route path='/' element={<App />} /> <Route path='/' element={<App />} />
<Route path='/result' element={<AnalysisResultPage />} /> <Route path='/result' element={<AnalysisResultPage />} />
<Route path='/login' element={<LoginPage />} /> <Route path='/admin' element={<LoginPage />} />
<Route path='/dashboard' element={<DashboardPage />} /> <Route path='/admin/dashboard' element={<DashboardPage />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+16
View File
@@ -0,0 +1,16 @@
const fs = require('fs');
const files = [
'd:/Projects/Limelite-Face_ANalyzer/frontend/src/AnalysisResultPage.jsx',
'd:/Projects/Limelite-Face_ANalyzer/frontend/src/App.js',
'd:/Projects/Limelite-Face_ANalyzer/frontend/src/DashboardPage.jsx',
'd:/Projects/Limelite-Face_ANalyzer/frontend/src/LoginPage.jsx'
];
files.forEach(file => {
let content = fs.readFileSync(file, 'utf8');
content = content.replace(/\[limelite-red\]/g, 'limelite-red');
content = content.replace(/\[limelite-maroon\]/g, 'limelite-maroon');
content = content.replace(/\[limelite-gold\]/g, 'limelite-gold');
fs.writeFileSync(file, content);
console.log(`Updated ${file}`);
});