feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - Landvex</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="header">
<div class="container">
<h1>🏢 Landvex Admin</h1>
</div>
</header>
<main class="container">
<section id="loginSection">
<h2>Admin-inloggning</h2>
<form id="loginForm">
<div class="form-group">
<label for="username">Användarnamn</label>
<input type="text" id="username" required>
</div>
<div class="form-group">
<label for="password">Lösenord</label>
<input type="password" id="password" required>
</div>
<button type="submit" class="btn btn-primary">Logga in</button>
</form>
</section>
<section id="dashboardSection" class="hidden">
<nav class="admin-nav">
<button onclick="showTab('stats')" class="btn btn-small">Statistik</button>
<button onclick="showTab('orders')" class="btn btn-small">Beställningar</button>
<button onclick="showTab('reports')" class="btn btn-small">Rapporter</button>
<button onclick="logout()" class="btn btn-small btn-danger">Logga ut</button>
</nav>
<div id="statsTab" class="tab-content">
<h3>Översikt</h3>
<div id="statsGrid" class="stats-grid"></div>
<h4>Senaste aktivitet</h4>
<div id="recentActivity"></div>
</div>
<div id="ordersTab" class="tab-content hidden">
<h3>Alla beställningar</h3>
<div id="ordersTable"></div>
</div>
<div id="reportsTab" class="tab-content hidden">
<h3>Alla rapporter</h3>
<div id="reportsTable"></div>
</div>
</section>
</main>
<script src="/js/admin.js"></script>
</body>
</html>
+389
View File
@@ -0,0 +1,389 @@
/* Landvex Styles */
:root {
--primary: #1a5276;
--primary-light: #2874a6;
--secondary: #1abc9c;
--danger: #e74c3c;
--warning: #f39c12;
--success: #27ae60;
--bg: #f8f9fa;
--card-bg: #ffffff;
--text: #2c3e50;
--text-light: #7f8c8d;
--border: #dee2e6;
--shadow: 0 2px 10px rgba(0,0,0,0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 0 20px;
}
/* Header */
.header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
padding: 40px 0;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.2em;
opacity: 0.9;
}
/* Hero */
.hero {
text-align: center;
padding: 40px 0;
}
.hero h2 {
color: var(--primary);
margin-bottom: 15px;
}
/* Forms */
.order-form {
background: var(--card-bg);
padding: 30px;
border-radius: 10px;
box-shadow: var(--shadow);
margin-bottom: 40px;
}
.form-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.form-section:last-child {
border-bottom: none;
}
.form-section h3 {
color: var(--primary);
margin-bottom: 20px;
font-size: 1.3em;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--text);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
border: 2px solid var(--border);
border-radius: 6px;
font-size: 1em;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.form-group small {
display: block;
margin-top: 5px;
color: var(--text-light);
}
.checkbox-group {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.checkbox-group label {
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
}
.checkbox-group input {
width: auto;
margin-right: 8px;
}
/* Buttons */
.btn {
display: inline-block;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-light);
}
.btn-secondary {
background: var(--secondary);
color: white;
}
.btn-secondary:hover {
background: #16a085;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-small {
padding: 8px 16px;
font-size: 0.9em;
}
.form-actions {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 30px;
}
/* Results */
.result {
background: var(--card-bg);
padding: 30px;
border-radius: 10px;
box-shadow: var(--shadow);
margin-bottom: 40px;
text-align: center;
}
.success-message {
color: var(--success);
}
.success-message h3 {
margin-bottom: 15px;
}
/* Progress */
.progress-bar {
width: 100%;
height: 20px;
background: var(--border);
border-radius: 10px;
overflow: hidden;
margin: 15px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--secondary), var(--primary));
transition: width 0.5s ease;
border-radius: 10px;
}
/* Report */
.report-header {
text-align: center;
margin-bottom: 30px;
}
.executive-summary {
background: #eaf2f8;
padding: 20px;
border-left: 5px solid var(--primary);
margin: 20px 0;
border-radius: 0 8px 8px 0;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin: 30px 0;
}
.chart-box {
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background: var(--card-bg);
box-shadow: var(--shadow);
border-radius: 8px;
overflow: hidden;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
background: var(--primary);
color: white;
}
tr:hover {
background: #f5f5f5;
}
/* Status badges */
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 600;
}
.badge-pending { background: #fff3cd; color: #856404; }
.badge-processing { background: #cce5ff; color: #004085; }
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
/* Admin */
.admin-nav {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 20px 0;
}
.stat-card {
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
text-align: center;
}
.stat-card h4 {
color: var(--text-light);
font-size: 0.9em;
margin-bottom: 10px;
}
.stat-card .number {
font-size: 2em;
font-weight: bold;
color: var(--primary);
}
/* Utility */
.hidden {
display: none !important;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-light);
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
/* Footer */
.footer {
background: var(--text);
color: white;
text-align: center;
padding: 20px;
margin-top: 40px;
}
.footer a {
color: var(--secondary);
}
/* Print styles for reports */
@media print {
.header, .footer, .report-actions {
display: none;
}
.container {
max-width: 100%;
}
}
/* Responsive */
@media (max-width: 600px) {
.header h1 {
font-size: 1.8em;
}
.form-actions {
flex-direction: column;
}
.checkbox-group {
grid-template-columns: 1fr;
}
.charts-container {
grid-template-columns: 1fr;
}
}
+169
View File
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Landvex - Marknadsintelligens</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="header">
<div class="container">
<h1>🏢 Landvex</h1>
<p>AI-driven marknadsintelligens för din produkt</p>
</div>
</header>
<main class="container">
<section class="hero">
<h2>Få en professionell marknadsrapport inom 72 timmar</h2>
<p>Fyll i formuläret nedan för att få en komplett marknadsanalys med konkurrensanalys, trenddata och strategiska rekommendationer.</p>
</section>
<form id="orderForm" class="order-form">
<div class="form-section">
<h3>Produktinformation</h3>
<div class="form-group">
<label for="productName">Produktnamn *</label>
<input type="text" id="productName" name="productName" required placeholder="t.ex. Omega-3 Kapslar">
</div>
<div class="form-group">
<label for="productType">Produkttyp *</label>
<select id="productType" name="productType" required>
<option value="">Välj produkttyp</option>
<option value="kosttillskott">Kosttillskott</option>
<option value="tech">Teknik/Elektronik</option>
<option value="fastighet">Fastighet</option>
<option value="mode">Mode/Kläder</option>
<option value="livsmedel">Livsmedel</option>
<option value="skonhet">Skönhet</option>
<option value="halsa">Hälsa/Vård</option>
<option value="ovrigt">Övrigt</option>
</select>
</div>
<div class="form-group">
<label for="ingredients">Ingredienser/Teknik</label>
<textarea id="ingredients" name="ingredients" rows="3" placeholder="t.ex. Fiskolja, Vitamin D, Vitamin E"></textarea>
</div>
<div class="form-group">
<label for="usage">Användningsområde</label>
<textarea id="usage" name="usage" rows="3" placeholder="t.ex. Dagligt kosttillskott för hjärthälsa"></textarea>
</div>
<div class="form-group">
<label for="format">Format/Storlek</label>
<input type="text" id="format" name="format" placeholder="t.ex. 60 kapslar, 500ml">
</div>
<div class="form-group">
<label for="priceLevel">Prisnivå</label>
<select id="priceLevel" name="priceLevel">
<option value="">Välj prisnivå</option>
<option value="budget">Budget</option>
<option value="mellan">Mellan</option>
<option value="premium">Premium</option>
<option value="lyx">Lyx</option>
</select>
</div>
<div class="form-group">
<label for="certifications">Certifieringar</label>
<input type="text" id="certifications" name="certifications" placeholder="t.ex. KRAV, EU-ekologiskt, GMP">
</div>
</div>
<div class="form-section">
<h3>Målgrupp & Marknader</h3>
<div class="form-group">
<label>Målgrupp</label>
<div class="checkbox-group">
<label><input type="checkbox" name="targetGroups" value="ungdomar"> Ungdomar (13-25)</label>
<label><input type="checkbox" name="targetGroups" value="vuxna"> Vuxna (25-45)</label>
<label><input type="checkbox" name="targetGroups" value="medelalder"> Medelålder (45-65)</label>
<label><input type="checkbox" name="targetGroups" value="aldre"> Äldre (65+)</label>
<label><input type="checkbox" name="targetGroups" value="halsomedvetna"> Hälso-medvetna</label>
<label><input type="checkbox" name="targetGroups" value="aktiva"> Aktiva/Tränande</label>
<label><input type="checkbox" name="targetGroups" value="familjer"> Familjer</label>
<label><input type="checkbox" name="targetGroups" value="premium"> Premium-konsumenter</label>
</div>
</div>
<div class="form-group">
<label>Nuvarande marknader</label>
<div class="checkbox-group">
<label><input type="checkbox" name="currentMarkets" value="SE"> Sverige</label>
<label><input type="checkbox" name="currentMarkets" value="NO"> Norge</label>
<label><input type="checkbox" name="currentMarkets" value="DK"> Danmark</label>
<label><input type="checkbox" name="currentMarkets" value="FI"> Finland</label>
<label><input type="checkbox" name="currentMarkets" value="DE"> Tyskland</label>
<label><input type="checkbox" name="currentMarkets" value="UK"> Storbritannien</label>
<label><input type="checkbox" name="currentMarkets" value="US"> USA</label>
</div>
</div>
<div class="form-group">
<label>Målmarknader (välj de du vill analysera)</label>
<div class="checkbox-group">
<label><input type="checkbox" name="targetMarkets" value="SE" checked> Sverige</label>
<label><input type="checkbox" name="targetMarkets" value="NO"> Norge</label>
<label><input type="checkbox" name="targetMarkets" value="DK"> Danmark</label>
<label><input type="checkbox" name="targetMarkets" value="FI"> Finland</label>
<label><input type="checkbox" name="targetMarkets" value="DE"> Tyskland</label>
<label><input type="checkbox" name="targetMarkets" value="NL"> Nederländerna</label>
<label><input type="checkbox" name="targetMarkets" value="UK"> Storbritannien</label>
<label><input type="checkbox" name="targetMarkets" value="PL"> Polen</label>
</div>
</div>
</div>
<div class="form-section">
<h3>Leverans</h3>
<div class="form-group">
<label for="email">E-postadress *</label>
<input type="email" id="email" name="email" required placeholder="din@email.com">
<small>Rapporten skickas till denna adress när den är klar.</small>
</div>
<div class="form-group">
<label for="priority">Prioritet</label>
<select id="priority" name="priority">
<option value="normal">Normal (72h)</option>
<option value="high">Hög (48h)</option>
<option value="urgent">Brådskande (24h)</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Beställ rapport</button>
<button type="button" class="btn btn-secondary" onclick="trackOrder()">Spåra beställning</button>
</div>
</form>
<div id="result" class="result hidden">
<div class="success-message">
<h3>✅ Beställning mottagen!</h3>
<p>Din beställning har registrerats och bearbetas.</p>
<div id="orderDetails"></div>
<p><a href="#" id="trackLink" class="btn btn-secondary">Spåra din beställning</a></p>
</div>
</div>
<div id="error" class="error hidden"></div>
</main>
<footer class="footer">
<div class="container">
<p>Landvex AI - Data-driven marknadsintelligens</p>
</div>
</footer>
<script src="/js/main.js"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
let authToken = localStorage.getItem('landvex_admin_token');
if (authToken) {
showDashboard();
}
document.getElementById('loginForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const result = await response.json();
if (response.ok) {
authToken = result.data.token;
localStorage.setItem('landvex_admin_token', authToken);
showDashboard();
} else {
alert(result.error || 'Inloggning misslyckades');
}
} catch (error) {
alert('Kunde inte logga in');
}
});
function showDashboard() {
document.getElementById('loginSection').classList.add('hidden');
document.getElementById('dashboardSection').classList.remove('hidden');
loadStats();
}
function logout() {
localStorage.removeItem('landvex_admin_token');
authToken = null;
location.reload();
}
function showTab(tab) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.getElementById(`${tab}Tab`).classList.remove('hidden');
if (tab === 'orders') loadOrders();
if (tab === 'reports') loadReports();
}
async function loadStats() {
try {
const response = await fetch('/api/admin/stats', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const result = await response.json();
if (response.ok) {
const stats = result.data;
document.getElementById('statsGrid').innerHTML = `
<div class="stat-card">
<h4>Totalt antal beställningar</h4>
<div class="number">${stats.orders.total}</div>
</div>
<div class="stat-card">
<h4>Genomförda</h4>
<div class="number">${stats.orders.completed}</div>
</div>
<div class="stat-card">
<h4>Väntande</h4>
<div class="number">${stats.orders.pending}</div>
</div>
<div class="stat-card">
<h4>Genomförandedgrad</h4>
<div class="number">${stats.orders.completionRate}%</div>
</div>
`;
document.getElementById('recentActivity').innerHTML = stats.recentOrders.map(order => `
<div class="order-card">
<p><strong>${order.productName}</strong> - ${translateStatus(order.status)}</p>
<small>${new Date(order.createdAt).toLocaleString('sv-SE')}</small>
</div>
`).join('');
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
async function loadOrders() {
try {
const response = await fetch('/api/orders', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const result = await response.json();
if (response.ok) {
const html = `
<table>
<thead>
<tr>
<th>Produkt</th>
<th>E-post</th>
<th>Status</th>
<th>Framsteg</th>
<th>Datum</th>
</tr>
</thead>
<tbody>
${result.data.map(order => `
<tr>
<td>${order.productName}</td>
<td>${order.email}</td>
<td><span class="badge badge-${order.status}">${translateStatus(order.status)}</span></td>
<td>${order.progress}%</td>
<td>${new Date(order.createdAt).toLocaleDateString('sv-SE')}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('ordersTable').innerHTML = html;
}
} catch (error) {
console.error('Error loading orders:', error);
}
}
async function loadReports() {
try {
const response = await fetch('/api/reports', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const result = await response.json();
if (response.ok) {
const html = `
<table>
<thead>
<tr>
<th>Titel</th>
<th>Produkt</th>
<th>Genererad</th>
<th>Åtgärder</th>
</tr>
</thead>
<tbody>
${result.data.map(report => `
<tr>
<td>${report.title}</td>
<td>${report.order?.productName || 'N/A'}</td>
<td>${new Date(report.generatedAt).toLocaleDateString('sv-SE')}</td>
<td>
<a href="/api/reports/${report.id}/html" target="_blank" class="btn btn-small">Visa</a>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('reportsTable').innerHTML = html;
}
} catch (error) {
console.error('Error loading reports:', error);
}
}
function translateStatus(status) {
const translations = {
pending: 'Väntar',
data_collection: 'Samlar data',
analysis: 'Analyserar',
report_generation: 'Genererar rapport',
completed: 'Klar',
failed: 'Misslyckades'
};
return translations[status] || status;
}
+69
View File
@@ -0,0 +1,69 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('orderForm');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = {
productName: formData.get('productName'),
productType: formData.get('productType'),
ingredients: formData.get('ingredients'),
usage: formData.get('usage'),
format: formData.get('format'),
priceLevel: formData.get('priceLevel'),
certifications: formData.get('certifications'),
targetGroups: Array.from(form.querySelectorAll('input[name="targetGroups"]:checked')).map(cb => cb.value),
targetMarkets: Array.from(form.querySelectorAll('input[name="targetMarkets"]:checked')).map(cb => cb.value),
currentMarkets: Array.from(form.querySelectorAll('input[name="currentMarkets"]:checked')).map(cb => cb.value),
email: formData.get('email'),
priority: formData.get('priority')
};
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
showResult(result.data);
} else {
showError(result.error || 'Ett fel inträffade');
}
} catch (error) {
showError('Kunde inte skicka beställningen. Försök igen.');
}
});
});
function showResult(data) {
document.getElementById('orderForm').classList.add('hidden');
document.getElementById('result').classList.remove('hidden');
document.getElementById('orderDetails').innerHTML = `
<p><strong>Beställnings-ID:</strong> ${data.id}</p>
<p><strong>Produkt:</strong> ${data.productName}</p>
<p><strong>Beräknad klar:</strong> ${new Date(data.estimatedCompletion).toLocaleString('sv-SE')}</p>
`;
document.getElementById('trackLink').href = `/status?email=${encodeURIComponent(data.email)}`;
}
function showError(message) {
const errorDiv = document.getElementById('error');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
function trackOrder() {
const email = document.getElementById('email').value;
if (email) {
window.location.href = `/status?email=${encodeURIComponent(email)}`;
} else {
window.location.href = '/status';
}
}
+88
View File
@@ -0,0 +1,88 @@
document.addEventListener('DOMContentLoaded', async () => {
const urlParams = new URLSearchParams(window.location.search);
const orderId = urlParams.get('orderId');
const reportId = urlParams.get('id');
if (!orderId && !reportId) {
showError('Ingen rapport angiven');
return;
}
try {
let response;
if (orderId) {
response = await fetch(`/api/reports/order/${orderId}`);
} else {
response = await fetch(`/api/reports/${reportId}`);
}
const result = await response.json();
if (response.ok) {
displayReport(result.data);
} else {
showError(result.error || 'Rapport hittades inte');
}
} catch (error) {
showError('Kunde inte ladda rapporten');
}
});
function displayReport(report) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('reportContent').classList.remove('hidden');
document.getElementById('reportTitle').textContent = report.title;
document.getElementById('reportMeta').innerHTML = `
Genererad: ${new Date(report.generatedAt).toLocaleString('sv-SE')} |
Order: ${report.order?.productName || 'N/A'}
`;
if (report.executiveSummary) {
document.getElementById('executiveSummary').innerHTML = `
<h3>Exekutiv Sammanfattning</h3>
<p>${report.executiveSummary}</p>
`;
}
// Render charts
if (report.charts && report.charts.length > 0) {
const container = document.getElementById('chartsContainer');
container.innerHTML = '';
report.charts.forEach((chart, index) => {
const chartDiv = document.createElement('div');
chartDiv.className = 'chart-box';
chartDiv.innerHTML = `<canvas id="chart${index}"></canvas>`;
container.appendChild(chartDiv);
new Chart(document.getElementById(`chart${index}`), {
type: chart.type,
data: chart.data,
options: {
responsive: true,
plugins: {
title: {
display: true,
text: chart.title
}
}
}
});
});
}
// Render report content
if (report.htmlContent) {
document.getElementById('reportBody').innerHTML = report.htmlContent;
} else if (report.content) {
document.getElementById('reportBody').innerHTML = report.content;
}
}
function showError(message) {
document.getElementById('loading').classList.add('hidden');
const errorDiv = document.getElementById('error');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
+88
View File
@@ -0,0 +1,88 @@
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const email = urlParams.get('email');
if (email) {
document.getElementById('trackEmail').value = email;
loadOrders();
}
});
async function loadOrders() {
const email = document.getElementById('trackEmail').value;
if (!email) {
alert('Ange en e-postadress');
return;
}
try {
const response = await fetch(`/api/orders/track?email=${encodeURIComponent(email)}`);
const result = await response.json();
if (response.ok && result.data.length > 0) {
displayOrders(result.data);
} else {
document.getElementById('ordersList').innerHTML = '<p>Inga beställningar hittades.</p>';
}
} catch (error) {
document.getElementById('ordersList').innerHTML = '<p>Kunde inte ladda beställningar.</p>';
}
}
function displayOrders(orders) {
const html = orders.map(order => `
<div class="order-card" onclick="showOrderDetail('${order.id}')">
<h4>${order.productName}</h4>
<p>Status: <span class="badge badge-${order.status}">${translateStatus(order.status)}</span></p>
<p>Beställd: ${new Date(order.createdAt).toLocaleString('sv-SE')}</p>
<p>Framsteg: ${order.progress}%</p>
</div>
`).join('');
document.getElementById('ordersList').innerHTML = html;
}
async function showOrderDetail(orderId) {
try {
const response = await fetch(`/api/orders/${orderId}`);
const result = await response.json();
if (response.ok) {
const order = result.data;
document.getElementById('orderDetail').classList.remove('hidden');
document.getElementById('detailContent').innerHTML = `
<p><strong>Produkt:</strong> ${order.productName}</p>
<p><strong>Typ:</strong> ${order.productType}</p>
<p><strong>Status:</strong> ${translateStatus(order.status)}</p>
<p><strong>Beställd:</strong> ${new Date(order.createdAt).toLocaleString('sv-SE')}</p>
<p><strong>Beräknad klar:</strong> ${order.estimatedCompletion ? new Date(order.estimatedCompletion).toLocaleString('sv-SE') : 'Ej satt'}</p>
`;
document.getElementById('progressFill').style.width = `${order.progress}%`;
document.getElementById('progressText').textContent = order.progressMessage || translateStatus(order.status);
if (order.status === 'completed') {
document.getElementById('reportSection').classList.remove('hidden');
document.getElementById('viewReportBtn').href = `/report-view?orderId=${order.id}`;
} else {
document.getElementById('reportSection').classList.add('hidden');
}
}
} catch (error) {
console.error('Error loading order detail:', error);
}
}
function translateStatus(status) {
const translations = {
pending: 'Väntar',
data_collection: 'Samlar data',
analysis: 'Analyserar',
report_generation: 'Genererar rapport',
completed: 'Klar',
failed: 'Misslyckades'
};
return translations[status] || status;
}
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rapport - Landvex</title>
<link rel="stylesheet" href="/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<header class="header">
<div class="container">
<h1>🏢 Landvex</h1>
<p>Marknadsintelligensrapport</p>
</div>
</header>
<main class="container">
<div id="loading" class="loading">
<p>Laddar rapport...</p>
</div>
<div id="reportContent" class="hidden">
<div class="report-header">
<h2 id="reportTitle"></h2>
<p id="reportMeta"></p>
</div>
<div id="executiveSummary" class="executive-summary"></div>
<div id="chartsContainer" class="charts-container"></div>
<div id="reportBody"></div>
<div class="report-actions">
<button onclick="window.print()" class="btn btn-secondary">Skriv ut / Spara som PDF</button>
<a href="/" class="btn btn-secondary">Ny beställning</a>
</div>
</div>
<div id="error" class="error hidden"></div>
</main>
<script src="/js/report.js"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spåra beställning - Landvex</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="header">
<div class="container">
<h1>🏢 Landvex</h1>
<p>Spåra din beställning</p>
</div>
</header>
<main class="container">
<section class="track-section">
<h2>Spåra din beställning</h2>
<div class="form-group">
<label for="trackEmail">E-postadress</label>
<input type="email" id="trackEmail" placeholder="din@email.com">
<button onclick="loadOrders()" class="btn btn-primary">Sök</button>
</div>
<div id="ordersList"></div>
</section>
<section id="orderDetail" class="hidden">
<h3>Beställningsdetaljer</h3>
<div id="detailContent"></div>
<div id="progressSection">
<h4>Framsteg</h4>
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
<p id="progressText"></p>
</div>
<div id="reportSection" class="hidden">
<h4>Din rapport</h4>
<a id="viewReportBtn" href="#" class="btn btn-primary">Visa rapport</a>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<p><a href="/">← Tillbaka till startsidan</a></p>
</div>
</footer>
<script src="/js/status.js"></script>
</body>
</html>