Initial commit: LinkedIn Analyzer with Gemini 2.5 Flash and PHP Backend

This commit is contained in:
Hamza-Ayed
2026-05-17 01:28:17 +03:00
commit 8445affc78
18 changed files with 3323 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# PHP Dependencies
server/vendor/
server/composer.lock
# macOS
.DS_Store
# IDEs
.idea/
.vscode/
# Logs
*.log

158
README.md Normal file
View File

@@ -0,0 +1,158 @@
# 🔍 LinkedIn Job Analyzer
### AI-powered job analysis extension for Microsoft Edge & Chrome
> **Personal use only.** Powered by Gemini 2.0 Flash Lite (free tier — 1,000 requests/day).
---
## ✨ What It Does
When you open any LinkedIn job page, the extension automatically reads the job and gives you:
| Tab | What you get |
|-----|-------------|
| 📊 **Analysis** | Match score (X/100), strong matches, gaps, recommendation, best CV variant |
| ✉️ **Cover Letter** | Complete, personalized cover letter ready to send |
| 📝 **CV Tips** | Tailored headline, summary, keywords to add, what to emphasize |
| ❓ **Q&A** | Answers to application questions (Easy Apply form) |
| ⭐ **Benefits** | Perks, work arrangement, growth potential, red flags |
---
## 🚀 Installation (5 minutes)
### Step 1 — Get a Free Gemini API Key
1. Open: **https://aistudio.google.com/apikey**
2. Sign in with any Google/Gmail account
3. Click **"Create API Key"** → **"Create API key in new project"**
4. Copy the key (looks like: `AIzaSy...39 characters`)
5. **No credit card needed** — completely free
> Free tier: **1,000 requests/day** — more than enough for personal job search.
### Step 2 — Install the Extension in Edge
1. Open Microsoft Edge
2. In the address bar, type: **`edge://extensions`** and press Enter
3. Toggle **"Developer mode"** ON (bottom-left corner)
4. Click **"Load unpacked"**
5. Select this folder: `/path/to/linkedin-analyzer/`
6. The 🔍 icon will appear in your toolbar
**For Chrome:** Go to `chrome://extensions` instead — same steps.
### Step 3 — Configure the Extension
1. Click the **🔍 icon** in your toolbar
2. Paste your **Gemini API Key** in the field
3. Click **"Test"** to verify it works (should show ✅ Valid)
4. Review your **profile** — it's pre-filled with your data, edit if needed
5. Click **"Save Settings"**
---
## 📖 How to Use
### Automatic Mode (recommended)
1. Go to any LinkedIn job page: `linkedin.com/jobs/view/...`
2. Wait 1-2 seconds — a **🔍 purple button** appears in the bottom-right corner
3. Click it to open the analysis panel
4. You'll see the job title and company already extracted
5. Click **"⚡ Analyze This Job"**
6. Switch between tabs to see all results
7. Click **📋 Copy** to copy any result to clipboard
### Manual Mode
If auto-detection doesn't work (e.g. on job aggregators):
1. Open the panel
2. Click **"📋 Paste job description manually"**
3. Paste the full job description text
4. Click **"Use this text"**
5. Click **"⚡ Analyze This Job"**
### Q&A Auto-fill
When Easy Apply is open and you're on the Q&A tab:
- After generating answers, a **"📝 Auto-fill Answers"** button appears
- Click it to automatically fill the form fields
- Always review before submitting!
---
## ⚙️ Settings Reference
| Setting | Description |
|---------|-------------|
| **API Key** | Your Gemini API key from aistudio.google.com |
| **Test Key** | Verifies the key works with a live API call |
| **Profile** | Your CV/experience text — used in all AI prompts |
| **Reset Profile** | Restores the default pre-filled profile |
| **Language** | Auto (matches job language), Always English, Always Arabic |
| **Daily Usage** | Shows how many of your 1,000 daily requests you've used |
| **Clear Cache** | Removes stored analysis results (forces re-analysis) |
| **Clear All Data** | Removes everything including API key |
---
## 💡 Tips & Tricks
- **Results are cached for 24 hours** — revisiting the same job doesn't use quota
- **Drag the panel** by clicking and holding the header
- **Minimize** with the `` button — the 🔍 toggle stays visible
- If LinkedIn **updates its layout**, use Manual Mode as fallback
- The **cover letter** is written in the job's language (Arabic or English) by default
- For the **best results**, keep your profile text detailed and up to date
---
## 🔒 Privacy & Security
- Your **API key is stored locally** in the browser extension storage — never sent anywhere except Google's Gemini API
- Your **profile data stays local** — only sent to Gemini when you click Analyze
- **No analytics**, no tracking, no external servers
- The extension only runs on `linkedin.com/jobs/*` pages
---
## ❓ Troubleshooting
| Problem | Solution |
|---------|----------|
| Panel doesn't appear | Wait 2-3 seconds after page loads, then scroll the page slightly |
| Job not detected | Use Manual Mode — paste the job description |
| "Daily limit reached" | Wait until midnight Pacific Time (PT) for quota reset |
| "Invalid API key" | Re-check the key in settings and click Test |
| Analysis is wrong | Edit your profile in settings to be more accurate |
| Panel overlaps content | Drag it to a different position |
---
## 📁 File Structure
```
linkedin-analyzer/
├── manifest.json # Extension configuration
├── content.js # Page scraper + overlay logic
├── overlay.css # Dark luxury UI styles
├── background.js # Gemini API calls + caching
├── popup.html # Settings page UI
├── popup.js # Settings logic
├── profile.js # Default profile data
├── icons/
│ ├── icon16.png
│ ├── icon48.png
│ └── icon128.png
└── README.md # This file
```
---
## 🔄 Updating
When LinkedIn changes its layout (happens 2-4x per year):
1. The DOM selectors in `content.js``extractJobData()` may need updating
2. Use browser DevTools (F12) on a job page to find new selectors
3. Manual Mode always works as fallback
---
*Built for personal job search use. Not affiliated with LinkedIn or Google.*

176
background.js Normal file
View File

@@ -0,0 +1,176 @@
// background.js — Service Worker
// Handles all Gemini API calls (avoids CORS issues from content scripts)
const GEMINI_MODEL = 'gemini-2.5-flash';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
// ─── Message listener ────────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GEMINI_REQUEST') {
handleGeminiRequest(message.payload)
.then(result => sendResponse({ success: true, data: result }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true; // Keep message channel open for async
}
});
// ─── Core API call ───────────────────────────────────────────────────────────
async function handleGeminiRequest({ apiKey, prompt, tab }) {
// Rate limit check
const canProceed = await checkRateLimit();
if (!canProceed) {
throw new Error('Daily limit reached (1,000 requests). Resets at midnight PT.');
}
// Check cache (keyed by tab + prompt hash — different jobs produce different hashes)
const cacheKey = `cache_${tab}_${hashString(prompt)}`;
const cached = await getCached(cacheKey);
if (cached) {
return { text: cached, fromCache: true };
}
// Truncate prompt if too long (free tier has strict TPM limits)
const maxPromptChars = 6000;
const trimmedPrompt = prompt.length > maxPromptChars
? prompt.substring(0, maxPromptChars) + '\n\n[Description truncated for length]'
: prompt;
// Retry logic (up to 3 attempts with LONG backoff for free tier)
const MAX_RETRIES = 3;
const RETRY_DELAYS = [2000, 20000, 30000]; // 2s, 20s, 30s
let lastError = '';
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
await delay(RETRY_DELAYS[attempt]);
try {
const response = await fetch(`${GEMINI_URL}?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: trimmedPrompt }] }],
generationConfig: {
temperature: 0.7,
maxOutputTokens: 2048,
topP: 0.9
}
})
});
if (response.ok) {
const data = await response.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) {
lastError = 'Empty response from Gemini.';
continue;
}
await incrementUsage();
await setCached(cacheKey, text);
return { text, fromCache: false };
}
const status = response.status;
const errData = await response.json().catch(() => ({}));
const errMsg = errData.error?.message || '';
if (status === 429 || status === 503) {
lastError = `API quota limit (${status}): ${errMsg || 'Too many tokens'}. Waiting before retry...`;
continue;
} else if (status === 400) {
throw new Error('Request error: ' + (errMsg || 'Bad request'));
} else if (status === 403) {
throw new Error('Access denied. Check your API key in settings.');
} else {
throw new Error(`API Error (${status}): ${errMsg || 'Unknown'}`);
}
} catch (e) {
if (e.message.startsWith('Request error') || e.message.startsWith('Access denied') || e.message.startsWith('API Error')) {
throw e;
}
lastError = e.message || 'Network error';
if (attempt === MAX_RETRIES - 1) throw new Error(`Failed after ${MAX_RETRIES} attempts: ${lastError}`);
}
}
throw new Error(`API busy. ${lastError}. Please wait 1-2 minutes and try again.`);
}
// ─── Rate Limiting ───────────────────────────────────────────────────────────
async function checkRateLimit() {
const today = new Date().toDateString();
const data = await storageGet(['usageDate', 'usageCount']);
if (data.usageDate !== today) return true; // New day, reset
return (data.usageCount || 0) < 1000;
}
async function incrementUsage() {
const today = new Date().toDateString();
const data = await storageGet(['usageDate', 'usageCount']);
if (data.usageDate !== today) {
await storageSet({ usageDate: today, usageCount: 1 });
} else {
await storageSet({ usageCount: (data.usageCount || 0) + 1 });
}
}
// ─── Cache ───────────────────────────────────────────────────────────────────
async function getCached(key) {
const data = await storageGet(['analysisCache']);
const cache = data.analysisCache || {};
const entry = cache[key];
if (!entry) return null;
// Expire after 24 hours
if (Date.now() - entry.timestamp > 24 * 60 * 60 * 1000) {
return null;
}
return entry.value;
}
async function setCached(key, value) {
const data = await storageGet(['analysisCache']);
const cache = data.analysisCache || {};
// Keep max 50 entries
const keys = Object.keys(cache);
if (keys.length >= 50) {
// Remove oldest
const oldest = keys.sort((a, b) => cache[a].timestamp - cache[b].timestamp)[0];
delete cache[oldest];
}
cache[key] = { value, timestamp: Date.now() };
await storageSet({ analysisCache: cache });
}
// ─── Utilities ───────────────────────────────────────────────────────────────
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function hashString(str) {
let hash = 0;
for (let i = 0; i < Math.min(str.length, 200); i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
function storageGet(keys) {
return new Promise(resolve => chrome.storage.local.get(keys, resolve));
}
function storageSet(data) {
return new Promise(resolve => chrome.storage.local.set(data, resolve));
}

803
content.js Normal file
View File

@@ -0,0 +1,803 @@
// content.js — LinkedIn page scraper + overlay launcher
(function () {
'use strict';
// Prevent double injection
if (window.__linkedinAnalyzerLoaded) return;
window.__linkedinAnalyzerLoaded = true;
// ─── Job Data Extraction ─────────────────────────────────────────────────
function extractJobData() {
const data = {
jobTitle: '',
company: '',
location: '',
jobType: '',
seniority: '',
description: '',
skills: [],
questions: [],
url: window.location.href,
detectedAt: Date.now()
};
// ── Helper: try selectors until one works
function trySelectors(selectors, minLength = 1) {
for (const sel of selectors) {
try {
const el = document.querySelector(sel);
if (el && el.textContent.trim().length >= minLength) {
return el.textContent.trim();
}
} catch(e) { /* skip invalid selectors */ }
}
return '';
}
function trySelectorsInner(selectors, minLength = 1) {
for (const sel of selectors) {
try {
const el = document.querySelector(sel);
if (el && el.innerText.trim().length >= minLength) {
return el.innerText.trim();
}
} catch(e) {}
}
return '';
}
// ── Job Title (many fallbacks)
data.jobTitle = trySelectors([
'.job-details-jobs-unified-top-card__job-title h1',
'.job-details-jobs-unified-top-card__job-title a',
'.job-details-jobs-unified-top-card__job-title',
'.jobs-unified-top-card__job-title h1',
'.jobs-unified-top-card__job-title a',
'.jobs-unified-top-card__job-title',
'h1.t-24',
'h1.t-18',
'h1[class*="job-title"]',
'h2[class*="job-title"]',
'.t-24.t-bold',
'.top-card-layout__title',
'a[class*="topcard__title"]',
'.topcard__title',
]);
// Fallback: find any h1 in the job details area
if (!data.jobTitle) {
const jobPanel = document.querySelector('.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout');
if (jobPanel) {
const h1 = jobPanel.querySelector('h1');
if (h1 && h1.textContent.trim()) data.jobTitle = h1.textContent.trim();
}
}
// Last resort: any bold h2 in main area
if (!data.jobTitle) {
const allH2 = document.querySelectorAll('h2');
for (const h of allH2) {
const text = h.textContent.trim();
if (text.length > 4 && text.length < 100 && !text.match(/jobs|people|similar|featured/i)) {
data.jobTitle = text;
break;
}
}
}
// ── Company Name
data.company = trySelectors([
'.job-details-jobs-unified-top-card__company-name a',
'.job-details-jobs-unified-top-card__company-name',
'.jobs-unified-top-card__company-name a',
'.jobs-unified-top-card__company-name',
'.topcard__org-name-link',
'.top-card-layout__card-subtitle a',
'.top-card-layout__second-subline a',
'a[data-tracking-control-name*="company"]',
'a[href*="/company/"]',
'.jobs-poster__name',
]);
// Fallback: look for company link in job details panel
if (!data.company) {
const jobPanel = document.querySelector('.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout');
if (jobPanel) {
const companyLink = jobPanel.querySelector('a[href*="/company/"]');
if (companyLink) data.company = companyLink.textContent.trim();
}
}
// ── Location + metadata
const metaText = trySelectors([
'.job-details-jobs-unified-top-card__primary-description-container',
'.job-details-jobs-unified-top-card__primary-description',
'.jobs-unified-top-card__primary-description',
'.jobs-unified-top-card__subtitle',
'.top-card-layout__card-subtitle',
'.topcard__flavor--bullet',
]);
if (metaText) {
const parts = metaText.split('·').map(p => p.trim()).filter(Boolean);
if (parts.length > 0) data.location = parts[0];
}
// ── Job Type & Seniority from pills/tags
const tagSelectors = [
'.job-details-jobs-unified-top-card__job-insight span',
'.jobs-unified-top-card__job-insight span',
'.ui-label',
'.job-criteria__text',
'li.job-criteria__item',
];
for (const sel of tagSelectors) {
document.querySelectorAll(sel).forEach(el => {
const text = el.textContent.trim();
if (text.match(/full.time|part.time|contract|freelance|internship|temporary/i)) data.jobType = text;
if (text.match(/entry|associate|mid.senior|senior|director|executive|manager|intern/i)) data.seniority = text;
});
}
// Fallback: look for pill-style elements near the top
if (!data.jobType) {
document.querySelectorAll('span, li').forEach(el => {
const text = el.textContent.trim();
if (text === 'Full-time' || text === 'Part-time' || text === 'Contract' || text === 'Hybrid' ||
text === 'On-site' || text === 'Remote') {
if (!data.jobType) data.jobType = text;
if (!data.location && (text === 'Hybrid' || text === 'On-site' || text === 'Remote')) {
data.jobType = text;
}
}
});
}
// ── Job Description (many fallbacks)
data.description = trySelectorsInner([
'.jobs-description__content .jobs-box__html-content',
'#job-details',
'.jobs-description-content__text',
'.jobs-description__content',
'.jobs-description',
'.job-view-layout .description__text',
'.description__text--rich',
'.show-more-less-html__markup',
'[class*="jobs-description"]',
'[class*="description__text"]',
], 50);
// Fallback: find "About the job" section and grab everything after it
if (!data.description || data.description.length < 50) {
const allSections = document.querySelectorAll('section, div, article');
for (const section of allSections) {
const header = section.querySelector('h2, h3');
if (header && header.textContent.trim().match(/about the job|job description|description|responsibilities/i)) {
const text = section.innerText.trim();
if (text.length > 100) {
data.description = text.substring(0, 6000);
break;
}
}
}
}
// Last resort: grab largest text block from the detail column
if (!data.description || data.description.length < 50) {
const detailCol = document.querySelector('.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout, main');
if (detailCol) {
let longestText = '';
detailCol.querySelectorAll('div, section, article').forEach(el => {
const text = el.innerText.trim();
if (text.length > longestText.length && text.length > 200) {
longestText = text;
}
});
if (longestText) {
data.description = longestText.substring(0, 6000);
}
}
}
// Cap description
if (data.description) data.description = data.description.substring(0, 6000);
// ── Skills
const skillSelectors = [
'.job-details-how-you-match__skills-item-subtitle',
'.job-details-skill-match-status-list li',
'.jobs-pref-match-skill-wrapper',
'.job-details-how-you-match-card__pill',
'.job-details-skill-match-status-list span',
];
const skillSet = new Set();
for (const sel of skillSelectors) {
document.querySelectorAll(sel).forEach(el => {
const skill = el.textContent.trim();
if (skill && skill.length < 60) skillSet.add(skill);
});
}
data.skills = Array.from(skillSet).slice(0, 20);
// ── Application Questions (Easy Apply)
data.questions = extractApplicationQuestions();
return data;
}
function extractApplicationQuestions() {
const questions = [];
const formSelectors = [
'.jobs-easy-apply-content',
'.jobs-easy-apply-form-section__grouping',
'.fb-dash-form-element'
];
for (const sel of formSelectors) {
document.querySelectorAll(sel).forEach(section => {
// Labels
const labels = section.querySelectorAll('label, legend, .fb-dash-form-element__label');
labels.forEach(label => {
const text = label.textContent.trim();
if (text && text.length > 3 && text.length < 300) {
// Determine input type
const parent = label.closest('.fb-dash-form-element') || section;
let inputType = 'text';
if (parent.querySelector('select')) inputType = 'select';
else if (parent.querySelector('input[type="radio"]')) inputType = 'radio';
else if (parent.querySelector('textarea')) inputType = 'textarea';
else if (parent.querySelector('input[type="checkbox"]')) inputType = 'checkbox';
questions.push({ question: text, type: inputType });
}
});
});
}
return questions.slice(0, 15); // Max 15 questions
}
// ─── Overlay Injection ───────────────────────────────────────────────────
function injectOverlay() {
if (document.getElementById('lja-root')) return;
const root = document.createElement('div');
root.id = 'lja-root';
document.body.appendChild(root);
const jobData = extractJobData();
root.innerHTML = buildOverlayHTML(jobData);
initOverlayLogic(root, jobData);
}
function buildOverlayHTML(job) {
const hasJob = !!(job.jobTitle || job.description);
return `
<div id="lja-toggle" title="LinkedIn Job Analyzer">🔍</div>
<div id="lja-panel">
<div id="lja-header">
<div id="lja-title">
<span>🔍</span>
<div>
<div id="lja-panel-name">Job Analyzer</div>
<div id="lja-status-bar">
<span id="lja-status-dot"></span>
<span id="lja-status-text">${hasJob ? 'Job detected' : 'No job detected — use manual mode'}</span>
</div>
</div>
</div>
<div id="lja-header-actions">
<button id="lja-minimize" title="Minimize"></button>
</div>
</div>
<!-- Job preview -->
<div id="lja-job-preview">
<div id="lja-job-title">${job.jobTitle || 'Unknown Position'}</div>
<div id="lja-job-company">${job.company || ''}${job.location ? ' · ' + job.location : ''}</div>
${job.skills.length ? `<div id="lja-skills">${job.skills.slice(0, 6).map(s => `<span class="lja-skill-tag">${s}</span>`).join('')}</div>` : ''}
</div>
<!-- Manual mode -->
<div id="lja-manual-section">
<div id="lja-manual-toggle">📋 Paste job description manually</div>
<textarea id="lja-manual-input" placeholder="Paste the full job description here..."></textarea>
<button id="lja-use-manual" class="lja-btn-secondary">Use this text</button>
</div>
<!-- Tabs -->
<div id="lja-tabs">
<button class="lja-tab active" data-tab="analysis">📊</button>
<button class="lja-tab" data-tab="coverletter">✉️</button>
<button class="lja-tab" data-tab="cvtips">📝</button>
<button class="lja-tab" data-tab="qa">❓</button>
<button class="lja-tab" data-tab="benefits">⭐</button>
</div>
<div id="lja-tab-labels">
<span class="lja-tab-label active" data-tab="analysis">Analysis</span>
<span class="lja-tab-label" data-tab="coverletter">Cover Letter</span>
<span class="lja-tab-label" data-tab="cvtips">CV Tips</span>
<span class="lja-tab-label" data-tab="qa">Q&A</span>
<span class="lja-tab-label" data-tab="benefits">Benefits</span>
</div>
<!-- Tab panes -->
<div id="lja-content">
${['analysis', 'coverletter', 'cvtips', 'qa', 'benefits'].map(tab => `
<div class="lja-pane" id="lja-pane-${tab}" ${tab !== 'analysis' ? 'style="display:none"' : ''}>
<div class="lja-pane-empty">
<div class="lja-empty-icon">${tabIcon(tab)}</div>
<div class="lja-empty-text">Click <strong>Analyze</strong> to generate</div>
</div>
</div>
`).join('')}
</div>
<!-- Actions -->
<div id="lja-actions">
<button id="lja-analyze-btn">⚡ Analyze This Job</button>
<button id="lja-copy-btn" style="display:none">📋 Copy</button>
</div>
<!-- Loading -->
<div id="lja-loading" style="display:none">
<div class="lja-spinner"></div>
<div id="lja-loading-text">Analyzing...</div>
</div>
</div>
`;
}
function tabIcon(tab) {
const icons = { analysis: '📊', coverletter: '✉️', cvtips: '📝', qa: '❓', benefits: '⭐' };
return icons[tab] || '🔍';
}
// ─── Overlay Logic ───────────────────────────────────────────────────────
function initOverlayLogic(root, jobData) {
let currentTab = 'analysis';
let isOpen = false;
let isDragging = false;
let dragStartX, dragStartY, panelStartX, panelStartY;
const results = {};
const toggle = root.querySelector('#lja-toggle');
const panel = root.querySelector('#lja-panel');
const analyzeBtn = root.querySelector('#lja-analyze-btn');
const copyBtn = root.querySelector('#lja-copy-btn');
const loading = root.querySelector('#lja-loading');
const loadingText = root.querySelector('#lja-loading-text');
const minimizeBtn = root.querySelector('#lja-minimize');
const manualToggle = root.querySelector('#lja-manual-toggle');
const manualSection = root.querySelector('#lja-manual-section');
const manualInput = root.querySelector('#lja-manual-input');
const useManualBtn = root.querySelector('#lja-use-manual');
// Status dot colour
const statusDot = root.querySelector('#lja-status-dot');
statusDot.style.background = jobData.jobTitle ? '#00d67e' : '#ffb347';
// ── Toggle panel open/close
toggle.addEventListener('click', () => {
isOpen = !isOpen;
panel.style.display = isOpen ? 'flex' : 'none';
toggle.style.display = isOpen ? 'none' : 'flex';
});
minimizeBtn.addEventListener('click', () => {
isOpen = false;
panel.style.display = 'none';
toggle.style.display = 'flex';
});
// ── Drag panel
const header = root.querySelector('#lja-header');
header.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
isDragging = true;
const rect = panel.getBoundingClientRect();
dragStartX = e.clientX;
dragStartY = e.clientY;
panelStartX = rect.right;
panelStartY = rect.bottom;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = dragStartX - e.clientX;
const dy = dragStartY - e.clientY;
const newRight = Math.max(8, Math.min(window.innerWidth - 200, panelStartX + dx));
const newBottom = Math.max(8, Math.min(window.innerHeight - 100, panelStartY + dy));
panel.style.right = newRight + 'px';
panel.style.bottom = newBottom + 'px';
});
document.addEventListener('mouseup', () => { isDragging = false; });
// ── Manual toggle
manualToggle.addEventListener('click', () => {
const expanded = manualInput.style.display === 'block';
manualInput.style.display = expanded ? 'none' : 'block';
useManualBtn.style.display = expanded ? 'none' : 'block';
});
useManualBtn.addEventListener('click', () => {
const text = manualInput.value.trim();
if (text.length < 50) {
showPanelToast(root, 'Please paste a longer job description');
return;
}
jobData.description = text;
jobData.jobTitle = jobData.jobTitle || 'Pasted Job';
root.querySelector('#lja-job-title').textContent = jobData.jobTitle;
root.querySelector('#lja-status-text').textContent = 'Manual mode — ready to analyze';
statusDot.style.background = '#6c63ff';
showPanelToast(root, '✅ Manual text applied');
});
// ── Tab switching
root.querySelectorAll('.lja-tab').forEach(btn => {
btn.addEventListener('click', () => {
currentTab = btn.dataset.tab;
root.querySelectorAll('.lja-tab').forEach(b => b.classList.remove('active'));
root.querySelectorAll('.lja-tab-label').forEach(b => b.classList.remove('active'));
root.querySelectorAll('.lja-pane').forEach(p => p.style.display = 'none');
btn.classList.add('active');
root.querySelector(`.lja-tab-label[data-tab="${currentTab}"]`).classList.add('active');
root.querySelector(`#lja-pane-${currentTab}`).style.display = 'block';
// Show copy button if we have content
copyBtn.style.display = results[currentTab] ? 'block' : 'none';
});
});
// ── Analyze button
analyzeBtn.addEventListener('click', async () => {
if (!jobData.description && !jobData.jobTitle) {
showPanelToast(root, '⚠️ No job detected. Use manual mode.');
return;
}
const settings = await getSettings();
if (!settings.apiKey) {
showPanelToast(root, '⚠️ Set your API key in extension settings first!');
return;
}
await runAnalysis(settings, jobData, currentTab, results, root, loading, loadingText, copyBtn);
});
// ── Copy button
copyBtn.addEventListener('click', () => {
if (results[currentTab]) {
navigator.clipboard.writeText(results[currentTab])
.then(() => showPanelToast(root, '📋 Copied!'))
.catch(() => showPanelToast(root, 'Copy failed'));
}
});
}
// ─── Run Analysis ────────────────────────────────────────────────────────
async function runAnalysis(settings, jobData, tab, results, root, loading, loadingText, copyBtn) {
const loadingMsgs = {
analysis: 'Analyzing job fit...',
coverletter: 'Writing cover letter...',
cvtips: 'Generating CV tips...',
qa: 'Preparing Q&A answers...',
benefits: 'Summarizing benefits...'
};
loading.style.display = 'flex';
loadingText.textContent = loadingMsgs[tab] || 'Generating...';
const pane = root.querySelector(`#lja-pane-${tab}`);
const analyzeBtn = root.querySelector('#lja-analyze-btn');
analyzeBtn.disabled = true;
try {
const prompt = buildPromptV2(tab, jobData, settings.userProfile, settings.language);
const response = await chrome.runtime.sendMessage({
type: 'GEMINI_REQUEST',
payload: { apiKey: settings.apiKey, prompt, tab }
});
if (!response.success) {
throw new Error(response.error);
}
const text = response.data.text;
results[tab] = text;
// Render markdown-like output
pane.innerHTML = `
<div class="lja-result ${response.data.fromCache ? 'lja-cached' : ''}">
${response.data.fromCache ? '<div class="lja-cache-badge">⚡ Cached result</div>' : ''}
${renderMarkdown(text)}
</div>
`;
copyBtn.style.display = 'block';
// Show auto-fill button for Q&A tab
if (tab === 'qa' && jobData.questions.length > 0) {
const fillBtn = document.createElement('button');
fillBtn.className = 'lja-fill-btn';
fillBtn.textContent = '📝 Auto-fill Answers';
fillBtn.addEventListener('click', () => autoFillAnswers(text, root));
pane.appendChild(fillBtn);
}
} catch (err) {
pane.innerHTML = `<div class="lja-error">❌ ${err.message}</div>`;
} finally {
loading.style.display = 'none';
analyzeBtn.disabled = false;
}
}
// ─── Prompt Builder ──────────────────────────────────────────────────────
function buildPrompt(tab, job, userProfile, language) {
const langInstruction = language === 'arabic'
? 'Respond entirely in Arabic.'
: language === 'english'
? 'Respond entirely in English.'
: 'Respond in the same language as the job posting (Arabic or English).';
const jobContext = `
Job Title: ${job.jobTitle || 'Not specified'}
Company: ${job.company || 'Not specified'}
Location: ${job.location || 'Not specified'}
Description:
${job.description || 'No description available'}
${job.skills.length ? `\nRequired Skills: ${job.skills.join(', ')}` : ''}`.trim();
const profileBlock = `MY PROFILE:\n${userProfile}`;
const prompts = {
analysis: `You are a senior career advisor specializing in tech roles in the Middle East.
${langInstruction}
Analyze this job posting against my profile. Be direct and honest.
${profileBlock}
JOB POSTING:
${jobContext}
Provide in this exact format:
## MATCH SCORE: X/100
Brief explanation.
## ✅ STRONG MATCHES
- bullet points of matching skills/experience
## ⚠️ GAPS
- bullet points of missing requirements or weak areas
## 🎯 RECOMMENDATION
Apply / Apply with preparation / Skip — with clear reason
## 💡 KEY SELLING POINTS
- what to emphasize when applying
## 🏷️ BEST CV VARIANT
Which of my CV variants (Solutions Architect / Flutter Developer / GIS Developer / FinTech Engineer) best fits and why`,
coverletter: `Write a compelling, personalized cover letter for this position.
${langInstruction}
${profileBlock}
JOB:
${jobContext}
Requirements:
- 3 paragraphs maximum
- Reference specific requirements from the job description
- Highlight my most relevant achievements with numbers
- Professional but confident tone — not generic
- End with a strong call to action
- Do NOT use placeholder brackets — write a complete ready-to-send letter
- Address to "Hiring Team" if no specific name is available
- Sign as "Hamza Ayed"`,
cvtips: `Based on this job posting, provide specific CV optimization advice.
${langInstruction}
${profileBlock}
JOB:
${jobContext}
Provide:
## 📌 SUGGESTED HEADLINE
A LinkedIn-style headline optimized for this specific role (max 120 characters)
## 📋 SUGGESTED SUMMARY
3-4 sentence professional summary tailored for this role
## 🔑 KEYWORDS TO ADD
- specific keywords from the job to add to CV
## ⬆️ EXPERIENCE TO PRIORITIZE
- which of my experiences to move to top or emphasize
## ✂️ WHAT TO DE-EMPHASIZE
- what to remove or minimize for this application
## 🎯 SKILLS SECTION
- specific skills to list for this role`,
qa: `I am applying for this job and need to answer application questions.
${langInstruction}
Answer each question based on my profile. Be specific, professional, and concise.
${profileBlock}
JOB:
${jobContext}
${job.questions.length > 0
? 'QUESTIONS TO ANSWER:\n' + job.questions.map((q, i) => `${i + 1}. [${q.type}] ${q.question}`).join('\n')
: 'Generate answers for these common application questions:\n1. Why are you interested in this role?\n2. What is your relevant experience?\n3. What are your salary expectations?\n4. When can you start?\n5. Do you require visa sponsorship?\n6. What is your current notice period?'}
For each question provide a clear, professional answer ready to paste. Keep answers concise (2-4 sentences for text fields).`,
benefits: `Analyze this job posting and extract all benefits and advantages.
${langInstruction}
JOB:
${jobContext}
Provide:
## 💰 COMPENSATION & BENEFITS
- salary, bonuses, equity if mentioned; otherwise state "Not specified"
## 🏢 WORK ARRANGEMENT
- remote/hybrid/onsite, flexibility, hours
## 📈 GROWTH OPPORTUNITIES
- career development, training, advancement potential
## 🌟 COMPANY PERKS
- insurance, vacation, equipment, culture perks etc.
## ⚠️ POTENTIAL CONCERNS
- red flags, very demanding requirements, unclear aspects
## 📊 OVERALL ATTRACTIVENESS: X/10
Brief honest assessment of this opportunity for my profile`
};
return prompts[tab] || prompts.analysis;
}
// ─── Markdown Renderer ───────────────────────────────────────────────────
function renderMarkdown(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>(\n|$))+/g, m => '<ul>' + m + '</ul>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^(?!<[hup])(.+)$/gm, (m) => m.startsWith('<') ? m : '<p>' + m + '</p>');
}
// ─── Auto-fill ───────────────────────────────────────────────────────────
function autoFillAnswers(qaText, root) {
showPanelToast(root, '🔄 Attempting to fill answers...');
// Parse numbered answers from AI response
const answerLines = qaText.split('\n');
const inputs = document.querySelectorAll('.jobs-easy-apply-content input[type="text"], .jobs-easy-apply-content textarea');
let filled = 0;
inputs.forEach((input, idx) => {
const answerLine = answerLines.find(l => l.match(new RegExp(`^${idx + 1}\\.`)));
if (answerLine) {
const answer = answerLine.replace(/^\d+\.\s*/, '').trim();
if (answer) {
input.value = answer;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
filled++;
}
}
});
showPanelToast(root, filled > 0 ? `✅ Filled ${filled} fields` : '⚠️ Could not auto-fill. Copy answers manually.');
}
// ─── Utilities ───────────────────────────────────────────────────────────
function getSettings() {
return new Promise(resolve => {
chrome.storage.sync.get(['apiKey', 'userProfile', 'language'], resolve);
});
}
function showPanelToast(root, msg) {
let toast = root.querySelector('#lja-panel-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'lja-panel-toast';
root.querySelector('#lja-panel').appendChild(toast);
}
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
}
// ─── SPA Navigation Observer ─────────────────────────────────────────────
let lastUrl = location.href;
let lastJobId = new URLSearchParams(location.search).get('currentJobId') || '';
let debounceTimer;
function getJobIdFromUrl() {
const params = new URLSearchParams(location.search);
return params.get('currentJobId') || '';
}
function refreshOverlay() {
const old = document.getElementById('lja-root');
if (old) old.remove();
window.__linkedinAnalyzerLoaded = false;
setTimeout(injectOverlay, 1200);
}
// Watch for URL changes (SPA navigation + job switches)
const observer = new MutationObserver(() => {
const currentUrl = location.href;
const currentJobId = getJobIdFromUrl();
// Job switched via sidebar click (URL param changed)
if (currentJobId && currentJobId !== lastJobId) {
lastJobId = currentJobId;
lastUrl = currentUrl;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshOverlay, 600);
return;
}
// Full URL change (different page)
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
lastJobId = currentJobId;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (currentUrl.includes('/jobs/')) {
refreshOverlay();
}
}, 800);
}
});
observer.observe(document.body, { childList: true, subtree: true });
// ─── Init ─────────────────────────────────────────────────────────────────
function init() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(injectOverlay, 1000));
} else {
setTimeout(injectOverlay, 1000);
}
}
init();
})();

169
cv_template.html Normal file
View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
/*
* CSS Optimized for Dompdf / mPDF / TCPDF
* Avoids flexbox, uses standard block/inline styling for flawless PDF generation.
*/
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 13px;
color: #222;
line-height: 1.5;
margin: 0;
padding: 20px 40px;
}
.header {
text-align: center;
border-bottom: 2px solid #1a237e;
padding-bottom: 15px;
margin-bottom: 20px;
}
h1 {
font-size: 28px;
color: #1a237e;
margin: 0 0 5px 0;
text-transform: uppercase;
letter-spacing: 1px;
}
.headline {
font-size: 16px;
font-weight: bold;
color: #424242;
margin-bottom: 5px;
}
.contact {
font-size: 12px;
color: #555;
}
.section-title {
font-size: 15px;
font-weight: bold;
color: #1a237e;
border-bottom: 1px solid #ddd;
margin-top: 20px;
margin-bottom: 10px;
padding-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
p { margin: 0 0 10px 0; text-align: justify; }
.job-block { margin-bottom: 15px; }
.job-header {
width: 100%;
margin-bottom: 5px;
}
.job-title {
font-weight: bold;
font-size: 14px;
color: #222;
float: left;
}
.job-meta {
font-size: 12px;
color: #1a237e;
font-weight: bold;
float: right;
}
.clear { clear: both; }
ul { margin: 5px 0 10px 0; padding-left: 20px; }
li { margin-bottom: 6px; text-align: justify; }
</style>
</head>
<body>
<div class="header">
<h1>Hamza Ayed</h1>
<!-- PHP WILL INJECT THE AI-GENERATED HEADLINE HERE -->
<div class="headline">{{JOB_HEADLINE}}</div>
<div class="contact">
Amman, Jordan | +962 79 858 3052 | hamzaayed@intaleqapp.com | linkedin.com/in/hamza-ayed
</div>
</div>
<div class="section-title">Professional Summary</div>
<!-- PHP WILL INJECT THE AI-GENERATED SUMMARY HERE -->
<p>{{TAILORED_SUMMARY}}</p>
<div class="section-title">Core Competencies & Skills</div>
<!-- PHP WILL INJECT ATS KEYWORDS HERE -->
<p><strong>Targeted Expertise:</strong> {{DYNAMIC_SKILLS}}</p>
<p><strong>Technologies:</strong> Flutter/Dart, PHP, Python (FastAPI/Flask), Node.js, MySQL, PostgreSQL, AWS/Cloud Infrastructure, Git, Docker.</p>
<p><strong>Domains:</strong> System Architecture, Distributed Systems, GIS/Mapping (OSM), FinTech (Payment Gateways), AI/ML Integration, Zero-Trust Security.</p>
<div class="section-title">Professional Experience</div>
<div class="job-block">
<div class="job-header">
<div class="job-title">CTO & Solutions Architect — Intaleq</div>
<div class="job-meta">Jan 2025 Present | Syria / Remote</div>
<div class="clear"></div>
</div>
<ul>
<li>Led the full-stack architecture of a smart transportation ecosystem supporting 1,800+ drivers and 2,500+ riders.</li>
<li>Built a proprietary mapping platform (IntaleqMaps) on OpenStreetMap, eliminating reliance on Google Maps API and saving $10,000+/month in operational costs.</li>
<li>Designed secure, custom payment infrastructure for environments lacking standard payment APIs, ensuring high-availability transaction integrity.</li>
</ul>
</div>
<div class="job-block">
<div class="job-header">
<div class="job-title">Co-Founder & Lead Developer — Tripz Egypt</div>
<div class="job-meta">Jan 2024 Present | Cairo / Remote</div>
<div class="clear"></div>
</div>
<ul>
<li>Architected Egypt's homegrown ride-hailing platform, managing the entire distributed ecosystem with real-time tracking and dispatching.</li>
<li>Implemented robust microservices for real-time driver/rider matching and route optimization using event-driven architecture.</li>
</ul>
</div>
<div class="job-block">
<div class="job-header">
<div class="job-title">Mobile Solutions Architect — Freelance</div>
<div class="job-meta">Jan 2017 Dec 2023 | Jordan / Remote</div>
<div class="clear"></div>
</div>
<ul>
<li>Delivered 25+ production enterprise applications across GIS, FinTech, HR, and utilities for clients across the MENA region.</li>
<li>Integrated AI vision models for document analysis (KYC) and automated invoice processing pipelines.</li>
</ul>
</div>
<div class="job-block">
<div class="job-header">
<div class="job-title">Operations & Logistics Officer — Jordan Armed Forces</div>
<div class="job-meta">Oct 2003 Nov 2023 | Jordan</div>
<div class="clear"></div>
</div>
<ul>
<li>Retired Lieutenant Colonel. Directed logistics and crisis management operations, leading teams of 50+ personnel.</li>
<li>Applied rigorous, security-first methodologies to organizational leadership, disaster recovery, and operational planning.</li>
</ul>
</div>
<div class="section-title">Education & Certifications</div>
<ul>
<li><strong>BS Mathematics</strong>, Mutah University (20032007)</li>
<li>Google Data Analytics Professional Certificate</li>
<li>IBM Data Science Professional Certificate</li>
<li>Meta Mobile Development Certificate</li>
<li><em>Total of 51 professional certifications across software engineering, AI, and enterprise architecture.</em></li>
</ul>
</body>
</html>

BIN
icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

BIN
icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

113
linkedin_analyzer.md Normal file
View File

@@ -0,0 +1,113 @@
# Python LinkedIn Analyzer (Gemini AI)
This is a standalone Python version of the LinkedIn Job Analyzer, utilizing the latest `gemini-2.5-flash` model.
### Setup
1. Install the required Google Generative AI SDK:
```bash
pip install google-generativeai
```
2. Save the code below as `linkedin_analyzer.py` and run it:
```python
import os
import google.generativeai as genai
# ==========================================
# 1. Configuration
# ==========================================
# Replace with your actual Gemini API Key
API_KEY = "YOUR_API_KEY_HERE"
# Configure the API with the key
genai.configure(api_key=API_KEY)
# Use the latest, stable model supported by generateContent
MODEL_NAME = "gemini-2.5-flash"
try:
model = genai.GenerativeModel(MODEL_NAME)
except Exception as e:
print(f"Error loading model: {e}")
exit(1)
# ==========================================
# 2. User & Job Data
# ==========================================
USER_PROFILE = """
HAMZA AYED — Solutions Architect | Senior Flutter Developer | GIS & Mapping Expert
SUMMARY:
Solutions Architect with 6+ years building complete mobile ecosystems. Built two ride-hailing platforms (Fawry & Sayig) with 1,200+ users. Created proprietary offline map technology (Mapss) replacing Google Maps API and saving $10k+/month. Specialized in Dart, Python, and scalable backend infrastructure.
"""
JOB_POSTING = """
Job Title: Technical Architect (AI, Cloud, Distributed Systems & Banking Payments)
Company: International Turnkey Systems - ITS
Location: Cairo, Egypt (Hybrid)
Description:
We are looking for a highly skilled Technical Architect with deep experience in AI, cloud-native architectures, distributed systems, and banking payments...
"""
# ==========================================
# 3. AI Analysis Function
# ==========================================
def analyze_job():
print(f"🔄 Analyzing job with {MODEL_NAME}...\n")
prompt = f"""
You are an elite career strategist with 15+ years hiring for top tech companies in MENA.
Respond entirely in Arabic.
Perform a DEEP strategic analysis of this job against my profile. Be brutally honest.
MY PROFILE:
{USER_PROFILE}
JOB POSTING:
{JOB_POSTING}
Respond in this EXACT structure:
## MATCH SCORE: X/100
### Score Breakdown:
- **Technical Skills Match:** X/30
- **Experience Level Match:** X/25
- **Domain/Industry Match:** X/20
- **Location & Logistics:** X/15
- **Culture & Soft Skills:** X/10
## WHERE I CRUSH IT
- [Specific matching skills]
## HONEST GAPS
- [Missing requirements]
## VERDICT
[APPLY NOW / APPLY WITH PREP / LONG SHOT / SKIP]
"""
try:
# Generate the response
response = model.generate_content(prompt)
print("✅ ANALYSIS COMPLETE:\n")
print("="*60)
print(response.text)
print("="*60)
except Exception as e:
print(f"❌ API Error: {e}")
print("\nNote: If you get a 'Model not supported' error, ensure your API key has access to the 2.5/2.0 series or check your Google AI Studio project settings.")
# ==========================================
# Run the script
# ==========================================
if __name__ == "__main__":
if API_KEY == "YOUR_API_KEY_HERE":
print("⚠️ Please update the API_KEY variable at the top of the script first!")
else:
analyze_job()
```

33
manifest.json Normal file
View File

@@ -0,0 +1,33 @@
{
"manifest_version": 3,
"name": "LinkedIn Job Analyzer",
"version": "1.0.0",
"description": "AI-powered job analysis tool for LinkedIn — personal use",
"permissions": ["storage", "activeTab", "scripting"],
"host_permissions": [
"https://www.linkedin.com/*",
"https://generativelanguage.googleapis.com/*"
],
"content_scripts": [{
"matches": ["https://www.linkedin.com/jobs/*"],
"js": ["prompts.js", "content.js"],
"css": ["overlay.css"],
"run_at": "document_idle"
}],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

610
overlay.css Normal file
View File

@@ -0,0 +1,610 @@
/* overlay.css — LinkedIn Job Analyzer floating panel */
/* ─── Variables ─────────────────────────────────────────────────────────── */
#lja-root {
--lja-bg: #0a0a0f;
--lja-bg2: #12121a;
--lja-card: #1a1a26;
--lja-card2: #1e1e2e;
--lja-accent: #6c63ff;
--lja-accent2: #9b5de5;
--lja-glow: rgba(108, 99, 255, 0.25);
--lja-border: rgba(108, 99, 255, 0.18);
--lja-border2: rgba(108, 99, 255, 0.4);
--lja-text: #ffffff;
--lja-text2: #a0a0b8;
--lja-text3: #606078;
--lja-green: #00d67e;
--lja-red: #ff4d6d;
--lja-yellow: #ffb347;
--lja-radius: 16px;
--lja-shadow: 0 24px 60px rgba(0,0,0,0.6), 0 0 40px var(--lja-glow);
}
/* ─── Toggle Button ─────────────────────────────────────────────────────── */
#lja-toggle {
position: fixed;
bottom: 24px;
right: 24px;
width: 52px;
height: 52px;
background: linear-gradient(135deg, var(--lja-accent), var(--lja-accent2));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
cursor: pointer;
z-index: 2147483646;
box-shadow: 0 6px 24px var(--lja-glow), 0 2px 8px rgba(0,0,0,0.4);
transition: transform 0.2s, box-shadow 0.2s;
user-select: none;
}
#lja-toggle:hover {
transform: scale(1.1);
box-shadow: 0 10px 32px var(--lja-glow), 0 2px 10px rgba(0,0,0,0.5);
}
#lja-toggle:active { transform: scale(0.96); }
/* ─── Panel ─────────────────────────────────────────────────────────────── */
#lja-panel {
position: fixed;
bottom: 24px;
right: 24px;
width: 420px;
max-height: 88vh;
background: var(--lja-bg);
border: 1px solid var(--lja-border);
border-radius: var(--lja-radius);
box-shadow: var(--lja-shadow);
display: none;
flex-direction: column;
z-index: 2147483647;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
overflow: hidden;
animation: ljaSlideUp 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
backdrop-filter: blur(2px);
}
@keyframes ljaSlideUp {
from { opacity: 0; transform: translateY(20px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ─── Header ─────────────────────────────────────────────────────────────── */
#lja-header {
background: linear-gradient(135deg, #0e0e18 0%, #12101e 100%);
border-bottom: 1px solid var(--lja-border);
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: move;
flex-shrink: 0;
}
#lja-title {
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
#lja-title > span {
width: 34px;
height: 34px;
background: linear-gradient(135deg, var(--lja-accent), var(--lja-accent2));
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
box-shadow: 0 0 14px var(--lja-glow);
flex-shrink: 0;
}
#lja-panel-name {
font-size: 13px;
font-weight: 700;
color: var(--lja-text);
letter-spacing: 0.3px;
}
#lja-status-bar {
display: flex;
align-items: center;
gap: 5px;
margin-top: 2px;
}
#lja-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--lja-green);
animation: ljaPulse 2s infinite;
}
@keyframes ljaPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
#lja-status-text {
font-size: 10.5px;
color: var(--lja-text2);
}
#lja-header-actions {
display: flex;
gap: 6px;
pointer-events: all;
}
#lja-minimize {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: var(--lja-text2);
border-radius: 7px;
width: 28px;
height: 28px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
line-height: 1;
padding: 0;
}
#lja-minimize:hover {
background: rgba(255,255,255,0.12);
color: var(--lja-text);
}
/* ─── Job Preview ────────────────────────────────────────────────────────── */
#lja-job-preview {
padding: 12px 16px 10px;
border-bottom: 1px solid var(--lja-border);
flex-shrink: 0;
background: var(--lja-bg2);
}
#lja-job-title {
font-size: 13.5px;
font-weight: 700;
color: var(--lja-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#lja-job-company {
font-size: 11.5px;
color: var(--lja-text2);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#lja-skills {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.lja-skill-tag {
background: rgba(108, 99, 255, 0.12);
border: 1px solid rgba(108, 99, 255, 0.25);
color: #9e97ff;
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: 20px;
}
/* ─── Manual Section ─────────────────────────────────────────────────────── */
#lja-manual-section {
padding: 0 16px;
border-bottom: 1px solid var(--lja-border);
flex-shrink: 0;
background: var(--lja-bg2);
}
#lja-manual-toggle {
font-size: 11px;
color: var(--lja-accent);
cursor: pointer;
padding: 8px 0;
user-select: none;
transition: opacity 0.2s;
}
#lja-manual-toggle:hover { opacity: 0.75; }
#lja-manual-input {
display: none;
width: 100%;
background: var(--lja-card);
border: 1px solid var(--lja-border);
border-radius: 8px;
color: var(--lja-text);
font-family: inherit;
font-size: 11.5px;
line-height: 1.5;
padding: 8px 10px;
resize: vertical;
min-height: 80px;
max-height: 140px;
outline: none;
box-sizing: border-box;
margin-bottom: 8px;
}
#lja-manual-input:focus {
border-color: var(--lja-accent);
box-shadow: 0 0 0 3px var(--lja-glow);
}
#lja-use-manual {
display: none;
margin-bottom: 10px;
background: transparent;
border: 1px solid var(--lja-border2);
color: var(--lja-accent);
border-radius: 7px;
padding: 6px 14px;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
#lja-use-manual:hover {
background: rgba(108, 99, 255, 0.1);
}
/* ─── Tabs ───────────────────────────────────────────────────────────────── */
#lja-tabs {
display: flex;
justify-content: space-between;
padding: 10px 16px 0;
gap: 4px;
flex-shrink: 0;
background: var(--lja-bg);
}
.lja-tab {
flex: 1;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
color: var(--lja-text3);
font-size: 18px;
padding: 7px 4px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.lja-tab:hover {
background: rgba(108,99,255,0.08);
color: var(--lja-text2);
border-color: var(--lja-border);
}
.lja-tab.active {
background: rgba(108, 99, 255, 0.15);
border-color: var(--lja-border2);
color: var(--lja-accent);
box-shadow: 0 0 12px var(--lja-glow);
}
#lja-tab-labels {
display: flex;
justify-content: space-between;
padding: 4px 16px 8px;
gap: 4px;
flex-shrink: 0;
}
.lja-tab-label {
flex: 1;
text-align: center;
font-size: 9px;
font-weight: 600;
color: var(--lja-text3);
text-transform: uppercase;
letter-spacing: 0.4px;
transition: color 0.2s;
cursor: pointer;
}
.lja-tab-label.active { color: var(--lja-accent); }
/* ─── Content Panes ──────────────────────────────────────────────────────── */
#lja-content {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--lja-accent) transparent;
min-height: 0;
}
#lja-content::-webkit-scrollbar { width: 3px; }
#lja-content::-webkit-scrollbar-thumb {
background: var(--lja-accent);
border-radius: 2px;
}
.lja-pane {
padding: 14px 16px;
min-height: 180px;
}
/* Empty state */
.lja-pane-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 10px;
}
.lja-empty-icon { font-size: 32px; opacity: 0.4; }
.lja-empty-text {
font-size: 12px;
color: var(--lja-text3);
text-align: center;
}
.lja-empty-text strong { color: var(--lja-accent); }
/* Result content */
.lja-result {
font-size: 12.5px;
line-height: 1.7;
color: #e0e0ec;
}
.lja-result h3 {
font-size: 14px;
font-weight: 700;
color: #ffffff;
margin: 16px 0 8px;
padding: 6px 10px;
background: rgba(108, 99, 255, 0.12);
border-left: 3px solid var(--lja-accent);
border-radius: 0 6px 6px 0;
}
.lja-result h3:first-child { margin-top: 0; }
.lja-result h4 {
font-size: 12.5px;
font-weight: 600;
color: #b8b0ff;
margin: 12px 0 5px;
}
.lja-result p {
margin: 5px 0;
color: #d0d0e0;
}
.lja-result ul {
list-style: none;
padding: 0;
margin: 6px 0;
}
.lja-result ul li {
padding: 4px 0 4px 16px;
position: relative;
color: #d0d0e0;
line-height: 1.6;
}
.lja-result ul li::before {
content: '▸';
position: absolute;
left: 2px;
color: var(--lja-accent);
font-weight: 700;
font-size: 11px;
}
.lja-result strong { color: #ffffff; font-weight: 700; }
.lja-result em { color: #ffcc66; font-style: normal; font-weight: 500; }
/* Cache badge */
.lja-cache-badge {
font-size: 10px;
color: var(--lja-accent);
background: rgba(108,99,255,0.1);
border: 1px solid var(--lja-border);
border-radius: 20px;
padding: 2px 10px;
display: inline-block;
margin-bottom: 12px;
}
/* Error */
.lja-error {
background: rgba(255, 77, 109, 0.08);
border: 1px solid rgba(255, 77, 109, 0.25);
border-radius: 10px;
padding: 14px;
font-size: 12px;
color: var(--lja-red);
line-height: 1.5;
}
/* Auto-fill button */
.lja-fill-btn {
width: 100%;
margin-top: 12px;
background: rgba(0, 214, 126, 0.1);
border: 1px solid rgba(0, 214, 126, 0.3);
color: var(--lja-green);
border-radius: 8px;
padding: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
}
.lja-fill-btn:hover {
background: rgba(0, 214, 126, 0.18);
}
/* ─── Actions Bar ────────────────────────────────────────────────────────── */
#lja-actions {
display: flex;
gap: 8px;
padding: 10px 16px 14px;
border-top: 1px solid var(--lja-border);
flex-shrink: 0;
background: var(--lja-bg);
}
#lja-analyze-btn {
flex: 1;
background: linear-gradient(135deg, var(--lja-accent), var(--lja-accent2));
border: none;
border-radius: 10px;
color: white;
font-size: 13px;
font-weight: 700;
padding: 11px 16px;
cursor: pointer;
font-family: inherit;
letter-spacing: 0.3px;
transition: all 0.2s;
box-shadow: 0 4px 16px var(--lja-glow);
}
#lja-analyze-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px var(--lja-glow);
}
#lja-analyze-btn:active { transform: translateY(0); }
#lja-analyze-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
#lja-copy-btn {
background: rgba(255,255,255,0.05);
border: 1px solid var(--lja-border);
border-radius: 10px;
color: var(--lja-text2);
font-size: 16px;
padding: 10px 14px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
#lja-copy-btn:hover {
background: rgba(108,99,255,0.1);
border-color: var(--lja-border2);
color: var(--lja-accent);
}
/* ─── Loading ─────────────────────────────────────────────────────────────── */
#lja-loading {
position: absolute;
inset: 0;
background: rgba(10, 10, 15, 0.88);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
border-radius: var(--lja-radius);
z-index: 10;
}
.lja-spinner {
width: 38px;
height: 38px;
border: 3px solid rgba(108, 99, 255, 0.2);
border-top-color: var(--lja-accent);
border-radius: 50%;
animation: ljaSpin 0.8s linear infinite;
}
@keyframes ljaSpin {
to { transform: rotate(360deg); }
}
#lja-loading-text {
font-size: 13px;
color: var(--lja-text2);
font-weight: 500;
animation: ljaFade 1.5s ease-in-out infinite;
}
@keyframes ljaFade {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ─── Panel Toast ────────────────────────────────────────────────────────── */
#lja-panel-toast {
position: absolute;
bottom: 70px;
left: 50%;
transform: translateX(-50%) translateY(8px);
background: var(--lja-card);
border: 1px solid var(--lja-border);
color: var(--lja-text);
padding: 7px 16px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 500;
opacity: 0;
transition: all 0.3s;
pointer-events: none;
white-space: nowrap;
z-index: 20;
}
#lja-panel-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ─── Scrollbar for secondary elements ───────────────────────────────────── */
.lja-secondary-btn {
background: transparent;
border: 1px solid var(--lja-border);
color: var(--lja-text2);
border-radius: 8px;
padding: 5px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.lja-secondary-btn:hover {
border-color: var(--lja-accent);
color: var(--lja-accent);
}

502
popup.html Normal file
View File

@@ -0,0 +1,502 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LinkedIn Job Analyzer — Settings</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a26;
--accent: #6c63ff;
--accent-hover: #7c74ff;
--accent-glow: rgba(108, 99, 255, 0.3);
--text-primary: #ffffff;
--text-secondary: #a0a0b8;
--text-muted: #606078;
--border: rgba(108, 99, 255, 0.2);
--border-hover: rgba(108, 99, 255, 0.5);
--success: #00d67e;
--error: #ff4d6d;
--warning: #ffb347;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
width: 420px;
min-height: 500px;
max-height: 650px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--accent) transparent;
}
body::-webkit-scrollbar { width: 4px; }
body::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 2px; }
/* Header */
.header {
background: linear-gradient(135deg, #0a0a0f 0%, #12101e 100%);
border-bottom: 1px solid var(--border);
padding: 18px 20px 14px;
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 10;
}
.header-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--accent), #9b5de5);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 0 20px var(--accent-glow);
}
.header-text h1 {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.3px;
}
.header-text p {
font-size: 11px;
color: var(--text-secondary);
margin-top: 1px;
}
/* Content */
.content {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* Section */
.section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
transition: border-color 0.2s;
}
.section:hover {
border-color: var(--border-hover);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.8px;
display: flex;
align-items: center;
gap: 6px;
}
/* Labels & Inputs */
label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.input-wrapper {
position: relative;
}
input[type="password"],
input[type="text"],
select {
width: 100%;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-family: 'Inter', sans-serif;
font-size: 13px;
padding: 9px 38px 9px 12px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
select {
padding-right: 12px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23a0a0b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
.toggle-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 15px;
padding: 2px;
transition: color 0.2s;
}
.toggle-btn:hover { color: var(--text-secondary); }
textarea {
width: 100%;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-family: 'Inter', monospace;
font-size: 11.5px;
line-height: 1.6;
padding: 10px 12px;
outline: none;
resize: vertical;
min-height: 130px;
max-height: 220px;
transition: border-color 0.2s, box-shadow 0.2s;
}
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* Buttons */
.btn {
border: none;
border-radius: 8px;
cursor: pointer;
font-family: 'Inter', sans-serif;
font-weight: 600;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #9b5de5);
color: white;
padding: 10px 20px;
font-size: 13px;
width: 100%;
letter-spacing: 0.3px;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px var(--accent-glow);
}
.btn-primary:active { transform: translateY(0); }
.btn-sm {
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 6px 12px;
font-size: 11px;
}
.btn-sm:hover {
border-color: var(--accent);
color: var(--accent);
}
.btn-danger {
background: transparent;
border: 1px solid rgba(255, 77, 109, 0.3);
color: var(--error);
padding: 7px 14px;
font-size: 11px;
}
.btn-danger:hover {
background: rgba(255, 77, 109, 0.1);
border-color: var(--error);
}
/* Status badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
border-radius: 20px;
}
.status-badge.success {
background: rgba(0, 214, 126, 0.12);
color: var(--success);
border: 1px solid rgba(0, 214, 126, 0.25);
}
.status-badge.error {
background: rgba(255, 77, 109, 0.12);
color: var(--error);
border: 1px solid rgba(255, 77, 109, 0.25);
}
.status-badge.checking {
background: rgba(255, 179, 71, 0.12);
color: var(--warning);
border: 1px solid rgba(255, 179, 71, 0.25);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* API Key row */
.api-key-row {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 4px;
}
.api-key-row .input-wrapper { flex: 1; }
/* Usage bar */
.usage-bar {
margin-top: 10px;
}
.usage-label {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 5px;
}
.usage-track {
height: 4px;
background: var(--bg-secondary);
border-radius: 2px;
overflow: hidden;
}
.usage-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #9b5de5);
border-radius: 2px;
transition: width 0.5s ease;
}
/* Info link */
.info-link {
font-size: 11px;
color: var(--accent);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 6px;
transition: opacity 0.2s;
}
.info-link:hover { opacity: 0.75; }
/* Row helpers */
.row {
display: flex;
gap: 8px;
margin-top: 4px;
}
.row .btn { flex: 1; }
/* Divider */
.divider {
height: 1px;
background: var(--border);
margin: 6px 0;
}
/* Footer */
.footer {
padding: 10px 18px 14px;
text-align: center;
font-size: 10.5px;
color: var(--text-muted);
border-top: 1px solid var(--border);
}
.footer span { color: var(--accent); }
/* Notification toast */
.toast {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 9px 18px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
opacity: 0;
transition: all 0.3s;
pointer-events: none;
white-space: nowrap;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast.success { border-color: var(--success); color: var(--success); }
.toast.error { border-color: var(--error); color: var(--error); }
</style>
</head>
<body>
<div class="header">
<div class="header-icon">🔍</div>
<div class="header-text">
<h1>LinkedIn Job Analyzer</h1>
<p>Powered by Gemini 3.1 Flash-Lite Latest</p>
</div>
</div>
<div class="content">
<!-- API KEY SECTION -->
<div class="section">
<div class="section-header">
<span class="section-title">🔑 API Key</span>
<span id="key-status" class="status-badge" style="display:none;"></span>
</div>
<label for="api-key-input">Gemini API Key</label>
<div class="api-key-row">
<div class="input-wrapper">
<input type="password" id="api-key-input" placeholder="AIzaSy..." autocomplete="off" spellcheck="false">
<button class="toggle-btn" id="toggle-key" title="Show/Hide">👁</button>
</div>
<button class="btn btn-sm" id="test-key-btn">Test</button>
</div>
<a class="info-link" href="https://aistudio.google.com/apikey" target="_blank">
🔗 Get free API key (no credit card needed)
</a>
<!-- Usage -->
<div class="divider" style="margin-top:12px;"></div>
<div class="usage-bar">
<div class="usage-label">
<span>Daily Usage</span>
<span id="usage-count">0 / 1,000 requests</span>
</div>
<div class="usage-track">
<div class="usage-fill" id="usage-fill" style="width:0%;"></div>
</div>
</div>
</div>
<!-- PROFILE SECTION -->
<div class="section">
<div class="section-header">
<span class="section-title">👤 My Profile</span>
<button class="btn btn-sm" id="reset-profile-btn">Reset</button>
</div>
<label for="profile-textarea">Your CV / Experience (used in all AI prompts)</label>
<textarea id="profile-textarea" placeholder="Paste your CV or experience here..."></textarea>
</div>
<!-- PREFERENCES SECTION -->
<div class="section">
<div class="section-header">
<span class="section-title">⚙️ Preferences</span>
</div>
<label for="lang-select">Response Language</label>
<select id="lang-select">
<option value="auto">🌐 Auto-detect (match job language)</option>
<option value="english">🇬🇧 Always English</option>
<option value="arabic">🇯🇴 Always Arabic</option>
</select>
</div>
<!-- SAVE BUTTON -->
<button class="btn btn-primary" id="save-btn">
💾 Save Settings
</button>
<!-- DANGER ZONE -->
<div class="section">
<div class="section-header">
<span class="section-title">🗑️ Data Management</span>
</div>
<div class="row">
<button class="btn btn-danger" id="clear-cache-btn">Clear Cache</button>
<button class="btn btn-danger" id="clear-all-btn">Clear All Data</button>
</div>
</div>
</div>
<div class="footer">
LinkedIn Job Analyzer v1.0 — Personal Use Only<br>
<span>gemini-flash-lite-latest</span> · Free Tier
</div>
<div class="toast" id="toast"></div>
<script src="profile.js"></script>
<script src="popup.js"></script>
</body>
</html>

168
popup.js Normal file
View File

@@ -0,0 +1,168 @@
// popup.js — Settings popup logic
// ─── Helpers ────────────────────────────────────────────────────────────────
function showToast(msg, type = '') {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = 'toast show ' + type;
setTimeout(() => { toast.className = 'toast'; }, 2500);
}
function setKeyStatus(state) {
const badge = document.getElementById('key-status');
badge.style.display = 'inline-flex';
if (state === 'ok') {
badge.className = 'status-badge success';
badge.innerHTML = '<span class="status-dot"></span> Valid';
} else if (state === 'error') {
badge.className = 'status-badge error';
badge.innerHTML = '<span class="status-dot"></span> Invalid';
} else if (state === 'checking') {
badge.className = 'status-badge checking';
badge.innerHTML = '<span class="status-dot"></span> Testing...';
}
}
function updateUsageUI(count) {
const max = 1000;
const pct = Math.min((count / max) * 100, 100).toFixed(1);
document.getElementById('usage-count').textContent = `${count} / 1,000 requests`;
document.getElementById('usage-fill').style.width = pct + '%';
}
// ─── Load saved settings ────────────────────────────────────────────────────
function loadSettings() {
chrome.storage.sync.get(['apiKey', 'userProfile', 'language'], (data) => {
if (data.apiKey) {
document.getElementById('api-key-input').value = data.apiKey;
setKeyStatus('ok');
}
document.getElementById('profile-textarea').value =
data.userProfile || DEFAULT_PROFILE;
document.getElementById('lang-select').value =
data.language || 'auto';
});
// Usage counter from local storage
const today = new Date().toDateString();
chrome.storage.local.get(['usageDate', 'usageCount'], (data) => {
if (data.usageDate === today) {
updateUsageUI(data.usageCount || 0);
} else {
updateUsageUI(0);
}
});
}
// ─── Save settings ──────────────────────────────────────────────────────────
document.getElementById('save-btn').addEventListener('click', () => {
const apiKey = document.getElementById('api-key-input').value.trim();
const userProfile = document.getElementById('profile-textarea').value.trim();
const language = document.getElementById('lang-select').value;
if (!apiKey) {
showToast('⚠️ Please enter your API Key', 'error');
return;
}
if (!userProfile) {
showToast('⚠️ Profile cannot be empty', 'error');
return;
}
chrome.storage.sync.set({ apiKey, userProfile, language }, () => {
showToast('✅ Settings saved!', 'success');
});
});
// ─── Toggle API key visibility ───────────────────────────────────────────────
document.getElementById('toggle-key').addEventListener('click', () => {
const input = document.getElementById('api-key-input');
const btn = document.getElementById('toggle-key');
if (input.type === 'password') {
input.type = 'text';
btn.textContent = '🙈';
} else {
input.type = 'password';
btn.textContent = '👁';
}
});
// ─── Test API Key ───────────────────────────────────────────────────────────
document.getElementById('test-key-btn').addEventListener('click', async () => {
const apiKey = document.getElementById('api-key-input').value.trim();
if (!apiKey) {
showToast('⚠️ Enter your API key first', 'error');
return;
}
setKeyStatus('checking');
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: 'Say "OK" in one word.' }] }],
generationConfig: { maxOutputTokens: 5 }
})
}
);
if (response.ok) {
setKeyStatus('ok');
showToast('✅ API Key is valid!', 'success');
} else {
const err = await response.json();
setKeyStatus('error');
showToast('❌ Invalid key: ' + (err.error?.message || response.status), 'error');
}
} catch (e) {
setKeyStatus('error');
showToast('❌ Network error: ' + e.message, 'error');
}
});
// ─── Reset profile ──────────────────────────────────────────────────────────
document.getElementById('reset-profile-btn').addEventListener('click', () => {
if (confirm('Reset profile to default?')) {
document.getElementById('profile-textarea').value = DEFAULT_PROFILE;
showToast('🔄 Profile reset to default');
}
});
// ─── Clear cache ─────────────────────────────────────────────────────────────
document.getElementById('clear-cache-btn').addEventListener('click', () => {
chrome.storage.local.remove(['analysisCache'], () => {
showToast('🗑️ Analysis cache cleared');
});
});
// ─── Clear all data ──────────────────────────────────────────────────────────
document.getElementById('clear-all-btn').addEventListener('click', () => {
if (confirm('Clear ALL data including API key and profile?')) {
chrome.storage.sync.clear(() => {
chrome.storage.local.clear(() => {
document.getElementById('api-key-input').value = '';
document.getElementById('profile-textarea').value = DEFAULT_PROFILE;
document.getElementById('lang-select').value = 'auto';
document.getElementById('key-status').style.display = 'none';
updateUsageUI(0);
showToast('🗑️ All data cleared');
});
});
}
});
// ─── Init ────────────────────────────────────────────────────────────────────
loadSettings();

62
profile.js Normal file
View File

@@ -0,0 +1,62 @@
// profile.js — Default user profile (pre-filled)
// This is loaded into the popup and used as the default profile text.
const DEFAULT_PROFILE = `HAMZA AYED — Solutions Architect | Senior Flutter Developer | GIS & Mapping Expert
SUMMARY:
Solutions Architect with 6+ years building complete mobile ecosystems. Built two ride-hailing platforms (Egypt & Syria) with 4,300+ users. Created proprietary mapping infrastructure on OpenStreetMap, custom payment systems, and AI-powered verification. 30+ production apps on Google Play and App Store. Retired Lieutenant Colonel (20 years Jordan Armed Forces).
CORE SKILLS:
- Architecture: System Design, API Architecture, Database Design, Cloud Infrastructure, Security Architecture
- Mobile: Flutter/Dart (Expert), 30+ production apps, GetX/Bloc, Clean Architecture, Offline-first, OTA updates (Shorebird)
- Backend: PHP (production), FastAPI, Flask, NestJS, Django, MySQL, PostgreSQL, Firebase
- GIS/Mapping: OpenStreetMap, Custom Tile Server, Mapping SDK (Flutter + JS, published on pub.dev & NPM), Geocoding, Route Optimization
- FinTech: Stripe, PayPal, Paymob, Custom Payment Gateway, Digital Wallets, Driver Payout Systems
- AI/ML: Gemini Vision Models, Invoice Processing, KYC Verification, Document Analysis, Chatbot Systems
- Security: 3-layer Encryption, freeRASP, SSL/TLS Hardening, Server Hardening, Intrusion Monitoring
- DevOps: Linux Server Admin, CI/CD pipelines, Git, Nginx, Load Balancing, Shorebird Code Push
KEY ACHIEVEMENTS:
- Built IntaleqMaps: proprietary mapping platform on OSM, saving $800$30,000/month vs Google Maps API
- Created custom Flutter Mapping SDK published on pub.dev and NPM
- Designed payment infrastructure for Syria where no standard payment APIs exist
- Built 4-app ecosystem: Rider App, Driver App, Admin Dashboard, Service Portal — all from scratch
- Integrated AI vision models for invoice processing (Musadaq platform)
- 51 professional certifications (Google, IBM, Meta via Coursera)
- $0.78 Driver Customer Acquisition Cost in the competitive Egyptian market
- Government-licensed (NANS accreditation) — only licensed platform in Syrian market
EXPERIENCE:
- CTO & Solutions Architect — Intaleq | Jan 2025Present | Syria/Remote
→ Led full-stack architecture of smart transportation ecosystem: 1,800+ drivers, 2,500+ riders
- Co-Founder & Lead Developer — Tripz Egypt | Jan 2024Present | Cairo/Remote
→ Built Egypt's homegrown ride-hailing platform: 8 ride types, 8% driver commission (lowest in market)
- Mobile Solutions Architect — Freelance | Jan 2017Dec 2023 | Jordan/Remote
→ 25+ production apps across sports, mapping, HR, transit domains for MENA clients
- Operations & Logistics Officer — Jordan Armed Forces | Oct 2003Nov 2023
→ Retired Lieutenant Colonel. Led 2050+ personnel. Crisis management, logistics, security clearance.
EDUCATION:
- BS Mathematics, Mutah University, 20032007 (Very Good)
- Google Data Analytics Professional Certificate
- IBM Data Science Professional Certificate
- Meta Mobile Development Certificate
- 51 total certifications
LANGUAGES: Arabic (Native), English (Professional Working Proficiency)
LOCATION: Amman, Jordan | Open to Remote, Hybrid, On-site (Middle East & Gulf)
CONTACT: hamzaayed@intaleqapp.com | +962 79 858 3052 | linkedin.com/in/hamza-ayed
TARGET ROLES:
1. Solutions Architect (systems, GIS, FinTech focus)
2. Senior Flutter Developer (30+ apps, mobile ecosystems)
3. GIS / Geospatial Developer (mapping, OSM, custom SDK)
4. FinTech Integration Engineer (payments, wallets, gateways)
5. Technical Consultant / Mobile Architecture Lead
NOTABLE PROJECTS:
- Intaleq Smart Mobility Platform (intaleqapp.com)
- Tripz Egypt Ride-hailing (tripz-egypt.com)
- Musadaq — AI invoice processing platform
- IntaleqMaps SDK — pub.dev + NPM published
- 25+ client apps: sports news, HR systems, transit, utilities`;

206
prompts.js Normal file
View File

@@ -0,0 +1,206 @@
// prompts.js — All AI prompts v3
// LANGUAGE RULES: Analysis = match job language. Everything else = ENGLISH ALWAYS.
function buildPromptV2(tab, job, userProfile, language) {
// Only analysis follows job language; rest is always English
const analysisLang = language === 'arabic' ? 'Respond entirely in Arabic.'
: language === 'english' ? 'Respond entirely in English.'
: 'Respond in the same language as the job posting.';
const ctx = [
'Job Title: ' + (job.jobTitle || 'Not specified'),
'Company: ' + (job.company || 'Not specified'),
'Location: ' + (job.location || 'Not specified'),
'Type: ' + (job.jobType || 'Not specified'),
'Description:\n' + (job.description || 'No description available'),
job.skills.length ? 'Required Skills: ' + job.skills.join(', ') : ''
].filter(Boolean).join('\n');
const prof = 'MY PROFESSIONAL PROFILE:\n' + userProfile;
const co = job.company || 'this company';
const loc = job.location || 'Middle East';
const P = {};
P.analysis = `You are an elite career strategist with 15+ years hiring for top tech companies in MENA.
${analysisLang}
Perform a DEEP strategic analysis. Be brutally honest and specific.
${prof}
JOB POSTING:
${ctx}
IMPORTANT: Analyze the ACTUAL job being shown: "${job.jobTitle}" at "${job.company}". Do NOT confuse with any other role.
Respond in this EXACT structure:
## MATCH SCORE: X/100
### Score Breakdown:
- **Technical Skills Match:** X/30 — [1-line why]
- **Experience Level Match:** X/25 — [1-line why]
- **Domain/Industry Match:** X/20 — [1-line why]
- **Location & Logistics:** X/15 — [1-line why]
- **Culture & Soft Skills:** X/10 — [1-line why]
## WHERE I CRUSH IT
- [specific skills from MY profile that match job requirements]
## HONEST GAPS
- [what the job asks that I am missing — be specific]
## STRATEGIC ANGLE
How should I position myself? What is my unfair advantage?
## VERDICT
**[APPLY NOW / APPLY WITH PREP / LONG SHOT / SKIP]**
[2-3 sentence honest reasoning. If SKIP: explain why this job is not suitable and I should not waste time on it.]
## BEST CV TO USE
**[Solutions Architect / Flutter Developer / GIS Developer / FinTech Engineer]**
[Why this variant and what 2-3 bullet points to modify]
## APPLICATION STRATEGY
- [3-5 tactical steps to maximize chances]`;
P.coverletter = `You are an expert career writer. Write a COMPLETE, READY-TO-SEND cover letter.
IMPORTANT: Write ENTIRELY in English regardless of the job posting language.
${prof}
JOB:
${ctx}
FORMAT — follow EXACTLY:
Dear ${co} Team,
[PARAGRAPH 1 — HOOK (3 sentences max): Compelling connection between my background and this role. Mention the company by name. NO generic "I am writing to apply" openers.]
[PARAGRAPH 2 — PROOF (4-5 sentences): Match 3-4 job requirements to my REAL achievements with NUMBERS. Use "At Intaleq, I..." or "When building Tripz, I..." Be concrete.]
[PARAGRAPH 3 — CLOSE (2-3 sentences): Enthusiasm for THIS company. Confident call to action requesting an interview.]
Best regards,
Hamza Ayed
hamzaayed@intaleqapp.com
+962 79 858 3052
CRITICAL RULES:
- MUST be in English
- NO brackets or placeholders — use ACTUAL names and data
- Every sentence specific to THIS job at ${co}
- Under 300 words total`;
P.cvtips = `You are a LinkedIn optimization expert and ATS specialist.
IMPORTANT: Respond ENTIRELY in English regardless of the job posting language.
${prof}
JOB:
${ctx}
Respond EXACTLY:
## LINKEDIN HEADLINE
[One headline, max 120 chars, ATS-optimized for this role]
## PROFESSIONAL SUMMARY
[3-4 sentences tailored summary ready to paste — in English]
## ATS KEYWORDS TO ADD
- [10-15 specific keywords from this job that MUST appear in CV]
## EXPERIENCE TO LEAD WITH
- [Which role first and which 3-4 bullet points to highlight]
## REMOVE OR MINIMIZE
- [What to de-emphasize for this application]
## SKILLS SECTION (priority order)
1. [Most important skill]
2. [Second]
3. [Continue 8-10 skills]
## NEW BULLET POINTS TO ADD
- [2-3 new achievement bullets ready to paste into CV — in English]`;
P.qa = `You are a career coach. Generate ready-to-paste application answers.
IMPORTANT: ALL answers MUST be in English regardless of the job posting language.
${prof}
JOB:
${ctx}
Generate answers for ALL:
## 1. Why are you interested in this role?
[3-4 sentences specific to ${co} and this role — in English]
## 2. Why ${co}?
[2-3 sentences referencing something specific about the company]
## 3. Relevant experience
[4-5 sentences — most relevant achievements for this role]
## 4. Expected salary
[Competitive range for ${loc} market — USD and local currency]
## 5. When can you start?
Available immediately or within 2 weeks.
## 6. Visa sponsorship needed?
Based in Jordan. Open to relocation. Willing to process visa requirements.
## 7. Notice period
Available immediately — currently seeking new opportunities.
## 8. Willing to relocate?
Yes, open to ${loc} and broader Middle East.
## 9. Why are you the best candidate?
[3-4 sentences — unique differentiators for THIS role]
## 10. Key technology experience
[Identify top 2-3 technologies from the job description and write specific answers about my experience]
RULES: ALL answers in English. Ready to paste. No brackets. Concise. Use numbers.`;
P.benefits = `You are a career analyst specializing in tech compensation in MENA.
IMPORTANT: Respond ENTIRELY in English regardless of the job posting language.
${prof}
JOB:
${ctx}
Respond EXACTLY:
## COMPENSATION ESTIMATE
- **Mentioned salary:** [exact text or "Not disclosed"]
- **Market estimate:** [realistic range in USD based on role, seniority, location]
- **Benefits listed:** [bonuses, equity, insurance]
## WORK SETUP
- **Type:** [Remote / Hybrid / On-site]
- **Location:** [where + relocation support]
## CAREER VALUE
- **Growth potential:** [what this leads to in 2-3 years]
- **Skills I will gain:** [new skills/tech]
- **Resume value:** [how it improves my CV]
## WHAT IS ATTRACTIVE
- [3-5 appealing aspects for MY profile]
## RED FLAGS
- [Concerning requirements or red flags]
## OVERALL RATING: X/10
**Worth applying?** [YES / MAYBE / NO]
[2-3 sentence honest assessment]`;
return P[tab] || P.analysis;
}

7
server/composer.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "hamza/cv-generator",
"description": "Dynamic ATS-Optimized CV Generator",
"require": {
"dompdf/dompdf": "^3.0"
}
}

169
server/cv_template.html Normal file
View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
/*
* CSS Optimized for Dompdf / mPDF / TCPDF
* Avoids flexbox, uses standard block/inline styling for flawless PDF generation.
*/
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 13px;
color: #222;
line-height: 1.5;
margin: 0;
padding: 20px 40px;
}
.header {
text-align: center;
border-bottom: 2px solid #1a237e;
padding-bottom: 15px;
margin-bottom: 20px;
}
h1 {
font-size: 28px;
color: #1a237e;
margin: 0 0 5px 0;
text-transform: uppercase;
letter-spacing: 1px;
}
.headline {
font-size: 16px;
font-weight: bold;
color: #424242;
margin-bottom: 5px;
}
.contact {
font-size: 12px;
color: #555;
}
.section-title {
font-size: 15px;
font-weight: bold;
color: #1a237e;
border-bottom: 1px solid #ddd;
margin-top: 20px;
margin-bottom: 10px;
padding-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
p { margin: 0 0 10px 0; text-align: justify; }
.job-block { margin-bottom: 15px; }
.job-header {
width: 100%;
margin-bottom: 5px;
}
.job-title {
font-weight: bold;
font-size: 14px;
color: #222;
float: left;
}
.job-meta {
font-size: 12px;
color: #1a237e;
font-weight: bold;
float: right;
}
.clear { clear: both; }
ul { margin: 5px 0 10px 0; padding-left: 20px; }
li { margin-bottom: 6px; text-align: justify; }
</style>
</head>
<body>
<div class="header">
<h1>Hamza Ayed</h1>
<!-- PHP WILL INJECT THE AI-GENERATED HEADLINE HERE -->
<div class="headline">{{JOB_HEADLINE}}</div>
<div class="contact">
Amman, Jordan | +962 79 858 3052 | hamzaayed@intaleqapp.com | linkedin.com/in/hamza-ayed
</div>
</div>
<div class="section-title">Professional Summary</div>
<!-- PHP WILL INJECT THE AI-GENERATED SUMMARY HERE -->
<p>{{TAILORED_SUMMARY}}</p>
<div class="section-title">Core Competencies & Skills</div>
<!-- PHP WILL INJECT ATS KEYWORDS HERE -->
<p><strong>Targeted Expertise:</strong> {{DYNAMIC_SKILLS}}</p>
<p><strong>Technologies:</strong> Flutter/Dart, PHP, Python (FastAPI/Flask), Node.js, MySQL, PostgreSQL, AWS/Cloud Infrastructure, Git, Docker.</p>
<p><strong>Domains:</strong> System Architecture, Distributed Systems, GIS/Mapping (OSM), FinTech (Payment Gateways), AI/ML Integration, Zero-Trust Security.</p>
<div class="section-title">Professional Experience</div>
<div class="job-block">
<div class="job-header">
<div class="job-title">CTO & Solutions Architect — Intaleq</div>
<div class="job-meta">Jan 2025 Present | Syria / Remote</div>
<div class="clear"></div>
</div>
<ul>
<li>Led the full-stack architecture of a smart transportation ecosystem supporting 1,800+ drivers and 2,500+ riders.</li>
<li>Built a proprietary mapping platform (IntaleqMaps) on OpenStreetMap, eliminating reliance on Google Maps API and saving $10,000+/month in operational costs.</li>
<li>Designed secure, custom payment infrastructure for environments lacking standard payment APIs, ensuring high-availability transaction integrity.</li>
</ul>
</div>
<div class="job-block">
<div class="job-header">
<div class="job-title">Co-Founder & Lead Developer — Tripz Egypt</div>
<div class="job-meta">Jan 2024 Present | Cairo / Remote</div>
<div class="clear"></div>
</div>
<ul>
<li>Architected Egypt's homegrown ride-hailing platform, managing the entire distributed ecosystem with real-time tracking and dispatching.</li>
<li>Implemented robust microservices for real-time driver/rider matching and route optimization using event-driven architecture.</li>
</ul>
</div>
<div class="job-block">
<div class="job-header">
<div class="job-title">Mobile Solutions Architect — Freelance</div>
<div class="job-meta">Jan 2017 Dec 2023 | Jordan / Remote</div>
<div class="clear"></div>
</div>
<ul>
<li>Delivered 25+ production enterprise applications across GIS, FinTech, HR, and utilities for clients across the MENA region.</li>
<li>Integrated AI vision models for document analysis (KYC) and automated invoice processing pipelines.</li>
</ul>
</div>
<div class="job-block">
<div class="job-header">
<div class="job-title">Operations & Logistics Officer — Jordan Armed Forces</div>
<div class="job-meta">Oct 2003 Nov 2023 | Jordan</div>
<div class="clear"></div>
</div>
<ul>
<li>Retired Lieutenant Colonel. Directed logistics and crisis management operations, leading teams of 50+ personnel.</li>
<li>Applied rigorous, security-first methodologies to organizational leadership, disaster recovery, and operational planning.</li>
</ul>
</div>
<div class="section-title">Education & Certifications</div>
<ul>
<li><strong>BS Mathematics</strong>, Mutah University (20032007)</li>
<li>Google Data Analytics Professional Certificate</li>
<li>IBM Data Science Professional Certificate</li>
<li>Meta Mobile Development Certificate</li>
<li><em>Total of 51 professional certifications across software engineering, AI, and enterprise architecture.</em></li>
</ul>
</body>
</html>

134
server/generate_cv.php Normal file
View File

@@ -0,0 +1,134 @@
<?php
// ============================================================================
// Dynamic ATS-Optimized CV Generator (Backend)
// ============================================================================
// Allow CORS from browser extension
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Ensure composer dependencies are installed
$autoloadPath = __DIR__ . '/vendor/autoload.php';
if (!file_exists($autoloadPath)) {
http_response_code(500);
echo json_encode(["error" => "Vendor folder not found. Please run 'composer install' in the server directory."]);
exit;
}
require_once $autoloadPath;
use Dompdf\Dompdf;
use Dompdf\Options;
// 1. Get POST Data
$rawData = file_get_contents('php://input');
$data = json_decode($rawData, true);
$jobDescription = $data['jobDescription'] ?? '';
$apiKey = $data['apiKey'] ?? '';
if (empty($jobDescription) || empty($apiKey)) {
http_response_code(400);
echo json_encode(["error" => "Missing jobDescription or apiKey in POST payload."]);
exit;
}
// 2. Build Gemini API Request
$model = "gemini-2.5-flash";
$geminiUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . $apiKey;
$prompt = "You are an expert ATS CV tailor. Read the following job description and generate tailored content for my CV to maximize my chances of getting an interview.
Return ONLY a valid JSON object with EXACTLY three keys: 'headline', 'summary', and 'skills'.
The 'headline' should be a 5-6 word professional title relevant to the job.
The 'summary' should be a 3-sentence powerful paragraph highlighting skills relevant to the job.
The 'skills' should be a comma-separated list of 10 highly relevant ATS keywords.
Do NOT use markdown blocks like ```json, just return raw JSON text.
Job Description:
" . substr($jobDescription, 0, 4000);
$payload = json_encode([
"contents" => [
["parts" => [["text" => $prompt]]]
],
"generationConfig" => [
"temperature" => 0.2, // Low temperature for consistent JSON
"responseMimeType" => "application/json" // Force JSON output
]
]);
// 3. Call Google Gemini via cURL
$ch = curl_init($geminiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
http_response_code(500);
echo json_encode(["error" => "Gemini API Error", "statusCode" => $httpCode, "details" => json_decode($response)]);
exit;
}
// 4. Parse Gemini Response
$responseData = json_decode($response, true);
$aiText = $responseData['candidates'][0]['content']['parts'][0]['text'] ?? '{}';
// Clean markdown if Gemini still wrapped it in ```json ... ```
$aiText = str_replace(['```json', '```'], '', $aiText);
$aiText = trim($aiText);
$parsedJson = json_decode($aiText, true);
$headline = $parsedJson['headline'] ?? "Solutions Architect & Technical Leader";
$summary = $parsedJson['summary'] ?? "Experienced professional with a strong background in software engineering and system architecture.";
$skills = $parsedJson['skills'] ?? "Architecture, APIs, Cloud, Backend Systems, System Design";
// 5. Load HTML Template and Inject Data
// Note: Put cv_template.html in the same directory as this script on your server
$templatePath = __DIR__ . '/cv_template.html';
if (!file_exists($templatePath)) {
http_response_code(500);
echo json_encode(["error" => "cv_template.html not found on server."]);
exit;
}
$html = file_get_contents($templatePath);
$html = str_replace('{{JOB_HEADLINE}}', htmlspecialchars($headline), $html);
$html = str_replace('{{TAILORED_SUMMARY}}', htmlspecialchars($summary), $html);
$html = str_replace('{{DYNAMIC_SKILLS}}', htmlspecialchars($skills), $html);
// 6. Generate PDF via Dompdf
try {
$options = new Options();
$options->set('isHtml5ParserEnabled', true);
$options->set('defaultFont', 'Helvetica');
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
// 7. Return PDF as Base64 encoded string to the frontend
$pdfOutput = $dompdf->output();
$base64Pdf = base64_encode($pdfOutput);
echo json_encode([
"success" => true,
"pdf" => $base64Pdf,
"filename" => "Hamza_Ayed_Tailored_CV.pdf"
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(["error" => "PDF Generation Failed", "details" => $e->getMessage()]);
}