feat: setup S2S integration, tailwind fixes, admin routing, nginx fallback
This commit is contained in:
@@ -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, {
|
|
||||||
headers: {
|
|
||||||
...formData.getHeaders(),
|
|
||||||
'Authorization': `Bearer ${process.env.PERFECT_CORP_API_KEY}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return res.json({ result: response.data });
|
|
||||||
*/
|
|
||||||
|
|
||||||
// MOCK RESPONSE
|
const baseUrl = process.env.PERFECT_CORP_API_URL || 'https://yce-api-01.makeupar.com/s2s/v2.0';
|
||||||
console.log('Mocking perfect corp analysis for image:', req.file.originalname);
|
|
||||||
|
// Step 1: Initialize Upload
|
||||||
const mockResult = {
|
console.log('Step 1: Initializing Upload...');
|
||||||
results: [
|
const initPayload = {
|
||||||
|
files: [
|
||||||
{
|
{
|
||||||
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' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,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');
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+88
-7
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+45
-49
@@ -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 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()));
|
const originalBase64 = await blobToBase64(await fetch(previewUrl).then(r => r.blob()));
|
||||||
|
|
||||||
|
const newMaskMap = {};
|
||||||
|
for (const key of Object.keys(scores)) {
|
||||||
|
const maskUrl = scores[key].output_mask_name;
|
||||||
|
if (!maskUrl) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the image from S3 to cache it locally as base64
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user