Vela Platform v2: single monolith, real P&L, bilingual marketing site
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
vela.db
|
||||||
|
vela.db-shm
|
||||||
|
vela.db-wal
|
||||||
|
.git/
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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 |
Reference in New Issue
Block a user