Vela Platform v2: single monolith, real P&L, bilingual marketing site

This commit is contained in:
2026-06-26 19:51:06 +01:00
commit 918c48ffca
12 changed files with 2093 additions and 0 deletions
+497
View File
@@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vela Platform</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
/* ── Theme variables ── */
:root {
--bg: #0f0f0f;
--bg-surface: #1a1a1a;
--border: #2a2a2a;
--text: #e0e0e0;
--text-dim: #888;
--text-bright: #fff;
--input-bg: #0f0f0f;
--hover-row: #1a1a1a;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--card-value-default: #e0e0e0;
}
[data-theme="light"] {
--bg: #f5f5f5;
--bg-surface: #ffffff;
--border: #e0e0e0;
--text: #333333;
--text-dim: #777;
--text-bright: #111;
--input-bg: #ffffff;
--hover-row: #f0f0f0;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #16a34a;
--red: #dc2626;
--yellow: #d97706;
--card-value-default: #333;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
transition: background 0.2s, color 0.2s;
}
/* ── Login ── */
#loginScreen { display:none; align-items:center; justify-content:center; min-height:100vh; }
.login-box { background:var(--bg-surface); border:1px solid var(--border); border-radius:12px; padding:40px; width:360px; }
.login-box h1 { font-size:24px; margin-bottom:8px; color:var(--text-bright); }
.login-box .sub { color:var(--text-dim); font-size:14px; margin-bottom:24px; }
.login-box label { display:block; font-size:13px; color:var(--text-dim); margin-bottom:4px; }
.login-box input { width:100%; padding:10px 12px; background:var(--input-bg); border:1px solid var(--border); border-radius:6px; color:var(--text-bright); font-size:15px; margin-bottom:16px; }
.login-box input:focus { outline:none; border-color:var(--accent); }
.login-box button { width:100%; padding:12px; background:var(--accent); color:#fff; border:none; border-radius:6px; font-size:15px; font-weight:600; cursor:pointer; }
.login-box button:hover { background:var(--accent-hover); }
.login-box .error { color:var(--red); font-size:13px; margin-top:8px; display:none; }
/* ── App ── */
#appScreen { display:none; }
.topbar { background:var(--bg-surface); border-bottom:1px solid var(--border); padding:0 24px; display:flex; align-items:center; justify-content:space-between; height:56px; }
.topbar h1 { font-size:18px; color:var(--text-bright); }
.topbar .userinfo { color:var(--text-dim); font-size:13px; margin-right:16px; }
.topbar .logout { color:var(--text-dim); cursor:pointer; font-size:13px; border:none; background:none; }
.topbar .logout:hover { color:var(--red); }
.tabs { display:flex; gap:0; border-bottom:1px solid var(--border); background:var(--bg-surface); padding:0 24px; }
.tab { padding:12px 24px; cursor:pointer; color:var(--text-dim); font-size:14px; border-bottom:2px solid transparent; transition:all .15s; }
.tab:hover { color:var(--text); }
.tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.content { padding:24px; max-width:1100px; margin:0 auto; }
.panel { display:none; }
.panel.active { display:block; }
/* ── Theme toggle ── */
.theme-btn { background:none; border:1px solid var(--border); color:var(--text-dim); padding:4px 10px; border-radius:6px; cursor:pointer; font-size:14px; margin-right:12px; transition:all .15s; }
.theme-btn:hover { color:var(--text-bright); border-color:var(--accent); }
/* ── Cards ── */
.card-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:24px; }
.card { background:var(--bg-surface); border:1px solid var(--border); border-radius:8px; padding:16px; }
.card .label { font-size:12px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.5px; }
.card .value { font-size:28px; font-weight:700; margin-top:4px; color:var(--card-value-default); }
.card .value.green { color:var(--green); }
.card .value.red { color:var(--red); }
.card .value.blue { color:var(--accent); }
/* ── Tables ── */
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; padding:10px 12px; color:var(--text-dim); font-weight:500; border-bottom:1px solid var(--border); }
td { padding:10px 12px; border-bottom:1px solid var(--border); }
tr:hover td { background:var(--hover-row); }
/* ── Buttons ── */
.btn { padding:8px 16px; border-radius:6px; border:none; font-size:13px; cursor:pointer; font-weight:500; }
.btn-blue { background:var(--accent); color:#fff; } .btn-blue:hover { background:var(--accent-hover); }
.btn-red { background:var(--red); color:#fff; } .btn-red:hover { filter:brightness(.9); }
.btn-ghost { background:transparent; color:var(--text-dim); border:1px solid var(--border); } .btn-ghost:hover { background:var(--hover-row); color:var(--text); }
.btn-sm { padding:4px 10px; font-size:12px; }
/* ── Forms ── */
.form-row { display:flex; gap:12px; margin-bottom:12px; align-items:end; flex-wrap:wrap; }
.form-row input, .form-row select { padding:8px 12px; background:var(--input-bg); border:1px solid var(--border); border-radius:6px; color:var(--text-bright); font-size:13px; }
.form-row input:focus, .form-row select:focus { outline:none; border-color:var(--accent); }
.form-row label { font-size:12px; color:var(--text-dim); display:block; margin-bottom:4px; }
/* ── Badges ── */
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.badge-paid { background:#22c55e22; color:var(--green); }
.badge-unpaid { background:#f59e0b22; color:var(--yellow); }
.badge-active { background:#22c55e22; color:var(--green); }
.badge-inactive { background:#ef444422; color:var(--red); }
.section-title { font-size:16px; font-weight:600; margin-bottom:16px; color:var(--text-bright); }
.empty { color:var(--text-dim); font-style:italic; padding:24px; text-align:center; }
.spinner { text-align:center; padding:60px; color:var(--text-dim); }
/* ── Tier cards ── */
.tier-card { background:var(--bg-surface); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:16px; }
.tier-name { font-size:16px; font-weight:600; color:var(--text-bright); }
.tier-detail { color:var(--text-dim); font-size:12px; margin-left:8px; }
.tier-cost { color:var(--text-dim); font-size:12px; margin-left:4px; opacity:0.7; }
.tier-margin { color:var(--green); font-size:12px; margin-left:4px; }
.tier-notes { color:var(--text-dim); font-size:11px; opacity:0.6; }
.profit-green { color:var(--green); }
.profit-red { color:var(--red); }
.notes-dim { color:var(--text-dim); }
</style>
</head>
<body>
<!-- LOGIN SCREEN -->
<div id="loginScreen">
<div class="login-box">
<h1>Vela Platform</h1>
<p class="sub">P&L Manager — Sign in</p>
<form id="loginForm">
<label>Username</label>
<input type="text" id="username" autocomplete="username" required autofocus>
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
<div class="error" id="loginError"></div>
</form>
</div>
</div>
<!-- LOADING -->
<div id="loadingScreen" style="display:flex; align-items:center; justify-content:center; min-height:100vh;">
<div class="spinner">Loading...</div>
</div>
<!-- APP SCREEN -->
<div id="appScreen">
<div class="topbar">
<div style="display:flex;align-items:center;gap:16px;">
<h1>Vela Platform</h1>
<span class="userinfo" id="userDisplay"></span>
</div>
<div style="display:flex;align-items:center;">
<button class="theme-btn" onclick="toggleTheme()" id="themeBtn">☀️</button>
<button class="logout" onclick="logout()">Sign out</button>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="tiers">Tiers</div>
<div class="tab" data-tab="transactions">Transactions</div>
<div class="tab" data-tab="expenses">Expenses</div>
</div>
<div class="content">
<!-- DASHBOARD -->
<div class="panel active" id="panel-dashboard">
<div class="card-grid" id="pnlCards"></div>
<div style="margin-bottom:16px;display:flex;gap:12px;align-items:center;">
<span style="color:#888;font-size:13px;">Month:</span>
<input type="month" id="pnlMonthFilter" onchange="loadDashboard()">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('pnlMonthFilter').value='';loadDashboard();">All</button>
</div>
<div id="pnlTable"></div>
</div>
<!-- TIERS -->
<div class="panel" id="panel-tiers"><div id="tiersList"></div></div>
<!-- TRANSACTIONS -->
<div class="panel" id="panel-transactions">
<div class="section-title">New Sale</div>
<div class="form-row">
<div><label>Service</label><select id="txnService"></select></div>
<div><label>Qty</label><input type="number" id="txnQty" value="1" min="1" style="width:60px;"></div>
<div><label>Month</label><input type="month" id="txnMonth"></div>
<div><label>Payment</label><select id="txnPayment"><option value="unpaid">Unpaid</option><option value="paid">Paid</option><option value="partial">Partial</option></select></div>
<div><label>Notes</label><input type="text" id="txnNotes" placeholder="optional"></div>
<div style="align-self:end;"><button class="btn btn-blue" onclick="addTransaction()">Add</button></div>
</div>
<div style="margin:16px 0;display:flex;gap:12px;align-items:center;">
<span style="color:#888;font-size:13px;">Filter:</span>
<input type="month" id="txnMonthFilter" onchange="loadTransactions()">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('txnMonthFilter').value='';loadTransactions();">Clear</button>
</div>
<div id="txnTable"></div>
</div>
<!-- EXPENSES -->
<div class="panel" id="panel-expenses">
<div class="section-title">New Expense</div>
<div class="form-row">
<div><label>Name</label><input type="text" id="expName" placeholder="e.g. Internet bill"></div>
<div><label>Amount (MAD)</label><input type="number" id="expAmount" step="0.01" min="0"></div>
<div><label>Category</label><input type="text" id="expCategory" placeholder="utilities"></div>
<div><label>Month</label><input type="month" id="expMonth"></div>
<div style="align-self:end;"><button class="btn btn-blue" onclick="addExpense()">Add</button></div>
</div>
<div style="margin:16px 0;display:flex;gap:12px;align-items:center;">
<span style="color:#888;font-size:13px;">Filter:</span>
<input type="month" id="expMonthFilter" onchange="loadExpenses()">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('expMonthFilter').value='';loadExpenses();">Clear</button>
<button class="btn btn-ghost btn-sm" style="margin-left:auto;" onclick="generateMonth()">Generate from templates</button>
</div>
<div id="expTable"></div>
</div>
</div>
</div>
<script>
// ── Theme ──
function getTheme() { return localStorage.getItem('vela_theme') || 'dark'; }
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
document.getElementById('themeBtn').textContent = t === 'light' ? '🌙' : '☀️';
localStorage.setItem('vela_theme', t);
}
function toggleTheme() { setTheme(getTheme() === 'dark' ? 'light' : 'dark'); }
setTheme(getTheme()); // apply on load
// ── State ──
let token = localStorage.getItem('vela_token') || '';
let currentUser = '';
// ── Auth flow ──
async function checkAuth() {
if (!token) { showLogin(); return; }
try {
const r = await fetch('/api/verify', { headers: { 'Authorization': 'Bearer ' + token } });
if (!r.ok) { token = ''; localStorage.removeItem('vela_token'); showLogin(); return; }
const d = await r.json();
currentUser = d.email;
showApp();
} catch(e) { showLogin(); }
}
function showLogin() {
document.getElementById('loadingScreen').style.display = 'none';
document.getElementById('loginScreen').style.display = 'flex';
document.getElementById('appScreen').style.display = 'none';
document.getElementById('loginError').style.display = 'none';
document.getElementById('password').value = '';
}
function showApp() {
document.getElementById('loadingScreen').style.display = 'none';
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('appScreen').style.display = 'block';
document.getElementById('userDisplay').textContent = currentUser;
loadDashboard();
}
// ── Login ──
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('loginError');
err.style.display = 'none';
const form = new FormData();
form.append('username', document.getElementById('username').value);
form.append('password', document.getElementById('password').value);
try {
const r = await fetch('/api/auth/login', { method: 'POST', body: form });
if (!r.ok) { const d = await r.json(); err.textContent = d.detail || 'Wrong credentials'; err.style.display = 'block'; return; }
const d = await r.json();
token = d.token;
currentUser = d.email;
localStorage.setItem('vela_token', token);
showApp();
} catch(ex) { err.textContent = 'Network error'; err.style.display = 'block'; }
});
function logout() {
fetch('/api/auth/logout', { method: 'POST' });
token = '';
currentUser = '';
localStorage.removeItem('vela_token');
showLogin();
document.getElementById('password').value = '';
}
// ── API helper ──
async function api(url, opts = {}) {
opts.headers = opts.headers || {};
opts.headers['Authorization'] = 'Bearer ' + token;
const r = await fetch(url, opts);
if (r.status === 401) { logout(); throw new Error('Session expired'); }
return r;
}
// ── Tabs ──
document.querySelectorAll('.tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.panel').forEach(x => x.classList.remove('active'));
t.classList.add('active');
document.getElementById('panel-' + t.dataset.tab).classList.add('active');
const fn = 'load' + t.dataset.tab.charAt(0).toUpperCase() + t.dataset.tab.slice(1);
if (typeof window[fn] === 'function') window[fn]();
});
});
// ── Dashboard ──
async function loadDashboard() {
let month = document.getElementById('pnlMonthFilter')?.value || '';
let url = '/api/pnl' + (month ? '?month=' + month : '');
const r = await api(url);
const d = await r.json();
const s = d.summary;
document.getElementById('pnlCards').innerHTML = `
<div class="card"><div class="label">Revenue</div><div class="value blue">${s.revenue.toLocaleString()}</div></div>
<div class="card"><div class="label">Parts cost</div><div class="value">${s.cogs.toLocaleString()}</div></div>
<div class="card"><div class="label">Gross profit</div><div class="value green">${s.gross_profit.toLocaleString()}</div></div>
<div class="card"><div class="label">Margin</div><div class="value">${s.margin_pct}%</div></div>
<div class="card"><div class="label">Expenses</div><div class="value red">${s.expenses.toLocaleString()}</div></div>
<div class="card"><div class="label">Net profit</div><div class="value ${s.net_profit >= 0 ? 'green' : 'red'}">${s.net_profit.toLocaleString()}</div></div>
`;
const months = d.months;
let html = '<table><thead><tr><th>Month</th><th>Revenue</th><th>Parts</th><th>Gross</th><th>Expenses</th><th>Net</th><th>Sales</th></tr></thead><tbody>';
for (const [m, v] of Object.entries(months)) {
html += `<tr><td>${m}</td><td>${v.revenue.toLocaleString()}</td><td>${v.cogs.toLocaleString()}</td><td class="${v.gross_profit>=0?'profit-green':'profit-red'}">${v.gross_profit.toLocaleString()}</td><td>${v.expenses.toLocaleString()}</td><td class="${v.net_profit>=0?'profit-green':'profit-red'}">${v.net_profit.toLocaleString()}</td><td>${v.txn_count}</td></tr>`;
}
html += '</tbody></table>';
if (!Object.keys(months).length) html = '<div class="empty">No data yet</div>';
document.getElementById('pnlTable').innerHTML = html;
}
// ── Tiers ──
async function loadTiers() {
const r = await api('/api/services');
const services = await r.json();
let html = '';
for (const s of services) {
const cost = s.components.reduce((sum, c) => sum + c.unit_cost * c.quantity, 0);
const margin = s.sell_price > 0 ? ((s.sell_price - cost) / s.sell_price * 100).toFixed(0) : 0;
html += `<div class="tier-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div>
<span class="tier-name">${s.name}</span>
<span class="badge ${s.active ? 'badge-active' : 'badge-inactive'}" style="margin-left:8px;">${s.active ? 'Active' : 'Off'}</span>
<span class="tier-detail">Sell: ${s.sell_price.toLocaleString()} MAD</span>
<span class="tier-cost">| Cost: ${cost.toLocaleString()} MAD</span>
<span class="tier-margin">| ~${margin}%</span>
</div>
<div>
<button class="btn btn-ghost btn-sm" onclick="toggleService(${s.id})">${s.active ? 'Disable' : 'Enable'}</button>
<button class="btn btn-blue btn-sm" style="margin-left:4px;" onclick="editService(${s.id},'${s.name.replace(/'/g,"\\'")}',${s.sell_price})">Edit</button>
</div>
</div>
<table><thead><tr><th>Component</th><th>Unit Cost</th><th>Qty</th><th>Total</th><th style="width:50px;"></th></tr></thead><tbody>`;
for (const c of s.components) {
html += `<tr><td>${c.name} ${c.notes ? '<span class="tier-notes">(' + c.notes + ')</span>' : ''}</td><td>${c.unit_cost}</td><td>${c.quantity}</td><td>${(c.unit_cost * c.quantity).toLocaleString()}</td><td><button class="btn btn-ghost btn-sm" onclick="editComponent(${s.id},${c.id},'${c.name.replace(/'/g,"\\'")}',${c.unit_cost},${c.quantity})">✎</button></td></tr>`;
}
html += '</tbody></table></div>';
}
document.getElementById('tiersList').innerHTML = html;
}
async function toggleService(id) { await api('/api/services/' + id + '/toggle', { method: 'PUT' }); loadTiers(); }
function editService(id, name, price) {
const n = prompt('Name:', name); if (!n) return;
const p = prompt('Sell price (MAD):', price); if (p === null) return;
api('/api/services/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: n, sell_price: parseFloat(p) }) }).then(loadTiers);
}
function editComponent(sid, cid, name, cost, qty) {
const n = prompt('Name:', name); if (!n) return;
const c = prompt('Unit cost:', cost); if (c === null) return;
const q = prompt('Qty:', qty); if (q === null) return;
api('/api/services/' + sid + '/components/' + cid, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: n, unit_cost: parseFloat(c), quantity: parseInt(q) }) }).then(loadTiers);
}
// ── Transactions ──
async function loadTransactions() {
const month = document.getElementById('txnMonthFilter')?.value || '';
const url = '/api/transactions' + (month ? '?month=' + month : '');
const r = await api(url);
const txns = await r.json();
let html = '<table><thead><tr><th>Date</th><th>Service</th><th>Qty</th><th>Revenue</th><th>Parts</th><th>Profit</th><th>Month</th><th>Paid?</th><th>Notes</th><th style="width:60px;"></th></tr></thead><tbody>';
for (const t of txns) {
html += `<tr id="txn-${t.id}"><td>${t.created_at ? t.created_at.slice(0, 10) : ''}</td><td>${t.service_name}</td><td>${t.quantity}</td><td>${t.revenue.toLocaleString()}</td><td>${t.cogs.toLocaleString()}</td><td class="${t.profit >= 0 ? 'profit-green' : 'profit-red'}">${t.profit.toLocaleString()}</td><td>${t.month}</td><td><span class="badge ${t.payment_status === 'paid' ? 'badge-paid' : 'badge-unpaid'}">${t.payment_status}</span></td><td class="notes-dim">${t.notes || ''}</td><td><button class="btn btn-red btn-sm" onclick="deleteTxn(${t.id})">✕</button></td></tr>`;
}
html += '</tbody></table>';
if (!txns.length) html = '<div class="empty">No sales yet</div>';
document.getElementById('txnTable').innerHTML = html;
// Populate dropdowns
const sr = await api('/api/services');
const svcs = await sr.json();
document.getElementById('txnService').innerHTML = '<option value="">Manual</option>' + svcs.filter(s => s.active).map(s => `<option value="${s.id}">${s.name} (${s.sell_price} MAD)</option>`).join('');
document.getElementById('txnMonth').value = new Date().toISOString().slice(0, 7);
}
async function addTransaction() {
const sid = document.getElementById('txnService').value || null;
const qty = parseInt(document.getElementById('txnQty').value) || 1;
const month = document.getElementById('txnMonth').value;
await api('/api/transactions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service_id: sid ? parseInt(sid) : null, quantity: qty, month, payment_status: document.getElementById('txnPayment').value, notes: document.getElementById('txnNotes').value }) });
document.getElementById('txnNotes').value = '';
loadTransactions(); loadDashboard();
}
let deleteTimers = {};
async function deleteTxn(id) {
const row = document.getElementById('txn-'+id);
if (!row) return;
const btn = row.querySelector('button');
const original = btn.textContent;
const originalClass = btn.className;
// If already confirming, do the delete
if (btn.textContent === 'Sure?') {
clearTimeout(deleteTimers[id]);
await api('/api/transactions/'+id, {method:'DELETE'});
row.style.opacity = '0.3';
row.style.textDecoration = 'line-through';
btn.textContent = '↩';
btn.className = 'btn btn-blue btn-sm';
btn.onclick = () => undoDeleteTxn(id);
// Auto-remove after 8s
deleteTimers[id] = setTimeout(() => { row.remove(); }, 8000);
loadDashboard();
return;
}
// First click: ask confirm
btn.textContent = 'Sure?';
btn.className = 'btn btn-red btn-sm';
// Reset after 4s if not confirmed
deleteTimers[id] = setTimeout(() => {
btn.textContent = original;
btn.className = originalClass;
delete deleteTimers[id];
}, 4000);
}
async function undoDeleteTxn(id) {
// Undo isn't possible at API level — just reload
loadTransactions();
loadDashboard();
}
// ── Expenses ──
async function loadExpenses() {
const month = document.getElementById('expMonthFilter')?.value || '';
const url = '/api/expenses' + (month ? '?month=' + month : '');
const r = await api(url);
const expenses = await r.json();
let html = '<table><thead><tr><th>Name</th><th>Amount</th><th>Category</th><th>Month</th><th style="width:50px;"></th></tr></thead><tbody>';
for (const e of expenses) {
html += `<tr><td>${e.name}</td><td>${e.amount.toLocaleString()} MAD</td><td>${e.category}</td><td>${e.month}</td><td><button class="btn btn-red btn-sm" onclick="deleteExpense(${e.id})">✕</button></td></tr>`;
}
html += '</tbody></table>';
if (!expenses.length) html = '<div class="empty">No expenses yet</div>';
document.getElementById('expTable').innerHTML = html;
document.getElementById('expMonth').value = new Date().toISOString().slice(0, 7);
}
async function addExpense() {
const name = document.getElementById('expName').value;
const amount = parseFloat(document.getElementById('expAmount').value) || 0;
if (!name || !amount) return;
await api('/api/expenses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, amount, category: document.getElementById('expCategory').value || 'general', month: document.getElementById('expMonth').value }) });
document.getElementById('expName').value = '';
document.getElementById('expAmount').value = '';
loadExpenses(); loadDashboard();
}
async function deleteExpense(id) { await api('/api/expenses/' + id, { method: 'DELETE' }); loadExpenses(); loadDashboard(); }
async function generateMonth() {
const month = document.getElementById('expMonthFilter').value || new Date().toISOString().slice(0, 7);
await api('/api/expenses/generate-month?month=' + month, { method: 'POST' });
loadExpenses(); loadDashboard();
}
// ── Init ──
checkAuth();
</script>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vela — Sauvegarde</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #050505; --bg2: #0a0a0a; --surface: #111111; --border: #222222;
--text: #ffffff; --muted: #888888; --accent: #3b82f6; --accent-hover: #2563eb; --green: #22c55e; --red: #ef4444;
}
[data-theme="light"] {
--bg: #fafafa; --bg2: #f0f0f0; --surface: #ffffff; --border: #e0e0e0;
--text: #111111; --muted: #666666; --accent: #3b82f6; --accent-hover: #2563eb; --green: #16a34a; --red: #dc2626;
}
body { font-family:Inter,system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.7; transition:background .2s,color .2s; }
.container { max-width:800px; margin:0 auto; padding:0 24px; }
nav { display:flex; align-items:center; justify-content:space-between; padding:16px 24px; max-width:800px; margin:0 auto; border-bottom:1px solid var(--border); }
nav .logo { font-size:20px; font-weight:700; color:var(--text); text-decoration:none; }
nav a { color:var(--muted); text-decoration:none; font-size:14px; }
nav a:hover { color:var(--text); }
.theme-btn { background:none; border:1px solid var(--border); color:var(--muted); padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px; }
.theme-btn:hover { color:var(--text); border-color:var(--accent); }
.lang-btn { background:none; border:1px solid var(--border); color:var(--muted); padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px; margin-right:8px; }
.lang-btn:hover { color:var(--text); border-color:var(--accent); }
section { padding:60px 0; }
h1 { font-size:clamp(28px,5vw,44px); font-weight:800; line-height:1.2; margin-bottom:16px; letter-spacing:-.5px; }
h2 { font-size:24px; font-weight:700; margin:40px 0 16px; }
p { margin-bottom:16px; color:var(--text); }
.lead { font-size:18px; color:var(--muted); margin-bottom:32px; }
.box { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; margin:24px 0; }
.box h3 { font-size:18px; margin-bottom:8px; }
.box.green-border { border-color:var(--green); }
.highlight { color:var(--accent); font-weight:600; }
.comparison { display:grid; grid-template-columns:1fr 1fr; gap:20px; margin:32px 0; }
@media(max-width:600px) { .comparison { grid-template-columns:1fr; } }
.comparison .col { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:24px; }
.comparison .col.recommended { border-color:var(--accent); }
.btn { display:inline-block; padding:12px 24px; background:var(--accent); color:#fff; border-radius:8px; text-decoration:none; font-weight:600; font-size:14px; }
.btn:hover { background:var(--accent-hover); }
footer { border-top:1px solid var(--border); padding:24px; text-align:center; color:var(--muted); font-size:13px; margin-top:60px; }
[lang="en"] .fr { display:none; }
[lang="fr"] .en { display:none; }
</style>
</head>
<body>
<nav>
<a href="/" class="logo">← Vela</a>
<div style="display:flex;align-items:center;">
<button class="lang-btn" onclick="toggleLang()" id="langBtn">EN</button>
<button class="theme-btn" onclick="toggleTheme()" id="themeBtn">☀️</button>
</div>
</nav>
<section>
<div class="container">
<h1><span class="fr">Vos souvenirs sont trop précieux pour n'exister qu'à un seul endroit.</span><span class="en">Your memories are too precious to live in just one place.</span></h1>
<p class="lead"><span class="fr">La sauvegarde expliquée simplement. Sans jargon.</span><span class="en">Backups explained simply. No jargon.</span></p>
<div class="box">
<h3 class="fr">📱 Imaginez ça</h3><h3 class="en">📱 Imagine this</h3>
<p><span class="fr">Vous avez 4 000 photos de vos enfants sur votre téléphone. Un jour, vous le faites tomber. Écran noir. Plus rien.</span><span class="en">You have 4,000 photos of your kids on your phone. One day, you drop it. Black screen. Nothing.</span></p>
<p><span class="fr"><span class="highlight">Sans sauvegarde :</span> ces 4 000 photos ont disparu. Pour toujours.</span><span class="en"><span class="highlight">Without backup:</span> those 4,000 photos are gone. Forever.</span></p>
<p class="fr"><span class="highlight">Avec Vela :</span> chaque photo que vous prenez est automatiquement copiée sur votre serveur Vela dès que vous rentrez chez vous. Votre téléphone peut mourir, vos photos survivent.</p>
<p class="en"><span class="highlight">With Vela:</span> every photo you take is automatically copied to your Vela server as soon as you get home. Your phone can die, your photos survive.</p>
</div>
<h2><span class="fr">Comment ça marche</span><span class="en">How it works</span></h2>
<p class="fr">C'est simple : au lieu d'avoir vos photos et fichiers <strong>uniquement</strong> sur votre téléphone, Vela en garde une copie chez vous. Automatiquement. Sans que vous ayez à faire quoi que ce soit.</p>
<p class="en">It's simple: instead of having your photos and files <strong>only</strong> on your phone, Vela keeps a copy at home. Automatically. Without you doing anything.</p>
<p class="fr">C'est comme avoir un deuxième coffre-fort pour vos souvenirs. Si le premier brûle, le deuxième est toujours là.</p>
<p class="en">It's like having a second safe for your memories. If the first one burns, the second one is still there.</p>
<h2><span class="fr">Sauvegarde simple vs redondante</span><span class="en">Simple vs redundant backup</span></h2>
</div>
</section>
<section style="background:var(--bg2);">
<div class="container">
<div class="comparison">
<div class="col">
<h3 class="fr">✅ Sauvegarde simple</h3><h3 class="en">✅ Simple backup</h3>
<p class="fr">Vos données sont copiées sur <strong>un</strong> disque dur.</p>
<p class="en">Your data is copied to <strong>one</strong> hard drive.</p>
<p><span class="fr">✅ Protège contre :</span><span class="en">✅ Protects against:</span></p>
<ul style="color:var(--muted);padding-left:20px;margin-bottom:12px;">
<li class="fr">Téléphone cassé ou perdu</li><li class="en">Broken or lost phone</li>
<li class="fr">Suppression accidentelle</li><li class="en">Accidental deletion</li>
</ul>
<p><span class="fr">❌ Ne protège pas contre :</span><span class="en">❌ Does not protect against:</span></p>
<ul style="color:var(--muted);padding-left:20px;">
<li class="fr">Panne du disque dur</li><li class="en">Hard drive failure</li>
</ul>
<p style="margin-top:16px;font-size:14px;color:var(--muted);"><span class="fr">Disponible sur :</span><span class="en">Available on:</span> <strong>Rif, Atlas</strong></p>
</div>
<div class="col recommended">
<h3 class="fr">🛡️ Sauvegarde redondante</h3><h3 class="en">🛡️ Redundant backup</h3>
<p class="fr">Vos données sont copiées sur <strong>deux</strong> disques durs différents.</p>
<p class="en">Your data is copied to <strong>two</strong> different hard drives.</p>
<p class="fr">Si un disque tombe en panne, l'autre a toujours tout. C'est la même protection qu'utilisent les banques et les hôpitaux.</p>
<p class="en">If one drive fails, the other still has everything. It's the same protection banks and hospitals use.</p>
<p style="margin-top:16px;font-size:14px;color:var(--accent);"><span class="fr">Disponible sur :</span><span class="en">Available on:</span> <strong>Rif+, Atlas+</strong></p>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<div class="box green-border">
<h3 class="fr">💡 La règle d'or</h3><h3 class="en">💡 The golden rule</h3>
<p class="fr"><strong>Si quelque chose n'existe qu'à un seul endroit, ça n'existe pas vraiment.</strong></p>
<p class="en"><strong>If something only exists in one place, it doesn't really exist.</strong></p>
<p class="fr">Un téléphone, ce n'est pas un endroit sûr. Un disque dur non plus. Deux disques durs séparés, chez vous : là, vous êtes tranquille.</p>
<p class="en">A phone is not a safe place. Neither is one hard drive. Two separate hard drives, at home: now you can relax.</p>
</div>
<div style="text-align:center;margin-top:40px;">
<a href="/#pricing" class="btn"><span class="fr">Voir les offres Vela</span><span class="en">See Vela plans</span></a>
</div>
</div>
</section>
<footer>
<div style="font-size:15px;color:var(--text);font-weight:600;margin-bottom:4px;"><span class="fr">Vos données. Chez vous.</span><span class="en">Your data. At home.</span></div>
<div>Vela © 2026</div>
</footer>
<script>
function getTheme() { return localStorage.getItem('vela_theme') || 'dark'; }
function setTheme(t) { document.documentElement.setAttribute('data-theme', t); document.getElementById('themeBtn').textContent = t==='light'?'🌙':'☀️'; localStorage.setItem('vela_theme', t); }
function toggleTheme() { setTheme(getTheme()==='dark'?'light':'dark'); }
setTheme(getTheme());
function getLang() { const p=new URLSearchParams(window.location.search); const l=p.get('lang'); if(l==='fr'||l==='en'){localStorage.setItem('vela_lang',l);return l;} return localStorage.getItem('vela_lang')||'fr'; }
function setLang(l) { document.documentElement.setAttribute('lang',l); document.getElementById('langBtn').textContent=l==='fr'?'EN':'FR'; localStorage.setItem('vela_lang',l); const u=new URL(window.location); u.searchParams.set('lang',l); window.history.replaceState({},'',u); }
function toggleLang() { setLang(getLang()==='fr'?'en':'fr'); }
setLang(getLang());
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Flower" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 792 792" style="enable-background:new 0 0 792 792;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FA2921;}
.st1{fill:#ED79B5;}
.st2{fill:#FFB400;}
.st3{fill:#1E83F7;}
.st4{fill:#18C249;}
</style>
<g id="Flower_00000077325900055813483940000000694823054982625702_">
<path class="st0" d="M375.48,267.63c38.64,34.21,69.78,70.87,89.82,105.42c34.42-61.56,57.42-134.71,57.71-181.3
c0-0.33,0-0.63,0-0.91c0-68.94-68.77-95.77-128.01-95.77s-128.01,26.83-128.01,95.77c0,0.94,0,2.2,0,3.72
C300.01,209.24,339.15,235.47,375.48,267.63z"/>
<path class="st1" d="M164.7,455.63c24.15-26.87,61.2-55.99,103.01-80.61c44.48-26.18,88.97-44.47,128.02-52.84
c-47.91-51.76-110.37-96.24-154.6-110.91c-0.31-0.1-0.6-0.19-0.86-0.28c-65.57-21.3-112.34,35.81-130.64,92.15
c-18.3,56.34-14.04,130.04,51.53,151.34C162.05,454.77,163.25,455.16,164.7,455.63z"/>
<path class="st2" d="M681.07,302.19c-18.3-56.34-65.07-113.45-130.64-92.15c-0.9,0.29-2.1,0.68-3.54,1.15
c-3.75,35.93-16.6,81.27-35.96,125.76c-20.59,47.32-45.84,88.27-72.51,118c69.18,13.72,145.86,12.98,190.26-1.14
c0.31-0.1,0.6-0.2,0.86-0.28C695.11,432.22,699.37,358.52,681.07,302.19z"/>
<path class="st3" d="M336.54,510.71c-11.15-50.39-14.8-98.36-10.7-138.08c-64.03,29.57-125.63,75.23-153.26,112.76
c-0.19,0.26-0.37,0.51-0.53,0.73c-40.52,55.78-0.66,117.91,47.27,152.72c47.92,34.82,119.33,53.54,159.86-2.24
c0.56-0.76,1.3-1.78,2.19-3.01C363.28,602.32,347.02,558.08,336.54,510.71z"/>
<path class="st4" d="M617.57,482.52c-35.33,7.54-82.42,9.33-130.72,4.66c-51.37-4.96-98.11-16.32-134.63-32.5
c8.33,70.03,32.73,142.73,59.88,180.6c0.19,0.26,0.37,0.51,0.53,0.73c40.52,55.78,111.93,37.06,159.86,2.24
c47.92-34.82,87.79-96.95,47.27-152.72C619.2,484.77,618.46,483.75,617.57,482.52z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+353
View File
@@ -0,0 +1,353 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vela — Votre serveur privé, prêt à l'emploi</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #050505;
--bg2: #0a0a0a;
--surface: #111111;
--border: #222222;
--text: #ffffff;
--muted: #888888;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #22c55e;
--radius: 12px;
}
[data-theme="light"] {
--bg: #fafafa;
--bg2: #f0f0f0;
--surface: #ffffff;
--border: #e0e0e0;
--text: #111111;
--muted: #666666;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #16a34a;
}
body {
font-family: Inter, system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
transition: background .2s, color .2s;
}
.container { max-width:1100px; margin:0 auto; padding:0 24px; }
/* Nav */
nav {
display:flex; align-items:center; justify-content:space-between;
padding:16px 24px; max-width:1100px; margin:0 auto;
border-bottom:1px solid var(--border);
}
nav .logo { font-size:20px; font-weight:700; color:var(--text); text-decoration:none; }
nav .nav-links { display:flex; gap:20px; align-items:center; }
nav a { color:var(--muted); text-decoration:none; font-size:14px; transition:color .15s; }
nav a:hover { color:var(--text); }
.lang-btn, .theme-btn {
background:none; border:1px solid var(--border); color:var(--muted);
padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px;
transition:all .15s;
}
.lang-btn:hover, .theme-btn:hover { color:var(--text); border-color:var(--accent); }
/* Sections */
section { padding:80px 0; }
.hero { text-align:center; padding:100px 0 80px; }
.hero h1 { font-size:clamp(32px,5vw,56px); font-weight:800; line-height:1.1; margin-bottom:20px; letter-spacing:-1px; }
.hero p { font-size:18px; color:var(--muted); max-width:600px; margin:0 auto 32px; }
.btn { display:inline-block; padding:14px 28px; border-radius:8px; font-size:15px; font-weight:600; text-decoration:none; cursor:pointer; border:none; transition:all .15s; }
.btn-primary { background:var(--accent); color:#fff; } .btn-primary:hover { background:var(--accent-hover); transform:translateY(-1px); }
.btn-ghost { background:transparent; color:var(--text); border:1px solid var(--border); margin-left:12px; }
.btn-ghost:hover { border-color:var(--accent); }
h2 { font-size:32px; font-weight:700; text-align:center; margin-bottom:16px; letter-spacing:-.5px; }
.section-sub { text-align:center; color:var(--muted); font-size:16px; margin-bottom:48px; max-width:600px; margin-left:auto; margin-right:auto; }
/* Pricing */
.pricing-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:20px; }
.pricing-card {
background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);
padding:28px; text-align:center; transition:border-color .2s;
}
.pricing-card:hover { border-color:var(--accent); }
.pricing-card h3 { font-size:20px; margin-bottom:4px; }
.pricing-card .price { font-size:32px; font-weight:800; margin:12px 0; color:var(--accent); }
.pricing-card .price small { font-size:14px; font-weight:400; color:var(--muted); }
.pricing-card .desc { color:var(--muted); font-size:14px; margin-bottom:16px; min-height:40px; }
.pricing-card .specs { text-align:left; font-size:13px; color:var(--muted); margin-bottom:20px; }
.pricing-card .specs li { padding:4px 0; list-style:none; }
.pricing-card .specs li::before { content:"✓ "; color:var(--green); }
/* Why */
.reasons { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:24px; }
.reason { text-align:center; padding:24px; }
.reason .icon { font-size:36px; margin-bottom:12px; }
.reason h4 { font-size:16px; margin-bottom:8px; }
.reason p { font-size:14px; color:var(--muted); }
/* Steps */
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:32px; text-align:center; }
.step .num { width:48px; height:48px; background:var(--accent); color:#fff; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; font-size:20px; font-weight:700; margin-bottom:16px; }
.step h4 { font-size:16px; margin-bottom:6px; }
.step p { font-size:14px; color:var(--muted); }
/* CTA */
.cta { text-align:center; background:var(--surface); border-radius:var(--radius); padding:60px 24px; margin-bottom:80px; }
.cta h2 { margin-bottom:8px; }
.cta p { color:var(--muted); margin-bottom:24px; }
/* Footer */
footer { border-top:1px solid var(--border); padding:24px; text-align:center; color:var(--muted); font-size:13px; }
footer .tagline { font-size:15px; color:var(--text); margin-bottom:8px; font-weight:600; }
/* Responsive */
@media (max-width:640px) {
section { padding:48px 0; }
.hero { padding:60px 0 48px; }
nav .nav-links { gap:10px; }
nav a.hide-mobile { display:none; }
}
[lang="en"] .fr { display:none; }
[lang="fr"] .en { display:none; }
</style>
</head>
<body>
<nav>
<a href="/" class="logo">Vela</a>
<div class="nav-links">
<a href="#pricing" class="hide-mobile"><span class="fr">Offres</span><span class="en">Pricing</span></a>
<a href="#how" class="hide-mobile"><span class="fr">Comment ça marche</span><span class="en">How it works</span></a>
<button class="lang-btn" onclick="toggleLang()" id="langBtn">EN</button>
<button class="theme-btn" onclick="toggleTheme()" id="themeBtn">☀️</button>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<div class="container">
<h1><span class="fr">Vos données.<br>Chez vous.</span><span class="en">Your data.<br>At home.</span></h1>
<p><span class="fr">Vous prenez une photo. Vous rentrez. Elle est déjà là. Reconnaissance de visages, recherche intelligente, sauvegardes — tout tourne chez vous, pas dans le cloud de Google.</span><span class="en">You take a photo. You get home. It's already there. Face recognition, smart search, backups — all runs at home, not on Google's cloud.</span></p>
<a href="#pricing" class="btn btn-primary"><span class="fr">Voir les offres</span><span class="en">See plans</span></a>
<a href="https://wa.me/212725569519" class="btn btn-ghost"><span class="fr">WhatsApp</span><span class="en">WhatsApp</span></a>
</div>
</section>
<!-- PROBLEM -->
<section style="background:var(--bg2);">
<div class="container">
<h2><span class="fr">Vous connaissez ça ?</span><span class="en">Sound familiar?</span></h2>
<div class="reasons">
<div class="reason">
<div class="icon">📱</div>
<h4 class="fr">Stockage plein</h4><h4 class="en">Storage full</h4>
<p class="fr">Google Photos ou iCloud vous demandent de payer chaque mois.</p>
<p class="en">Google Photos or iCloud asks you to pay every month.</p>
</div>
<div class="reason">
<div class="icon">💸</div>
<h4 class="fr">Abonnements qui s'accumulent</h4><h4 class="en">Subscriptions pile up</h4>
<p class="fr">Google One, iCloud, Dropbox... chaque mois, ça monte.</p>
<p class="en">Google One, iCloud, Dropbox... it adds up every month.</p>
</div>
<div class="reason">
<div class="icon">🔒</div>
<h4 class="fr">Vos données chez les autres</h4><h4 class="en">Your data on their servers</h4>
<p class="fr">Vos photos de famille sont sur les serveurs de Google.</p>
<p class="en">Your family photos live on Google's servers.</p>
</div>
<div class="reason">
<div class="icon">😰</div>
<h4 class="fr">Pas de vraie sauvegarde</h4><h4 class="en">No real backup</h4>
<p class="fr">Si votre téléphone tombe, où sont vos fichiers ?</p>
<p class="en">If your phone breaks, where are your files?</p>
</div>
</div>
</div>
</section>
<!-- SOLUTION -->
<section>
<div class="container">
<h2><span class="fr">La solution Vela</span><span class="en">The Vela solution</span></h2>
<p class="section-sub"><span class="fr">Un petit boîtier chez vous. Tout votre monde numérique, en sécurité, sans abonnement.</span><span class="en">A small box in your home. Your entire digital world, safe, no subscription.</span></p>
<div class="reasons">
<div class="reason">
<div class="icon"><img src="/static/immich-logo.svg" alt="Immich" style="height:40px;"></div>
<h4>Immich</h4>
<p class="fr">Vous prenez une photo. Vous rentrez chez vous. Elle est déjà sur votre Vela. Reconnaissance de visages, recherche par texte ("chien à la plage") — tout est privé, rien ne part chez Google.</p>
<p class="en">You take a photo. You get home. It's already on your Vela. Face recognition, text search ("dog at the beach") — all private, nothing goes to Google.</p>
</div>
<div class="reason">
<div class="icon"><img src="/static/nextcloud-logo.svg" alt="Nextcloud" style="height:36px;"></div>
<h4>Nextcloud</h4>
<p class="fr">Vos fichiers, vos documents, votre calendrier. Tout se synchronise tout seul entre votre téléphone et votre Vela.</p>
<p class="en">Your files, documents, calendar. Everything syncs by itself between your phone and your Vela.</p>
</div>
<div class="reason">
<div class="icon">💾</div>
<h4 class="fr">Sauvegarde automatique</h4><h4 class="en">Auto backup</h4>
<p class="fr">Vos données sont sauvegardées automatiquement. Plus jamais de panique.</p>
<p class="en">Your data is backed up automatically. No more panic.</p>
</div>
<div class="reason">
<div class="icon">🇲🇦</div>
<h4 class="fr">Installé à Casablanca</h4><h4 class="en">Installed in Casablanca</h4>
<p class="fr">On vient chez vous, on installe, on configure. Vous n'avez rien à faire.</p>
<p class="en">We come to your home, install, configure. You do nothing.</p>
</div>
</div>
</div>
</section>
<!-- PRICING -->
<section id="pricing" style="background:var(--bg2);">
<div class="container">
<h2><span class="fr">Choisissez votre Vela</span><span class="en">Choose your Vela</span></h2>
<p class="section-sub"><span class="fr">Prix unique. Pas d'abonnement. Installation incluse.</span><span class="en">One-time price. No subscription. Installation included.</span></p>
<div class="pricing-grid" id="pricingGrid">
<div class="pricing-card"><p style="color:var(--muted);">Chargement...</p></div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section id="how">
<div class="container">
<h2><span class="fr">Comment ça marche</span><span class="en">How it works</span></h2>
<p class="section-sub"><span class="fr">Simple, rapide, sans stress.</span><span class="en">Simple, fast, stress-free.</span></p>
<div class="steps">
<div class="step">
<div class="num">1</div>
<h4 class="fr">Vous choisissez</h4><h4 class="en">You choose</h4>
<p class="fr">Un modèle Vela adapté à vos besoins.</p>
<p class="en">A Vela model that fits your needs.</p>
</div>
<div class="step">
<div class="num">2</div>
<h4 class="fr">On installe</h4><h4 class="en">We install</h4>
<p class="fr">On vient chez vous, on branche, on configure tout.</p>
<p class="en">We come to your home, plug in, configure everything.</p>
</div>
<div class="step">
<div class="num">3</div>
<h4 class="fr">Vous profitez</h4><h4 class="en">You enjoy</h4>
<p class="fr">Vos photos, fichiers, sauvegardes — tout est là, chez vous.</p>
<p class="en">Your photos, files, backups — all there, at home.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section>
<div class="container">
<div class="cta">
<h2><span class="fr">Prêt à récupérer vos données ?</span><span class="en">Ready to take back your data?</span></h2>
<p><span class="fr">Un Vela chez vous, c'est la tranquillité. Contactez-nous.</span><span class="en">A Vela at home means peace of mind. Contact us.</span></p>
<a href="https://wa.me/212725569519" class="btn btn-primary"><span class="fr">Discutons sur WhatsApp</span><span class="en">Chat on WhatsApp</span></a>
</div>
</div>
</section>
<footer>
<div class="tagline"><span class="fr">Vos données. Chez vous.</span><span class="en">Your data. At home.</span></div>
<div>Vela © 2026 · <a href="/admin" style="color:var(--muted);text-decoration:none;opacity:0.3;font-size:12px;">Admin</a></div>
</footer>
<script>
// ── Theme ──
function getTheme() { return localStorage.getItem('vela_theme') || 'dark'; }
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
document.getElementById('themeBtn').textContent = t === 'light' ? '🌙' : '☀️';
localStorage.setItem('vela_theme', t);
}
function toggleTheme() { setTheme(getTheme() === 'dark' ? 'light' : 'dark'); }
setTheme(getTheme());
// ── Language ──
function getLang() {
const params = new URLSearchParams(window.location.search);
const urlLang = params.get('lang');
if (urlLang === 'fr' || urlLang === 'en') { localStorage.setItem('vela_lang', urlLang); return urlLang; }
return localStorage.getItem('vela_lang') || 'fr';
}
function setLang(l) {
document.documentElement.setAttribute('lang', l);
document.getElementById('langBtn').textContent = l === 'fr' ? 'EN' : 'FR';
localStorage.setItem('vela_lang', l);
// Update URL
const url = new URL(window.location);
url.searchParams.set('lang', l);
window.history.replaceState({}, '', url);
}
function toggleLang() { setLang(getLang() === 'fr' ? 'en' : 'fr'); }
setLang(getLang());
// Redirect from clean URL to ?lang= if no param
if (!window.location.search.includes('lang=') && getLang() === 'en') {
const url = new URL(window.location);
url.searchParams.set('lang', 'en');
window.history.replaceState({}, '', url);
}
// ── Pricing ──
async function loadPricing() {
try {
const r = await fetch('/api/public/pricing');
const tiers = await r.json();
const grid = document.getElementById('pricingGrid');
const specs = {
fr: {
"Rif": ["Mini PC", "512 Go SSD", "8 Go RAM", "Immich + Nextcloud", "Installation incluse"],
"Rif+": ["Mini PC", "512 Go SSD", "8 Go RAM", "Immich + Nextcloud", '<a href="/backup?lang=fr" style="color:var(--green);">+ Sauvegarde automatique 1 To</a>', "Installation incluse"],
"Atlas": ["Mini PC", "1 To SSD", "16 Go RAM", "Immich + Nextcloud", "Installation incluse"],
"Atlas+": ["Mini PC", "1 To SSD", "16 Go RAM", "Immich + Nextcloud", '<a href="/backup?lang=fr" style="color:var(--green);">+ Sauvegarde redondante 1 To</a>', "Installation incluse"]
},
en: {
"Rif": ["Mini PC", "512 GB SSD", "8 GB RAM", "Immich + Nextcloud", "Installation included"],
"Rif+": ["Mini PC", "512 GB SSD", "8 GB RAM", "Immich + Nextcloud", '<a href="/backup?lang=en" style="color:var(--green);">+ Auto backup 1 TB</a>', "Installation included"],
"Atlas": ["Mini PC", "1 TB SSD", "16 GB RAM", "Immich + Nextcloud", "Installation included"],
"Atlas+": ["Mini PC", "1 TB SSD", "16 GB RAM", "Immich + Nextcloud", '<a href="/backup?lang=en" style="color:var(--green);">+ Redundant backup 1 TB</a>', "Installation included"]
}
};
const desc = {
fr: {
"Rif": "Pour démarrer avec vos photos et fichiers.",
"Rif+": "Le meilleur rapport qualité-prix avec sauvegarde.",
"Atlas": "Plus de stockage et de puissance.",
"Atlas+": "La formule complète, sauvegarde redondante."
},
en: {
"Rif": "Start with your photos and files.",
"Rif+": "Best value with backup included.",
"Atlas": "More storage and power.",
"Atlas+": "The full package, redundant backup."
}
};
const lang = getLang();
grid.innerHTML = tiers.map(t => `
<div class="pricing-card">
<h3>${t.name}</h3>
<div class="price">${Number(t.sell_price).toLocaleString('fr-MA')} <small>MAD</small></div>
<div class="desc">${(desc[lang]||desc.fr)[t.name] || t.description || ''}</div>
<ul class="specs">${(specs[lang]||specs.fr)[t.name]?.map(s => `<li>${s}</li>`).join('') || ''}</ul>
<a href="https://wa.me/212725569519" class="btn btn-primary" style="width:100%;text-align:center;">${lang==='fr'?'Commander':'Order'}</a>
</div>
`).join('');
} catch(e) { document.getElementById('pricingGrid').innerHTML = '<p style="color:var(--muted);text-align:center;">Pricing unavailable</p>'; }
}
loadPricing();
</script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vela Platform — Login</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:#0f0f0f; color:#e0e0e0; display:flex; align-items:center; justify-content:center; min-height:100vh; }
.login-box { background:#1a1a1a; border:1px solid #2a2a2a; border-radius:12px; padding:40px; width:360px; }
h1 { font-size:24px; margin-bottom:8px; color:#fff; }
p.sub { color:#888; font-size:14px; margin-bottom:24px; }
label { display:block; font-size:13px; color:#aaa; margin-bottom:4px; }
input { width:100%; padding:10px 12px; background:#0f0f0f; border:1px solid #2a2a2a; border-radius:6px; color:#fff; font-size:15px; margin-bottom:16px; }
input:focus { outline:none; border-color:#3b82f6; }
button { width:100%; padding:12px; background:#3b82f6; color:#fff; border:none; border-radius:6px; font-size:15px; font-weight:600; cursor:pointer; }
button:hover { background:#2563eb; }
.error { color:#ef4444; font-size:13px; margin-top:8px; display:none; }
</style>
</head>
<body>
<div class="login-box">
<h1>Vela Platform</h1>
<p class="sub">P&L Manager</p>
<form id="loginForm">
<label>Username</label>
<input type="text" id="username" autocomplete="username" required>
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
<div class="error" id="error"></div>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('error');
err.style.display = 'none';
const form = new FormData();
form.append('username', document.getElementById('username').value);
form.append('password', document.getElementById('password').value);
try {
const r = await fetch('/api/auth/login', { method:'POST', body:form });
if (!r.ok) { const d = await r.json(); err.textContent = d.detail || 'Login failed'; err.style.display = 'block'; return; }
const d = await r.json();
localStorage.setItem('vela_token', d.token);
window.location = d.redirect;
} catch(ex) { err.textContent = 'Network error'; err.style.display = 'block'; }
});
</script>
</body>
</html>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB