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
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
vela.db
vela.db-shm
vela.db-wal
.git/
+190
View File
@@ -0,0 +1,190 @@
# Vela Platform v2
Single FastAPI application that powers the Vela business — a home server installation service in Casablanca, Morocco.
**Built:** June 26, 2026
**Deployed:** Proxmox CT 124 @ 192.168.1.244
---
## Architecture
```
┌─────────────────────────────────────────┐
│ nginx :80 │
│ ├── / → Marketing site │
│ ├── /admin → Admin P&L dashboard │
│ ├── /backup → Backup explainer │
│ ├── /static/* → Logos, assets │
│ └── /api/* → JSON API │
│ │ │
│ uvicorn :8000 (systemd: vela.service) │
│ ├── main.py — FastAPI app │
│ ├── models.py — SQLAlchemy models │
│ ├── database.py — SQLite WAL mode │
│ └── auth.py — JWT auth │
│ │
│ vela.db (SQLite) │
└─────────────────────────────────────────┘
```
One box. One app. One database. No microservices.
---
## What It Does
### Public Marketing Site (`/`)
- Bilingual FR/EN with URL-based language toggle (`?lang=fr` / `?lang=en`)
- Dark/light mode toggle (persisted in localStorage)
- Hero section with value proposition
- Problem/solution section
- Live pricing cards pulled from API
- Immich & Nextcloud sections with real SVG logos
- "How it works" — 3-step visual
- WhatsApp CTA buttons (all pointing to +212 725-569519)
- Footer with barely-visible "Admin" link (30% opacity)
### Backup Explainer (`/backup`)
- Explains backups to non-technical users
- Simple vs redundant backup comparison
- "The golden rule: if it only exists in one place, it doesn't really exist"
- Bilingual
### Admin Panel (`/admin`)
- JWT login (username: `oimwiodev`, password: `kxdr781020`)
- Token stored in localStorage, verified via `/api/verify`
- 4 tabs: Dashboard, Tiers, Transactions, Expenses
#### Dashboard
- Real-time P&L cards: Revenue, COGS, Gross Profit, Margin %, Expenses, Net Profit
- Monthly breakdown table with month filter
- Formula: **Revenue COGS Expenses = Net Profit** (not the fake v1 math)
#### Tiers
- 4 tiers: Rif, Rif+, Atlas, Atlas+
- Component breakdown with unit costs
- Inline edit: tier name, sell price, component costs
- Toggle active/inactive
- Cost price auto-calculated from components
- Margin % shown per tier
#### Transactions
- Add manual sale (select tier → snapshots sell price + cost at time of sale)
- Filter by month
- Delete with confirm + undo timer:
- 1st click: "Sure?" (4s timeout)
- 2nd click: deletes, row fades, 8s undo window
#### Expenses
- Add/delete expenses with month and category
- Monthly expense templates (auto-generate from templates)
- Soft delete (deleted_at timestamp)
---
## API Endpoints
### Public (no auth)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/public/pricing` | Active tiers: name, description, sell_price |
| GET | `/api/public/services/{id}` | Single tier details + components |
### Auth
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/auth/login` | Login (username + password) → JWT |
| POST | `/api/auth/logout` | Clear cookie |
| GET | `/api/verify` | Verify JWT, returns email |
### Admin (JWT required)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/pnl` | P&L summary + monthly breakdown |
| GET | `/api/services` | All tiers with components |
| PUT | `/api/services/{id}` | Update tier (name, price, active, etc.) |
| PUT | `/api/services/{id}/toggle` | Toggle active/inactive |
| PUT | `/api/services/{id}/components/{cid}` | Update component cost/qty |
| GET | `/api/transactions` | List transactions (optional `?month=`) |
| POST | `/api/transactions` | Create sale (snapshots prices) |
| DELETE | `/api/transactions/{id}` | Delete transaction |
| GET | `/api/expenses` | List expenses |
| POST | `/api/expenses` | Add expense |
| DELETE | `/api/expenses/{id}` | Soft-delete expense |
| GET | `/api/expense-templates` | List recurring templates |
| POST | `/api/expenses/generate-month` | Generate from templates |
---
## Database Schema
6 tables in SQLite (`vela.db`):
| Table | Purpose |
|-------|---------|
| `users` | Single admin (email, bcrypt password) |
| `services` | 4 tiers (name, sell_price, cost_price, active, sort_order) |
| `service_components` | Per-tier parts (name, unit_cost, quantity) |
| `transactions` | Frozen sales with price/cost snapshots, unique (source_type, source_id) |
| `expenses` | Monthly costs with soft delete |
| `expense_templates` | Recurring expense patterns |
Key design: **transactions freeze sell_price and cost_price at time of sale.** If you change an SSD cost later, old sales don't change. Real P&L math.
SQLite pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`
---
## Deployment
**Server:** Proxmox CT 124 (Debian 12)
**IP:** 192.168.1.244
**Service:** `systemctl restart vela`
**Files:** `/opt/vela/`
**DB:** `/opt/vela/vela.db`
### Update workflow
```bash
# 1. Edit files locally
# 2. Package and deploy
cd ~/src/vela-platform
tar czf /tmp/vela-update.tar.gz main.py templates/
sshpass -p 'PW' scp /tmp/vela-update.tar.gz root@192.168.1.109:/tmp/
sshpass -p 'PW' ssh root@192.168.1.109 "
pct push 124 /tmp/vela-update.tar.gz /tmp/vela-update.tar.gz &&
pct exec 124 -- bash -c 'cd /opt/vela && tar xzf /tmp/vela-update.tar.gz && systemctl restart vela'
"
```
### Public access
Point Nginx Proxy Manager at `192.168.1.244:80` for your domain.
---
## Design Decisions
- **No Jinja2** — hit a cache bug (`unhashable type: 'dict'`). Switched to `FileResponse` serving raw HTML. All dynamic content via `fetch()` to JSON APIs.
- **No framework** — vanilla HTML/CSS/JS. No React, no Vue, no build step.
- **CSS variables** — dark/light mode is a single `data-theme` attribute. Zero hardcoded hex colors in JS.
- **Single admin** — no user management. One account. Simple.
- **Public API is read-only** — pricing only. No costs, no margins, no internals leak.
---
## WhatsApp Sales Bot
A separate Hermes agent with a custom SOUL.md handles WhatsApp conversations. The bot:
- Answers questions in French/Darija/English
- Fetches live pricing via `curl http://192.168.1.244/api/public/pricing`
- Follows a 4-step sales process: understand → recommend → handle objections → close
- Never hardcodes prices — always checks the API
- Personality: calm, direct, short messages, no corporate tone
SOUL.md at `/tmp/vela-sales-bot-soul.md`
---
## What Was Removed (v1)
The old Vela had **two separate LXCs** (CT125 + CT126) with **three Python services** (two FastAPI apps + nginx, two auth systems, two databases). All nuked. Replaced with this single monolith.
+67
View File
@@ -0,0 +1,67 @@
"""Auth — JWT tokens, password hashing, admin guard."""
import os
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from database import get_db
from models import User
JWT_SECRET = os.environ.get("VELA_JWT_SECRET", "change-me-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
bearer_scheme = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
def create_access_token(user_id: int) -> str:
payload = {
"sub": str(user_id),
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def get_current_user(
request: Request,
creds: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
"""Try JWT header first, then fall back to cookie."""
token = None
if creds:
token = creds.credentials
elif "vela_token" in request.cookies:
token = request.cookies["vela_token"]
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
user_id = int(payload["sub"])
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError, ValueError):
raise HTTPException(status_code=401, detail="Invalid or expired token")
user = db.query(User).filter(User.id == user_id, User.active == True).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
"""Currently all users are admins — placeholder for future roles."""
return user
+34
View File
@@ -0,0 +1,34 @@
"""Database setup — SQLite with WAL mode, foreign keys, busy timeout."""
import os
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
DB_DIR = os.environ.get("VELA_DB_DIR", os.path.dirname(os.path.abspath(__file__)))
DB_PATH = os.path.join(DB_DIR, "vela.db")
DATABASE_URL = f"sqlite:///{DB_PATH}"
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False},
echo=False,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@event.listens_for(engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+598
View File
@@ -0,0 +1,598 @@
"""Vela Platform — P&L manager. Single FastAPI app with admin panel."""
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from pydantic import BaseModel
import os
from pathlib import Path
from auth import (
JWT_EXPIRE_HOURS,
create_access_token,
get_current_user,
hash_password,
require_admin,
verify_password,
)
from database import SessionLocal, engine, get_db
from models import Base, Expense, ExpenseTemplate, Service, ServiceComponent, Transaction, User
TEMPLATE_DIR = Path(__file__).parent / "templates"
# ── Pydantic request schemas ───────────────────────────────────────────────
class ServiceUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
sell_price: Optional[float] = None
cost_price: Optional[float] = None
active: Optional[bool] = None
sort_order: Optional[int] = None
class ComponentUpdate(BaseModel):
name: Optional[str] = None
unit_cost: Optional[float] = None
quantity: Optional[int] = None
class TransactionCreate(BaseModel):
service_id: Optional[int] = None
service_name: Optional[str] = None
quantity: int = 1
unit_sell: Optional[float] = None
unit_cost: Optional[float] = None
month: Optional[str] = None
source_type: str = "manual"
source_id: Optional[int] = None
payment_status: str = "unpaid"
notes: str = ""
class ExpenseCreate(BaseModel):
name: str
amount: float = 0
category: str = "general"
month: Optional[str] = None
notes: str = ""
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("vela")
app = FastAPI(title="Vela Platform", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files for logos
app.mount("/static", StaticFiles(directory=str(TEMPLATE_DIR)), name="static")
# ── seed defaults ──────────────────────────────────────────────────────────
DEFAULT_SERVICES = [
{
"name": "Rif",
"description": "Entry-level home server. Photos, files, peace of mind.",
"sell_price": 3499.0,
"sort_order": 1,
"components": [
("Mini PC", 800.0, 1, "Refurbished office mini PC"),
("SSD 512GB", 500.0, 1, "NVMe boot drive"),
("RAM 8GB", 300.0, 1, "DDR4"),
("Transport", 140.0, 1, ""),
("Expertise", 359.0, 1, "Setup & config"),
],
},
{
"name": "Rif+",
"description": "Rif with backup drive. Extra safety for your files.",
"sell_price": 3990.0,
"sort_order": 2,
"components": [
("Mini PC", 800.0, 1, "Refurbished office mini PC"),
("SSD 512GB", 500.0, 1, "NVMe boot drive"),
("Backup HDD 1TB", 300.0, 1, "Internal backup drive"),
("RAM 8GB", 300.0, 1, "DDR4"),
("Transport", 140.0, 1, ""),
("Expertise", 359.0, 1, "Setup & config"),
],
},
{
"name": "Atlas",
"description": "Pro home server. 1TB storage, 16GB RAM, fast and capable.",
"sell_price": 4390.0,
"sort_order": 3,
"components": [
("Mini PC", 800.0, 1, "Refurbished office mini PC"),
("SSD 1TB", 800.0, 1, "NVMe storage drive"),
("RAM 16GB", 600.0, 1, "DDR4"),
("Transport", 140.0, 1, ""),
("Expertise", 359.0, 1, "Setup & config"),
],
},
{
"name": "Atlas+",
"description": "Maxed out. 1TB SSD + 1TB backup HDD, 16GB RAM, total peace of mind.",
"sell_price": 4790.0,
"sort_order": 4,
"components": [
("Mini PC", 800.0, 1, "Refurbished office mini PC"),
("SSD 1TB", 800.0, 1, "NVMe storage drive"),
("Backup HDD 1TB", 300.0, 1, "Internal backup drive"),
("RAM 16GB", 600.0, 1, "DDR4"),
("Transport", 140.0, 1, ""),
("Expertise", 359.0, 1, "Setup & config"),
],
},
]
DEFAULT_EXPENSE_TEMPLATES = [
("Internet", 500.0, "utilities"),
("Electricity", 400.0, "utilities"),
("Transport", 200.0, "operations"),
]
# ── startup ────────────────────────────────────────────────────────────────
@app.on_event("startup")
def startup():
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# seed admin
if not db.query(User).first():
db.add(User(email="oimwiodev", password_hash=hash_password("kxdr781020")))
db.commit()
logger.info("Seeded admin user")
# seed services
if not db.query(Service).first():
for svc_data in DEFAULT_SERVICES:
comps = svc_data.pop("components")
svc = Service(**svc_data)
db.add(svc)
db.flush()
for name, cost, qty, notes in comps:
db.add(ServiceComponent(
service_id=svc.id,
name=name,
unit_cost=cost,
unit_sell=round(cost * 1.3),
quantity=qty,
notes=notes,
))
# set cost_price and sell_price from components
comp_list = [c for c in comps]
svc.cost_price = sum(c[1] * c[2] for c in comp_list)
svc.sell_price = svc_data["sell_price"]
db.commit()
logger.info("Seeded service tiers")
# seed expense templates
if not db.query(ExpenseTemplate).first():
for name, amount, category in DEFAULT_EXPENSE_TEMPLATES:
db.add(ExpenseTemplate(name=name, amount=amount, category=category))
db.commit()
logger.info("Seeded expense templates")
finally:
db.close()
# ── public API ─────────────────────────────────────────────────────────────
@app.get("/api/public/pricing")
def public_pricing(db: Session = Depends(get_db)):
services = db.query(Service).filter(Service.active == True).order_by(Service.sort_order).all()
return [
{
"id": s.id,
"name": s.name,
"description": s.description,
"sell_price": s.sell_price,
"active": s.active,
}
for s in services
]
@app.get("/api/public/services/{service_id}")
def public_service_detail(service_id: int, db: Session = Depends(get_db)):
svc = db.query(Service).filter(Service.id == service_id, Service.active == True).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
return {
"id": svc.id,
"name": svc.name,
"description": svc.description,
"sell_price": svc.sell_price,
"components": [{"name": c.name, "notes": c.notes} for c in svc.components],
}
# ── auth routes ────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
def login_page():
return FileResponse(TEMPLATE_DIR / "login.html")
@app.post("/api/auth/login")
def api_login(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.email == username, User.active == True).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="Wrong username or password")
token = create_access_token(user.id)
resp = JSONResponse({"token": token, "email": user.email, "redirect": "/"})
resp.set_cookie(
key="vela_token",
value=token,
httponly=True,
max_age=JWT_EXPIRE_HOURS * 3600,
samesite="lax",
)
return resp
@app.post("/api/auth/logout")
def api_logout():
resp = JSONResponse({"ok": True})
resp.delete_cookie("vela_token")
return resp
@app.get("/api/verify")
def api_verify(user: User = Depends(get_current_user)):
return {"email": user.email, "id": user.id}
# ── pages ──────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
def homepage():
return FileResponse(TEMPLATE_DIR / "index.html")
@app.get("/admin", response_class=HTMLResponse)
def admin_panel():
return FileResponse(TEMPLATE_DIR / "admin.html")
@app.get("/backup", response_class=HTMLResponse)
def backup_page():
return FileResponse(TEMPLATE_DIR / "backup.html")
# ── services API (admin) ───────────────────────────────────────────────────
@app.get("/api/services")
def list_services(db: Session = Depends(get_db)):
services = db.query(Service).order_by(Service.sort_order).all()
return [
{
"id": s.id,
"name": s.name,
"description": s.description,
"sell_price": s.sell_price,
"cost_price": s.cost_price,
"active": s.active,
"sort_order": s.sort_order,
"components": [
{
"id": c.id,
"name": c.name,
"unit_cost": c.unit_cost,
"unit_sell": c.unit_sell,
"quantity": c.quantity,
"notes": c.notes,
}
for c in s.components
],
}
for s in services
]
@app.put("/api/services/{service_id}")
def update_service(
service_id: int,
data: ServiceUpdate,
db: Session = Depends(get_db),
):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404)
if data.name is not None:
svc.name = data.name
if data.description is not None:
svc.description = data.description
if data.sell_price is not None:
svc.sell_price = data.sell_price
if data.cost_price is not None:
svc.cost_price = data.cost_price
if data.active is not None:
svc.active = data.active
if data.sort_order is not None:
svc.sort_order = data.sort_order
db.commit()
return {"ok": True}
@app.put("/api/services/{service_id}/toggle")
def toggle_service(service_id: int, db: Session = Depends(get_db)):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404)
svc.active = not svc.active
db.commit()
return {"ok": True, "active": svc.active}
@app.put("/api/services/{service_id}/components/{component_id}")
def update_component(
service_id: int,
component_id: int,
data: ComponentUpdate,
db: Session = Depends(get_db),
):
comp = (
db.query(ServiceComponent)
.filter(ServiceComponent.id == component_id, ServiceComponent.service_id == service_id)
.first()
)
if not comp:
raise HTTPException(status_code=404)
if data.name is not None:
comp.name = data.name
if data.unit_cost is not None:
comp.unit_cost = data.unit_cost
if data.quantity is not None:
comp.quantity = data.quantity
# recalc service cost_price
svc = db.query(Service).filter(Service.id == service_id).first()
svc.cost_price = sum(c.unit_cost * c.quantity for c in svc.components)
db.commit()
return {"ok": True}
# ── transactions API ──────────────────────────────────────────────────────
@app.get("/api/transactions")
def list_transactions(
month: Optional[str] = None,
limit: int = 200,
db: Session = Depends(get_db),
):
q = db.query(Transaction).order_by(Transaction.created_at.desc())
if month:
q = q.filter(Transaction.month_start_date == month)
return [
{
"id": t.id,
"source_type": t.source_type,
"source_id": t.source_id,
"service_name": t.service_name_snapshot,
"quantity": t.quantity,
"revenue": t.revenue_total,
"cogs": t.cogs_total,
"profit": t.gross_profit_total,
"month": t.month_start_date,
"payment_status": t.payment_status,
"notes": t.notes,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in q.limit(limit).all()
]
@app.post("/api/transactions")
def create_transaction(
data: TransactionCreate,
db: Session = Depends(get_db),
):
service_id = data.service_id
svc = db.query(Service).filter(Service.id == service_id).first() if service_id else None
svc_name = svc.name if svc else data.service_name or "Manual"
quantity = data.quantity
# snapshot prices NOW or use provided values
unit_sell = data.unit_sell if data.unit_sell is not None else (svc.sell_price if svc else 0)
unit_cost = data.unit_cost if data.unit_cost is not None else (svc.cost_price if svc else 0)
revenue = unit_sell * quantity
cogs = unit_cost * quantity
profit = revenue - cogs
month = data.month or datetime.now(timezone.utc).strftime("%Y-%m")
t = Transaction(
source_type=data.source_type,
source_id=data.source_id,
service_id=service_id,
service_name_snapshot=svc_name,
quantity=quantity,
unit_sell_price_snapshot=unit_sell,
unit_cost_snapshot=unit_cost,
revenue_total=revenue,
cogs_total=cogs,
gross_profit_total=profit,
month_start_date=month,
payment_status=data.payment_status,
notes=data.notes,
)
db.add(t)
db.commit()
return {"ok": True, "id": t.id}
@app.delete("/api/transactions/{txn_id}")
def delete_transaction(txn_id: int, db: Session = Depends(get_db)):
t = db.query(Transaction).filter(Transaction.id == txn_id).first()
if not t:
raise HTTPException(status_code=404, detail="Transaction not found")
db.delete(t)
db.commit()
return {"ok": True}
# ── expenses API ───────────────────────────────────────────────────────────
@app.get("/api/expenses")
def list_expenses(
month: Optional[str] = None,
db: Session = Depends(get_db),
):
q = db.query(Expense).filter(Expense.deleted_at == None).order_by(Expense.created_at.desc())
if month:
q = q.filter(Expense.month_start_date == month)
return [
{
"id": e.id,
"name": e.name,
"amount": e.amount,
"category": e.category,
"month": e.month_start_date,
"notes": e.notes,
}
for e in q.all()
]
@app.post("/api/expenses")
def create_expense(data: ExpenseCreate, db: Session = Depends(get_db)):
e = Expense(
name=data.name,
amount=data.amount,
category=data.category,
month_start_date=data.month or datetime.now(timezone.utc).strftime("%Y-%m"),
notes=data.notes,
)
db.add(e)
db.commit()
return {"ok": True, "id": e.id}
@app.delete("/api/expenses/{expense_id}")
def delete_expense(expense_id: int, db: Session = Depends(get_db)):
e = db.query(Expense).filter(Expense.id == expense_id).first()
if not e:
raise HTTPException(status_code=404)
e.deleted_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
@app.get("/api/expense-templates")
def list_expense_templates(db: Session = Depends(get_db)):
return [
{"id": et.id, "name": et.name, "amount": et.amount, "category": et.category, "active": et.active}
for et in db.query(ExpenseTemplate).all()
]
@app.post("/api/expenses/generate-month")
def generate_monthly_expenses(
month: Optional[str] = None,
db: Session = Depends(get_db),
):
"""Generate actual expenses from active templates for a given month."""
if not month:
month = datetime.now(timezone.utc).strftime("%Y-%m")
templates = db.query(ExpenseTemplate).filter(ExpenseTemplate.active == True).all()
created = 0
for tpl in templates:
existing = (
db.query(Expense)
.filter(
Expense.name == tpl.name,
Expense.month_start_date == month,
Expense.deleted_at == None,
)
.first()
)
if not existing:
db.add(Expense(
name=tpl.name,
amount=tpl.amount,
category=tpl.category,
month_start_date=month,
))
created += 1
db.commit()
return {"ok": True, "created": created, "month": month}
# ── P&L dashboard API ─────────────────────────────────────────────────────
@app.get("/api/pnl")
def get_pnl(month: Optional[str] = None, db: Session = Depends(get_db)):
"""Return P&L for a single month or all months."""
from collections import defaultdict
txn_q = db.query(Transaction)
exp_q = db.query(Expense).filter(Expense.deleted_at == None)
if month:
txn_q = txn_q.filter(Transaction.month_start_date == month)
exp_q = exp_q.filter(Expense.month_start_date == month)
transactions = txn_q.all()
expenses = exp_q.all()
# aggregate by month
monthly = defaultdict(lambda: {"revenue": 0, "cogs": 0, "gross_profit": 0, "expenses": 0, "net_profit": 0, "txn_count": 0})
for t in transactions:
m = t.month_start_date
monthly[m]["revenue"] += t.revenue_total
monthly[m]["cogs"] += t.cogs_total
monthly[m]["gross_profit"] += t.gross_profit_total
monthly[m]["txn_count"] += 1
for e in expenses:
monthly[e.month_start_date]["expenses"] += e.amount
for m in monthly:
monthly[m]["net_profit"] = monthly[m]["gross_profit"] - monthly[m]["expenses"]
# summary
total_revenue = sum(v["revenue"] for v in monthly.values())
total_cogs = sum(v["cogs"] for v in monthly.values())
total_gross = sum(v["gross_profit"] for v in monthly.values())
total_expenses = sum(v["expenses"] for v in monthly.values())
total_net = total_gross - total_expenses
margin = (total_gross / total_revenue * 100) if total_revenue else 0
return {
"summary": {
"revenue": total_revenue,
"cogs": total_cogs,
"gross_profit": total_gross,
"expenses": total_expenses,
"net_profit": total_net,
"margin_pct": round(margin, 1),
},
"months": dict(sorted(monthly.items())),
}
@app.get("/health")
def health():
return {"status": "ok"}
# ── main ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
+116
View File
@@ -0,0 +1,116 @@
"""Vela Platform — SQLAlchemy models."""
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
create_engine,
)
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class Service(Base):
__tablename__ = "services"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, default="")
sell_price = Column(Float, default=0.0)
cost_price = Column(Float, default=0.0) # sum of component unit_cost * quantity
active = Column(Boolean, default=True, nullable=False)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
components = relationship("ServiceComponent", back_populates="service",
cascade="all, delete-orphan")
class ServiceComponent(Base):
__tablename__ = "service_components"
id = Column(Integer, primary_key=True, autoincrement=True)
service_id = Column(Integer, ForeignKey("services.id", ondelete="CASCADE"), nullable=False)
name = Column(String(100), nullable=False)
unit_cost = Column(Float, default=0.0)
unit_sell = Column(Float, default=0.0)
quantity = Column(Integer, default=1)
notes = Column(Text, default="")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
service = relationship("Service", back_populates="components")
class Transaction(Base):
"""Frozen transaction — snapshots prices at time of sale."""
__tablename__ = "transactions"
__table_args__ = (
UniqueConstraint("source_type", "source_id", name="uq_transaction_source"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
source_type = Column(String(20), default="manual") # "manual" or "order"
source_id = Column(Integer, nullable=True)
service_id = Column(Integer, ForeignKey("services.id"), nullable=True)
service_name_snapshot = Column(String(100), nullable=False)
quantity = Column(Integer, default=1)
unit_sell_price_snapshot = Column(Float, nullable=False)
unit_cost_snapshot = Column(Float, nullable=False)
revenue_total = Column(Float, nullable=False)
cogs_total = Column(Float, nullable=False)
gross_profit_total = Column(Float, nullable=False)
month_start_date = Column(String(10), nullable=False) # "2026-06"
payment_status = Column(String(20), default="unpaid") # unpaid, partial, paid, refunded
notes = Column(Text, default="")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class Expense(Base):
__tablename__ = "expenses"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), nullable=False)
amount = Column(Float, nullable=False)
category = Column(String(100), default="general")
month_start_date = Column(String(10), nullable=False) # "2026-06"
notes = Column(Text, default="")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
deleted_at = Column(DateTime, nullable=True)
class ExpenseTemplate(Base):
"""Recurring expense templates — generate actual expenses each month."""
__tablename__ = "expense_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), nullable=False)
amount = Column(Float, nullable=False)
category = Column(String(100), default="general")
active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
+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