diff --git a/backend/controllers/analysisController.js b/backend/controllers/analysisController.js index 2565802..6884794 100644 --- a/backend/controllers/analysisController.js +++ b/backend/controllers/analysisController.js @@ -1,5 +1,7 @@ const axios = require('axios'); const FormData = require('form-data'); +const Config = require('../models/Config'); +const { decrypt } = require('../utils/crypto'); exports.analyzeImage = async (req, res) => { try { @@ -7,50 +9,128 @@ exports.analyzeImage = async (req, res) => { return res.status(400).json({ error: 'No image provided' }); } - // In a real scenario, we would forward this to the Perfect Corp API - // For now, we will return a mock response matching the expected structure - // or you could proxy it like this: - /* - const formData = new FormData(); - formData.append('image', req.file.buffer, req.file.originalname); - - 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 }); - */ + // Fetch API Key from DB + const apiKeyConfig = await Config.findOne({ keyName: 'PERFECT_CORP_API_KEY' }); + if (!apiKeyConfig) { + return res.status(500).json({ error: 'API Key configuration missing' }); + } + const decryptedApiKey = decrypt(apiKeyConfig.encryptedValue, apiKeyConfig.iv); - // MOCK RESPONSE - console.log('Mocking perfect corp analysis for image:', req.file.originalname); - - const mockResult = { - results: [ + const baseUrl = process.env.PERFECT_CORP_API_URL || 'https://yce-api-01.makeupar.com/s2s/v2.0'; + + // Step 1: Initialize Upload + console.log('Step 1: Initializing Upload...'); + const initPayload = { + files: [ { - data: [ - { - url: 'https://example.com/mock-mask.zip' - } - ] + content_type: req.file.mimetype || 'image/jpeg', + file_name: req.file.originalname || 'image.jpg', + 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) { - 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' }); } }; diff --git a/backend/controllers/reportController.js b/backend/controllers/reportController.js index e8eb686..3103181 100644 --- a/backend/controllers/reportController.js +++ b/backend/controllers/reportController.js @@ -41,3 +41,38 @@ exports.getReport = async (req, res) => { 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' }); + } +}; diff --git a/backend/models/Config.js b/backend/models/Config.js new file mode 100644 index 0000000..1509568 --- /dev/null +++ b/backend/models/Config.js @@ -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; diff --git a/backend/routes/api.js b/backend/routes/api.js index 471b303..5cbc42f 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -1,14 +1,33 @@ const express = require('express'); const router = express.Router(); 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) 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 const { analyzeImage } = require('../controllers/analysisController'); 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 { getDashboardUsers } = require('../controllers/dashboardController'); @@ -22,6 +41,7 @@ router.post('/verify-otp', verifyOtp); // 3. Reports router.post('/reports', createReport); router.get('/reports/:reportId', getReport); +router.post('/reports/save-pdf', uploadPdf.single('pdf'), savePdfReport); // 4. WhatsApp Integration router.post('/whatsapp/send-otp', sendOtp); diff --git a/backend/scripts/seedKeys.js b/backend/scripts/seedKeys.js new file mode 100644 index 0000000..93dc15c --- /dev/null +++ b/backend/scripts/seedKeys.js @@ -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(); diff --git a/backend/server.js b/backend/server.js index 396abf3..ad02a7e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2,6 +2,7 @@ const express = require('express'); const cors = require('cors'); const dotenv = require('dotenv'); const connectDB = require('./config/db'); +const path = require('path'); // Load env vars dotenv.config(); @@ -14,6 +15,7 @@ const app = express(); // Middleware app.use(cors()); app.use(express.json()); +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); // Routes const apiRoutes = require('./routes/api'); diff --git a/backend/utils/crypto.js b/backend/utils/crypto.js new file mode 100644 index 0000000..927044b --- /dev/null +++ b/backend/utils/crypto.js @@ -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 }; diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 683e6f5..3e0bd1f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -19,6 +19,7 @@ RUN npm run build FROM nginx:alpine COPY --from=build /app/build /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf # Expose port 80 for Nginx EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..2342fac --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 00875ec..c9c3385 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "face-api.js": "^0.22.2", "file-saver": "^2.0.5", "html2canvas": "^1.4.1", + "html2pdf.js": "^0.14.0", "js-encrypt": "^2.3.4", "jsencrypt": "^3.5.4", "jspdf": "^3.0.1", @@ -2053,9 +2054,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", - "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3826,6 +3827,12 @@ "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -6891,11 +6898,10 @@ } }, "node_modules/dompurify": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", - "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -8100,6 +8106,33 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "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": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -9158,6 +9191,34 @@ "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": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -9439,6 +9500,12 @@ "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": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", @@ -16764,6 +16831,20 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 23c2106..c604ee2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "face-api.js": "^0.22.2", "file-saver": "^2.0.5", "html2canvas": "^1.4.1", + "html2pdf.js": "^0.14.0", "js-encrypt": "^2.3.4", "jsencrypt": "^3.5.4", "jspdf": "^3.0.1", diff --git a/frontend/src/AnalysisResultPage.jsx b/frontend/src/AnalysisResultPage.jsx index d1dae42..28af8f5 100644 --- a/frontend/src/AnalysisResultPage.jsx +++ b/frontend/src/AnalysisResultPage.jsx @@ -102,7 +102,7 @@ const AnalysisResultPage = () => { if (!score && score !== 0) return 'bg-gray-600'; if (score >= 70) return 'bg-emerald-500'; if (score >= 40) return 'bg-yellow-500'; - return 'bg-[limelite-red]'; + return 'bg-limelite-red'; }; const getMetricDisplayName = (metricKey) => { @@ -212,18 +212,48 @@ const AnalysisResultPage = () => { throw new Error(errorMsg); } - // 2. Save Report to Backend - await fetch(`${API_BASE_URL}/api/reports`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - reportId, - phone, - userName: formData.name, - data: analysisData, - link: verificationLink - }) - }); + // 2. Generate PDF and Save to Backend + let generatedPdfLink = verificationLink; + if (reportRef.current) { + const reportElement = reportRef.current; + reportElement.style.display = 'block'; + + try { + const canvas = await html2canvas(reportElement, { + scale: 2, + useCORS: true, + allowTaint: true, + backgroundColor: '#ffffff' + }); + const imgData = canvas.toDataURL('image/png'); + const pdf = new jsPDF('p', 'mm', 'a4'); + const pdfWidth = pdf.internal.pageSize.getWidth(); + const pdfHeight = (canvas.height * pdfWidth) / canvas.width; + pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight); + 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}`); @@ -469,7 +499,7 @@ const AnalysisResultPage = () => { if (isLoading) return (
Analyzing skin profile...
{errorDescriptions[error] || error}
-No analysis data available.
-