feat(5.16+5.17): tipografie/antet branded + tipuri cont, planuri si enforcement

PRD 5.16 — propagare design finalizata (system font stack, fara IBM Plex self-hostat):
- US-001/002/008: tokeni --font-ui/--font-mono (system stack) + scala --fs-*; zero
  @font-face si zero /static/fonts/; landing aliniat la acelasi stack
- US-003: RAR online = dot compact in antet + meniu burger; banda rosie DOAR pe blocat
  (invariant zero-silent-failures pastrat)
- US-010: antet "ROMFAST AUTOPASS" + nume service + /login brandeit 2 coloane + badge plan;
  meniu burger cu separatoare; gate strict pe is_authenticated
- US-011: selector tema pill icon+eticheta (reuse THEMES)
- US-004/005/006/007: bug-fix editor prestatii (picker cod+denumire, add_extra in mod
  operatii, cod ales se salveaza fara "+", Renunta inchide via closest)
- US-012/013: landing Autentificare->/login; wizard import colapsat + 4 pasi pe tokeni
- fix VERIFY E2E: contoare duplicate pe 390px (inline display:flex batea @media) -> CSS + test-lock

PRD 5.17 — tipuri de cont + trial Pro 30z + enforcement DUR:
- US-001/002/008: accounts.tier + trial_until (migrare aditiva defensiva); app/plans.py
  sursa unica (PLANS, FREE_MONTHLY_LIMIT=60, effective_tier(now injectabil), monthly_usage,
  CONSUMED_STATUSES); create_account trial Pro 30z; CLI set-tier (protejat id=1, audit)
- US-003/004/005: enforce volum 60/luna INAINTE de build_key pe ambele canale
  (PLAN_LIMITA_LUNARA, 3 niveluri + log_event); gate API Pro+ (PLAN_FARA_API 403 actionabil);
  valideaza/nomenclator raman permise; downgrade lazy; flag AUTOPASS_ENFORCE_PLANS (kill-switch)
- US-006: badge plan antet + linie burger + consum N/60 + warn>=80% + 6 stari + copy RO
  pluralizat + banner one-time trial->Gratuit + pagina Cont

Regresie: 1380 passed, 0 failed, 1 deselected (live). E2E browser pe 390/1280 confirmat.
Backend trimitere (worker/masina stari/idempotenta/contract RAR) NEATINS. Lucrul 5.18
(corpus kNN) ramane separat, necomis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-29 06:01:05 +00:00
parent 9eccb9f6fa
commit c9f9a1ca0e
37 changed files with 3433 additions and 449 deletions

View File

@@ -15,6 +15,7 @@ inca fluxul de trimitere. (Addendum A2.)
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone
def _norm_cui(cui: str | None) -> str | None: def _norm_cui(cui: str | None) -> str | None:
@@ -57,10 +58,16 @@ def create_account(
cui = _norm_cui(cui) cui = _norm_cui(cui)
email = _norm_email(email) email = _norm_email(email)
try: try:
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
trial_until = (
(datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
)
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'. # Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
cur = conn.execute( cur = conn.execute(
"INSERT INTO accounts (name, cui, email, active, status) VALUES (?, ?, ?, ?, ?)", "INSERT INTO accounts (name, cui, email, active, status, tier, trial_until) "
(name, cui, email, 1 if active else 0, "active" if active else "pending"), "VALUES (?, ?, ?, ?, ?, ?, ?)",
(name, cui, email, 1 if active else 0, "active" if active else "pending",
"free", trial_until),
) )
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone() existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
@@ -107,6 +114,8 @@ def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
# Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de # Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de
# retentie); restul sunt reversibile. # retentie); restul sunt reversibile.
VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted") VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted")
# Tieruri de cont valide (5.17). Sursa de adevar: app/plans.py#PLANS (nu duplica valorile).
VALID_TIERS = ("free", "standard", "pro", "premium")
# Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b). # Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b).
_PROTECTED_ACCOUNT_ID = 1 _PROTECTED_ACCOUNT_ID = 1
@@ -131,6 +140,51 @@ def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
) )
def set_tier(
conn: sqlite3.Connection,
account_id: int,
tier: str,
trial_until: str | None = None,
) -> None:
"""Seteaza planul unui cont (tier + trial_until).
tier invalid -> ValueError cu mesaj clar.
Contul de sistem id=1 e protejat (ca set_status).
Cont inexistent -> ValueError.
Logheaza schimbarea in app_events (reuse observ.log_event, fara PII nou).
trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul).
"""
if tier not in VALID_TIERS:
raise ValueError(
f"tier invalid: {tier!r} (valid: {', '.join(VALID_TIERS)})"
)
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not row:
raise ValueError(f"cont inexistent: {account_id}")
if account_id == _PROTECTED_ACCOUNT_ID:
raise ValueError(
"Contul default (id=1) nu poate fi mutat pe alt plan via CLI "
"(cont de sistem, tratat coerent)."
)
conn.execute(
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
(tier, trial_until, account_id),
)
# Audit in app_events (decizie PRD 5.17 US-008, fara PII nou)
try:
from .observ import log_event
log_event(
"plan_schimbare_tier",
account_id=account_id,
mesaj=f"tier -> {tier}",
context={"tier": tier, "trial_until": trial_until},
conn=conn,
)
except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event)
pass
def delete_account(conn: sqlite3.Connection, account_id: int) -> None: def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele """Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
@@ -154,7 +208,7 @@ def list_accounts(conn: sqlite3.Connection) -> list[dict]:
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted' """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
(stergere soft -> invizibile in panou).""" (stergere soft -> invizibile in panou)."""
rows = conn.execute( rows = conn.execute(
"SELECT id, name, cui, email, active, status, created_at FROM accounts " "SELECT id, name, cui, email, active, status, tier, trial_until, created_at FROM accounts "
"WHERE status != 'deleted' ORDER BY id" "WHERE status != 'deleted' ORDER BY id"
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]

View File

@@ -29,8 +29,10 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from datetime import datetime, timezone
from ... import errors from ... import errors
from ...auth import resolve_account_id from ...auth import require_api_access, resolve_account_id
from ...crypto import decrypt_creds, encrypt_creds from ...crypto import decrypt_creds, encrypt_creds
from ...db import get_connection from ...db import get_connection
from ...idempotency import build_key, canonicalize_row from ...idempotency import build_key, canonicalize_row
@@ -413,7 +415,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di
async def upload_import( async def upload_import(
file: UploadFile, file: UploadFile,
sheet_name: str | None = None, sheet_name: str | None = None,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(require_api_access),
) -> dict: ) -> dict:
"""Upload fisier xlsx/csv -> staging in import_batches/import_rows. """Upload fisier xlsx/csv -> staging in import_batches/import_rows.
@@ -934,7 +936,7 @@ class CommitIn(BaseModel):
def commit_import( def commit_import(
import_id: int, import_id: int,
req: CommitIn, req: CommitIn,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(require_api_access),
) -> dict: ) -> dict:
"""Gate HARD confirmare + enqueue randuri ok + log atestare. """Gate HARD confirmare + enqueue randuri ok + log atestare.
@@ -1022,6 +1024,48 @@ def commit_import(
if n_total_ok == 0: if n_total_ok == 0:
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.") raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.")
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
from ...config import get_settings as _get_settings
from ...plans import PLANS, effective_tier, monthly_usage
from ...observ import log_event as _log_event_plan
_settings = _get_settings()
if _settings.enforce_plans:
_acct_row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
).fetchone()
_now = datetime.now(timezone.utc)
_et = effective_tier(_acct_row, _now)
_plan_limit = PLANS[_et].get("monthly_limit")
if _plan_limit is not None:
_usage = monthly_usage(conn, acct, _now)
if _usage + n_total_ok > _plan_limit:
_remaining = max(0, _plan_limit - _usage)
_log_event_plan(
"plan_limita_lunara_atinsa",
account_id=acct,
nivel="WARNING",
mesaj=f"Import de {n_total_ok} respins (usage={_usage}, limita={_plan_limit})",
context={
"n_to_enqueue": n_total_ok, "usage": _usage,
"plan_limit": _plan_limit, "tier": _et,
},
conn=conn,
)
raise HTTPException(
status_code=422,
detail={
"error": "plan_limita_lunara",
**errors.eroare(
"PLAN_LIMITA_LUNARA",
cauza=(
f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;"
f" mai poti trimite {_remaining}."
),
),
},
)
# Incarca maparea de coloane pentru a construi payload-ul # Incarca maparea de coloane pentru a construi payload-ul
first_row_db = conn.execute( first_row_db = conn.execute(
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1", "SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",

View File

@@ -13,11 +13,13 @@ import csv
import io import io
import json import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ...auth import resolve_account_id from ...auth import require_api_access, resolve_account_id
from ...crypto import encrypt_creds from ...crypto import encrypt_creds
from ...db import get_connection from ...db import get_connection
from ...errors import eroare as err_eroare from ...errors import eroare as err_eroare
@@ -135,7 +137,7 @@ def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult:
@router.post("/prezentari", response_model=PrezentariResponse) @router.post("/prezentari", response_model=PrezentariResponse)
def create_prezentari( def create_prezentari(
req: PrezentareRequest, req: PrezentareRequest,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(require_api_access),
) -> PrezentariResponse: ) -> PrezentariResponse:
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission. """Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
@@ -165,6 +167,46 @@ def create_prezentari(
# Reguli text incarcate o data per cerere (seam partajat cu dry-run). # Reguli text incarcate o data per cerere (seam partajat cu dry-run).
text_rules = load_text_rules(conn, acct) text_rules = load_text_rules(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
# T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
from ...config import get_settings as _get_settings
from ...plans import PLANS, effective_tier, monthly_usage
_settings = _get_settings()
if _settings.enforce_plans:
_acct_row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
).fetchone()
_now = datetime.now(timezone.utc)
_et = effective_tier(_acct_row, _now)
_plan_limit = PLANS[_et].get("monthly_limit")
if _plan_limit is not None:
_usage = monthly_usage(conn, acct, _now)
_nr_cerut = len(req.prezentari)
if _usage + _nr_cerut > _plan_limit:
_remaining = max(0, _plan_limit - _usage)
log_event(
"plan_limita_lunara_atinsa",
account_id=acct,
nivel="WARNING",
mesaj=f"Lot de {_nr_cerut} respins (usage={_usage}, limita={_plan_limit})",
context={
"nr_cerut": _nr_cerut, "usage": _usage,
"plan_limit": _plan_limit, "tier": _et,
},
conn=conn,
)
raise HTTPException(
status_code=422,
detail=err_eroare(
"PLAN_LIMITA_LUNARA",
cauza=(
f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;"
f" mai poti trimite {_remaining}."
),
),
)
for prez in req.prezentari: for prez in req.prezentari:
content = prez.model_dump() content = prez.model_dump()
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). # canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).

View File

@@ -18,8 +18,9 @@ from __future__ import annotations
import hashlib import hashlib
import secrets import secrets
import sqlite3 import sqlite3
from datetime import datetime, timezone
from fastapi import Header, HTTPException, Request from fastapi import Depends, Header, HTTPException, Request
from .config import get_settings from .config import get_settings
from .db import get_connection from .db import get_connection
@@ -162,3 +163,59 @@ def resolve_account_id(
_log_auth_esuat(request, plaintext, "cheie API invalida sau revocata") _log_auth_esuat(request, plaintext, "cheie API invalida sau revocata")
raise HTTPException(status_code=401, detail="cheie API invalida sau revocata") raise HTTPException(status_code=401, detail="cheie API invalida sau revocata")
return account_id return account_id
def require_api_access(
account_id: int = Depends(resolve_account_id),
) -> int:
"""Dependency FastAPI (T4, PRD 5.17): verifica ca tier-ul efectiv permite accesul la API.
Reguli:
- enforce_plans=False (kill-switch): sare verificarea.
- dev id=1 cu require_api_key=False: bypass (dogfooding, testele existente nu pica).
- Pro/Premium sau trial Pro activ: permit.
- Free/Standard fara trial: 403 PLAN_FARA_API cu eroare 3 niveluri.
Refoloseste resolve_account_id (account_id deja rezolvat din cheie API).
Se ataseaza ca Depends() pe rutele de ingestie API (POST /v1/prezentari,
POST /v1/import, POST /v1/import/{id}/commit). valideaza + nomenclator raman libere.
"""
from .plans import PLANS, effective_tier
from .errors import eroare as _eroare
settings = get_settings()
# Kill-switch operare: sare toate gate-urile de plan.
if not settings.enforce_plans:
return account_id
# Bypass pentru contul implicit dev (id=1) in modul fara cheie API obligatorie.
# In prod (require_api_key=True), id=1 nu are bypass implicit (cheie = obligatorie).
if not settings.require_api_key and account_id == DEFAULT_ACCOUNT_ID:
return account_id
conn = get_connection()
try:
row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (account_id,)
).fetchone()
finally:
conn.close()
now = datetime.now(timezone.utc)
et = effective_tier(row, now)
if not PLANS[et].get("api_access"):
from .observ import log_event
log_event(
"plan_api_refuzat",
account_id=account_id,
nivel="WARNING",
mesaj=f"Acces API refuzat: tier efectiv={et}",
context={"tier_efectiv": et},
)
raise HTTPException(
status_code=403,
detail=_eroare(
"PLAN_FARA_API",
cauza=f"Tier efectiv: {et}. API disponibil pe Pro/Premium.",
),
)
return account_id

View File

@@ -104,6 +104,13 @@ class Settings(BaseSettings):
worker_retry_max_s: int = 300 worker_retry_max_s: int = 300
worker_max_retries: int = 8 # peste atat -> error + banner worker_max_retries: int = 8 # peste atat -> error + banner
# --- Planuri de cont (PRD 5.17) ---
# Enforcement DUR al limitelor de plan (volum + acces API). True (implicit) = activ.
# False = kill-switch de operare: sare toate gate-urile de plan (util pentru debugging
# sau rollback rapid fara revert de cod). Enforcement DUR e activ implicit de la deploy
# (decizie user 2026-06-28, decizia #22 autoplan): nu exista conturi legacy, produs in TESTE.
enforce_plans: bool = True
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) --- # --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# DEZACTIVAT implicit: prima folosire lazy-load-eaza modelul fastembed/ONNX # DEZACTIVAT implicit: prima folosire lazy-load-eaza modelul fastembed/ONNX
# (~230MB pe disc) sincron in thread-ul de cerere -> hang la prima cerere /mapari. # (~230MB pe disc) sincron in thread-ul de cerere -> hang la prima cerere /mapari.

View File

@@ -84,6 +84,15 @@ def _migrate(conn: sqlite3.Connection) -> None:
if "email" not in acc_cols: if "email" not in acc_cols:
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy. # Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT") conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
if "tier" not in acc_cols:
# Plan de cont (US-001, PRD 5.17). Legacy -> 'free' fara trial (enforcement DUR la deploy).
conn.execute(
"ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT 'free' "
"CHECK (tier IN ('free','standard','pro','premium'))"
)
if "trial_until" not in acc_cols:
# Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial).
conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT")
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu. # Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
conn.execute( conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" "CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"

View File

@@ -178,6 +178,22 @@ CATALOG: dict[str, dict[str, str]] = {
" cererii (request_id) afisat." " cererii (request_id) afisat."
), ),
}, },
# Coduri de plan (PRD 5.17)
"PLAN_LIMITA_LUNARA": {
"problema": "Ai atins limita planului Gratuit (60 prestatii/luna)",
"fix": (
"Treci pe planul Standard sau Pro, sau asteapta inceperea lunii urmatoare."
" Numarul de prestatii ramase in luna curenta e in campul cauza."
),
},
"PLAN_FARA_API": {
"problema": "Importul prin API e disponibil pe planul Pro",
"fix": (
"Planul tau curent nu include accesul la API."
" Endpoint-ul /v1/prezentari/valideaza ramane disponibil pentru testare fara upgrade."
" Contacteaza-ne pentru a face upgrade la planul Pro."
),
},
} }

130
app/plans.py Normal file
View File

@@ -0,0 +1,130 @@
"""Definitia planurilor de cont (sursa unica de adevar). Modul PUR, fara import DB/HTTP.
Pattern ca app/errors.py: catalog + helperi. Consumat de rutele de ingestie si dashboard.
Nu importa DB, HTTP, sau orice alt modul intern cu efecte secundare.
Decizii implementare (PRD 5.17 / autoplan 2026-06-28):
- FREE_MONTHLY_LIMIT: constanta unica (T-CEO-2), tunabila fara arqueologie de cod.
- CONSUMED_STATUSES: decizie #20 — prestatie consumata = acceptata in coada.
- effective_tier: `now` injectabil (decizie #2) pentru teste deterministe.
- monthly_usage: pattern E7/5.15 (strftime localtime), `now` injectabil.
"""
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
# Limita lunara pentru planul Gratuit.
# Decizie user T-CEO-2 (2026-06-28): o singura constanta, referita din PLANS.
# Tunabila fara a modifica logica de enforcement.
FREE_MONTHLY_LIMIT: int = 60
# Statusurile care consuma din cota lunara (decizie #20, 2026-06-28).
# Prestatie consumata = acceptata in coada (queued/sending/sent), nu cele respinse/blocate.
# Rationale: limita e pe ce trimitem la RAR, nu pe incercari esuate sau blocate.
CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent")
# Sursa unica de adevar pentru planuri. Fiecare plan are:
# label -- eticheta afisata in RO (UI, mesaje)
# monthly_limit -- None = nelimitat; int = limita prestatii/luna
# api_access -- True = acces import prin API (/v1/*); False = doar web dashboard
#
# Aliniat landing-ului comercial (PRD 5.17 US-001):
# Gratuit: 60/luna, fara API
# Standard: nelimitat, fara API
# Pro: nelimitat, cu API
# Premium: nelimitat, cu API (suport dedicat)
PLANS: dict[str, dict] = {
"free": {
"label": "Gratuit",
"monthly_limit": FREE_MONTHLY_LIMIT,
"api_access": False,
},
"standard": {
"label": "Standard",
"monthly_limit": None,
"api_access": False,
},
"pro": {
"label": "Pro",
"monthly_limit": None,
"api_access": True,
},
"premium": {
"label": "Premium",
"monthly_limit": None,
"api_access": True,
},
}
def effective_tier(account_row, now: datetime) -> str:
"""Returneaza tier-ul efectiv al contului la momentul `now` (injectabil pentru determinism).
Daca `trial_until` e in viitor -> 'pro' (trial Pro activ).
Altfel -> `tier`-ul de baza al contului.
trial_until malformat/NULL -> fallback defensiv la tier de baza (nu arunca niciodata).
`now` TREBUIE injectat explicit (nu datetime.now() intern) — decizie #2 din autoplan.
Suporta sqlite3.Row si dict.
"""
# Citire robusta: suporta sqlite3.Row (IndexError pe key absent) si dict (KeyError)
try:
tier = account_row["tier"]
except (KeyError, IndexError, TypeError):
tier = "free"
try:
trial_until_str = account_row["trial_until"]
except (KeyError, IndexError, TypeError):
trial_until_str = None
# Fallback defensiv la 'free' daca tier e None/gol
if not tier:
tier = "free"
if not trial_until_str:
return tier
try:
# Parseaza trial_until; stocam ca "YYYY-MM-DD HH:MM:SS" (UTC implicit) sau ISO
tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T"))
# Daca fara timezone -> assume UTC (cum stocam in DB)
if tu.tzinfo is None:
tu = tu.replace(tzinfo=timezone.utc)
# Normalizeaza `now` la aware daca e naive
now_cmp = now
if now_cmp.tzinfo is None:
now_cmp = now_cmp.replace(tzinfo=timezone.utc)
if tu > now_cmp:
return "pro"
except (ValueError, AttributeError, TypeError):
pass # malformat -> fallback defensiv la tier de baza
return tier
def monthly_usage(conn: sqlite3.Connection, account_id: int, now: datetime) -> int:
"""Numara prestatiile contului acceptate in coada in luna calendaristica curenta.
Definitia 'luna curenta': strftime('%Y-%m', created_at, 'localtime') corespunde
lunii lui `now` (acelasi pattern ca E7/5.15 din routes.py — consistent cu 'localtime').
`now` injectabil pentru teste deterministe. Scoped strict pe account_id.
created_at NULL/malformat -> exclus defensiv (nu arunca niciodata).
NOTA: containerul are /etc/localtime=UTC, deci 'localtime' = UTC in mediul de test.
Testele de granita construiesc timestamp-uri relative la luna curenta calculata cu
acelasi 'localtime', nu valori absolute care presupun +2/+3h.
"""
# Formatam `now` ca string SQLite si folosim acelasi modificator 'localtime' ca routes.py
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
placeholders = ",".join("?" * len(CONSUMED_STATUSES))
row = conn.execute(
f"SELECT COUNT(*) AS n FROM submissions "
f"WHERE account_id = ? "
f" AND status IN ({placeholders}) "
f" AND created_at IS NOT NULL "
f" AND strftime('%Y-%m', created_at, 'localtime') = strftime('%Y-%m', ?, 'localtime')",
(account_id, *CONSUMED_STATUSES, now_str),
).fetchone()
return int(row["n"]) if row else 0

View File

@@ -25,6 +25,13 @@ CREATE TABLE IF NOT EXISTS accounts (
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error. -- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
CHECK (on_unmapped_error_default IN (0, 1)), CHECK (on_unmapped_error_default IN (0, 1)),
-- Plan de cont (5.17). Tier de baza al contului (admin aloca manual via CLI set-tier).
-- trial_until: daca != NULL si > now -> effective_tier() intoarce 'pro' (trial Pro activ).
-- Cont nou primeste tier='free' + trial_until=now+30z via create_account.
-- Contul implicit id=1 (dev) primeste DEFAULT 'free' + trial_until=NULL (fara trial).
tier TEXT NOT NULL DEFAULT 'free'
CHECK (tier IN ('free','standard','pro','premium')),
trial_until TEXT, -- ISO datetime UTC sau NULL; nullable
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi -- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi

View File

@@ -26,6 +26,7 @@ from fastapi.templating import Jinja2Templates
from .. import __version__ from .. import __version__
from .. import errors as _errors from .. import errors as _errors
from ..auth import rotate_api_key from ..auth import rotate_api_key
from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS
from ..payload_view import prezentare_din_payload from ..payload_view import prezentare_din_payload
from ..web.csrf import get_csrf_token, verify_csrf from ..web.csrf import get_csrf_token, verify_csrf
from .labels import ( from .labels import (
@@ -374,7 +375,7 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
).fetchone() ).fetchone()
are_creds = bool(row and row["rar_creds_enc"]) are_creds = bool(row and row["rar_creds_enc"])
account_meta = _fetch_account_meta(conn, acct) account_meta = _fetch_account_meta(conn, acct)
return templates.get_template("_cont.html").render({ cont_ctx = {
"request": request, "request": request,
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
"api_key": None, "api_key": None,
@@ -385,7 +386,10 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
"account_meta": account_meta, "account_meta": account_meta,
"date_firma_mesaj": None, "date_firma_mesaj": None,
"date_firma_eroare": None, "date_firma_eroare": None,
}) }
# US-006 (5.17): context plan pentru sectiunea Plan din _cont.html.
cont_ctx.update(_plan_ctx(conn, account_id))
return templates.get_template("_cont.html").render(cont_ctx)
def _render_panel_nomenclator(request: Request, conn) -> str: def _render_panel_nomenclator(request: Request, conn) -> str:
@@ -531,6 +535,139 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta
return _render_panel_acasa(request) return _render_panel_acasa(request)
# Etichete tier pentru badge in antet (US-010 PRD 5.16).
_TIER_LABELS: dict[str, str] = {
"free": "Gratuit",
"standard": "Standard",
"pro": "Pro",
"premium": "Premium",
}
def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict:
"""Context afisaj plan (6 stari US-006 PRD 5.17) pentru _status.html, _cont.html si burger.
Returneaza:
plan_linie — linie completa cu copy RO (cele 6 stari)
plan_warn — True la >=80% consum sau limita atinsa (culoare + text)
plan_limita_atinsa — True la 100% consum (--err in loc de --warn)
trial_expirat_recent — True daca trial_until era setat si a expirat (banner one-time)
usage_lunar — numar prestatii acceptate in coada luna curenta
monthly_limit_val — limita lunara (60 pt free, None pt nelimitat)
effective_tier_name — tier-ul efectiv ('free','standard','pro','premium')
"""
if now is None:
now = datetime.now(timezone.utc)
acct = account_or_default(account_id)
row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
).fetchone()
tier_base = (row["tier"] if row else None) or "free"
trial_until_str = (row["trial_until"] if row else None)
eff = _eff_tier(row, now) if row else "free"
monthly_limit = _PLANS.get(eff, _PLANS["free"]).get("monthly_limit")
usage = _monthly_usage(conn, acct, now)
# Calcul zile ramase din trial activ
trial_ultima_zi = False
trial_days: int | None = None
if trial_until_str and eff == "pro" and tier_base == "free":
try:
tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T"))
if tu.tzinfo is None:
tu = tu.replace(tzinfo=timezone.utc)
now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc)
delta = tu - now_cmp
trial_days = delta.days # 0 = < 1 zi ramasa (azi), 1 = < 2 zile, etc.
trial_ultima_zi = (trial_days <= 0)
except (ValueError, AttributeError, TypeError):
pass
# Construieste plan_linie si stari aferente (cele 6 stari din PRD)
warn_aproape = False
plan_limita_atinsa = False
trial_expirat_recent = False
if eff == "pro" and tier_base == "free" and trial_until_str:
# Trial Pro activ
if trial_ultima_zi:
plan_linie = "Plan: Pro · trial expira azi"
else:
n = trial_days or 0
z = "zi" if n == 1 else "zile"
plan_linie = f"Plan: Pro · trial {n} {z} ramase"
elif eff == "free":
# Free — cu sau fara trial expirat recent
if trial_until_str:
trial_expirat_recent = True
if monthly_limit is not None:
if usage >= monthly_limit:
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — limita atinsa"
warn_aproape = True
plan_limita_atinsa = True
elif monthly_limit > 0 and usage >= int(monthly_limit * 0.8):
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — aproape de limita"
warn_aproape = True
else:
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} luna asta"
else:
plan_linie = "Plan: Gratuit"
else:
# Platit (tier de baza != free, ex. standard/pro/premium alocat de admin)
label = _PLANS.get(eff, {}).get("label", eff.capitalize())
plan_linie = f"Plan: {label}"
return {
"plan_linie": plan_linie,
"plan_warn": warn_aproape,
"plan_limita_atinsa": plan_limita_atinsa,
"trial_expirat_recent": trial_expirat_recent,
"usage_lunar": usage,
"monthly_limit_val": monthly_limit,
"effective_tier_name": eff,
}
def _layout_header_ctx(conn, account_id: int) -> dict:
"""Context suplimentar pentru antetul branduit (US-010/003, PRD 5.16).
Citeste account_name, tier si starea de sanatate RAR pentru a popula:
- account_name: numele service-ului, afisat sub titlu cand logat
- tier_label: eticheta planului (Gratuit/Standard/Pro/Premium)
- sanatate_ok: True daca worker viu si RAR ok (dot verde in antet)
- last_login: data/ora ultimei autentificari RAR (format romanesc)
- plan_linie + plan_warn + ...: context plan US-006 (5.17) pentru burger
Apelat aditiv din dashboard() fara a atinge alti handlere.
"""
row = conn.execute(
"SELECT name, tier FROM accounts WHERE id=?", (account_id,)
).fetchone()
account_name = (row["name"] if row else None) or ""
tier = (row["tier"] if row else "free") or "free"
tier_label = _TIER_LABELS.get(tier, "Gratuit")
hb = read_heartbeat(conn)
worker_alive = _worker_alive(hb)
rar_state = _rar_state(hb, worker_alive)
rar_ok = rar_state == "ok"
sanatate_ok = worker_alive and rar_ok
ctx = {
"account_name": account_name,
"tier_label": tier_label,
"sanatate_ok": sanatate_ok,
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
}
# US-006 (5.17): context plan pentru linia detaliata din meniul burger.
ctx.update(_plan_ctx(conn, account_id))
return ctx
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse: def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
"""Dashboard principal cu tab-uri. """Dashboard principal cu tab-uri.
@@ -578,6 +715,9 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -
"is_admin": is_account_admin(conn, account_id), "is_admin": is_account_admin(conn, account_id),
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
} }
# US-010/003 (PRD 5.16): context antet (account_name, tier, sanatate RAR).
# Adaugat aditiv, fara a atinge handlerele altora.
ctx.update(_layout_header_ctx(conn, account_id))
return templates.TemplateResponse("dashboard.html", ctx) return templates.TemplateResponse("dashboard.html", ctx)
finally: finally:
conn.close() conn.close()
@@ -749,7 +889,7 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
else: else:
sanatate_text = "Declaratiile curg normal" sanatate_text = "Declaratiile curg normal"
return { status_ctx = {
"request": request, "request": request,
"worker_lbl": worker_lbl, "worker_lbl": worker_lbl,
"rar_lbl": rar_lbl, "rar_lbl": rar_lbl,
@@ -771,6 +911,9 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
"mapari_badge": counts.get("needs_mapping", 0), "mapari_badge": counts.get("needs_mapping", 0),
"oob": oob, "oob": oob,
} }
# US-006 (5.17): context plan pentru linia de consum/trial in _status.html.
status_ctx.update(_plan_ctx(conn, account_id))
return status_ctx
@router.get("/_fragments/status", response_class=HTMLResponse) @router.get("/_fragments/status", response_class=HTMLResponse)
@@ -1363,6 +1506,18 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
c.strip().upper() if isinstance(c, str) else "" c.strip().upper() if isinstance(c, str) else ""
for c in codes_raw for c in codes_raw
] ]
# US-006 (5.16): codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare.
# Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit.
# Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional.
# Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899).
_flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
if _flat_picker and _flat_picker not in codes_positional:
codes_positional.append(_flat_picker)
for _i in range(len(codes_positional)):
if not codes_positional[_i]:
_op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper()
if _op_val:
codes_positional[_i] = _op_val
# Verifica daca cel putin un cod non-gol a fost trimis # Verifica daca cel putin un cod non-gol a fost trimis
codes_nonempty = [c for c in codes_positional if c] codes_nonempty = [c for c in codes_positional if c]
if codes_nonempty: if codes_nonempty:
@@ -1923,6 +2078,7 @@ async def post_form_chips(request: Request) -> HTMLResponse:
}) })
action = str(form.get("chips_action") or "").strip() action = str(form.get("chips_action") or "").strip()
chips_extra_error = False # T-C1/T-E4 (5.16): semnal pentru add_extra esuat
conn = get_connection() conn = get_connection()
try: try:
@@ -1950,6 +2106,28 @@ async def post_form_chips(request: Request) -> HTMLResponse:
if exists: if exists:
chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""}) chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""})
elif action == "add_extra":
# US-005 (5.16): Adauga cod RAR liber (extra, fara op_service) in modul operatii.
# Refoloseste `chips_add_cod_flat` (acelasi select; dedup per-item E4 pastrat).
# T-C1/T-E4: select gol sau cod invalid → chips_extra_error = True (semnal vizibil).
add_cod_extra = str(form.get("chips_add_cod_flat") or "").strip().upper()
if add_cod_extra:
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod_extra,)
).fetchone()
if exists:
# Dedup per-item (E4): nu adauga un chip extra identic deja existent
existing_pairs = {
(c.get("cod_op_service", ""), c.get("cod_prestatie", ""))
for c in chips
}
if ("", add_cod_extra) not in existing_pairs:
chips.append({"cod_prestatie": add_cod_extra, "cod_op_service": "", "denumire": ""})
else:
chips_extra_error = True # cod necunoscut in nomenclator
else:
chips_extra_error = True # select gol
elif action == "remove": elif action == "remove":
# Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata) # Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata)
try: try:
@@ -1983,6 +2161,7 @@ async def post_form_chips(request: Request) -> HTMLResponse:
"has_r_odo": has_r_odo, "has_r_odo": has_r_odo,
"form_chips_url": "/form-chips", "form_chips_url": "/form-chips",
"chips_section_id": "chips-section", "chips_section_id": "chips-section",
"chips_extra_error": chips_extra_error, # T-C1/T-E4 (5.16)
}) })
@@ -3525,6 +3704,46 @@ async def web_confirma_import(
n_total_ok = len(to_enqueue) n_total_ok = len(to_enqueue)
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). Canal web.
from ..config import get_settings as _get_settings_plan
from ..plans import PLANS as _PLANS, effective_tier as _effective_tier, monthly_usage as _monthly_usage
_plan_settings = _get_settings_plan()
if _plan_settings.enforce_plans and n_total_ok > 0:
from datetime import datetime, timezone as _tz
_acct_row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
).fetchone()
_now_plan = datetime.now(_tz.utc)
_et = _effective_tier(_acct_row, _now_plan)
_plan_limit = _PLANS[_et].get("monthly_limit")
if _plan_limit is not None:
_usage = _monthly_usage(conn, acct, _now_plan)
if _usage + n_total_ok > _plan_limit:
_remaining = max(0, _plan_limit - _usage)
log_event(
"plan_limita_lunara_atinsa",
account_id=acct,
nivel="WARNING",
mesaj=f"Import web de {n_total_ok} respins (usage={_usage}, limita={_plan_limit})",
context={
"n_to_enqueue": n_total_ok, "usage": _usage,
"plan_limit": _plan_limit, "tier": _et,
},
conn=conn,
)
_err_msg = (
f"Ai atins limita planului Gratuit: {_usage}/{_plan_limit} prezentari luna aceasta."
f" Mai poti trimite {_remaining} luna aceasta."
f" Treci pe Standard sau Pro, sau asteapta luna viitoare."
)
_prev_result = _web_compute_preview(conn, import_id, account_id)
if isinstance(_prev_result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=_err_msg))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=_err_msg, error=True, **_prev_result
))
# Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis # Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis
if n_confirmat != n_total_ok: if n_confirmat != n_total_ok:
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)

View File

@@ -21,7 +21,7 @@
In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul
intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #} intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #}
<details id="import-details"{% if not are_trimiteri %} open{% endif %}> <details id="import-details"{% if not are_trimiteri %} open{% endif %}>
<summary>Importa un fisier</summary> <summary>+ Importa fisier (XLSX / CSV)</summary>
{% include '_upload.html' %} {% include '_upload.html' %}
</details> </details>

View File

@@ -117,6 +117,60 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{# ===== US-005 (5.16): Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat) #}
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
{% for chip in _chips %}
{% if not chip.cod_op_service and chip.cod_prestatie %}
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %}"
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
{{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">&times;</button>
</span>
{% endif %}
{% endfor %}
</div>
{% if nomenclator_rar %}
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
<select name="chips_add_cod_flat"
aria-label="Adauga cod RAR suplimentar"
style="min-width:160px;font-size:11px;height:26px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
<option value="">+ Adauga alta operatie / cod RAR</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add_extra"}'
aria-label="Adauga cod RAR suplimentar la trimitere">
+
</button>
</span>
{% else %}
{# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;margin-top:4px;">
Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila.
</div>
{% endif %}
{% if chips_extra_error %}
{# T-C1/T-E4 (5.16): semnal vizibil cand add_extra are select gol sau cod invalid #}
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
Selecteaza un cod RAR din lista inainte de a adauga.
</div>
{% endif %}
{% else %} {% else %}
{# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #} {# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
<div class="chips" role="group" aria-label="Coduri RAR selectate"> <div class="chips" role="group" aria-label="Coduri RAR selectate">
@@ -144,7 +198,7 @@
style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);"> style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
<option value="">+ cod</option> <option value="">+ cod</option>
{% for n in nomenclator_rar %} {% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }}</option> <option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %} {% endfor %}
</select> </select>
<button type="button" <button type="button"
@@ -158,6 +212,11 @@
+ +
</button> </button>
</span> </span>
{% else %}
{# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
Nomenclator indisponibil — nu se pot adauga coduri RAR momentan.
</div>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,6 +1,38 @@
<div class="card" id="card-cont"> <div class="card" id="card-cont">
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2> <h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
<!-- Sectiunea: Plan curent (US-006 PRD 5.17) -->
{% if plan_linie is defined %}
<div id="sectiune-plan" style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:var(--fs-sm); color:var(--muted); font-weight:500; margin:0 0 10px;
text-transform:uppercase; letter-spacing:.04em;">Plan curent</h3>
<div style="font-size:var(--fs-md); font-weight:600; margin-bottom:6px;
color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--ink){% endif %};">
{{ plan_linie }}
</div>
{% if monthly_limit_val is defined and monthly_limit_val is not none and effective_tier_name|default('') == 'free' %}
<div style="font-size:var(--fs-sm); color:var(--muted); margin-bottom:8px;">
Planul Gratuit include {{ monthly_limit_val }} prestatii/luna prin dashboard-ul web.
{% if plan_limita_atinsa|default(false) %}
Limita lunara a fost atinsa — trimiterile noi sunt blocate pana la inceputul lunii urmatoare.
{% elif plan_warn|default(false) %}
Te apropii de limita lunara.
{% endif %}
</div>
{% endif %}
<div style="font-size:var(--fs-sm); color:var(--muted); padding:8px 10px;
border:1px solid var(--line); border-radius:6px; margin-top:4px;">
Vrei sa treci pe Standard, Pro sau Premium?
Contacteaza-ne pentru alocare manuala — nu exista inca plata self-service.
<strong>Pro</strong> adauga import prin API; <strong>Standard</strong> si
<strong>Premium</strong> ridica limita de volum.
</div>
</div>
{% endif %}
<!-- Sectiunea: Date firma (US-002) --> <!-- Sectiunea: Date firma (US-002) -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);"> <div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Date firma</h3> <h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Date firma</h3>

View File

@@ -4,7 +4,7 @@
{# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #} {# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #}
{%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%} {%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%}
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 12px;"> <h2 style="font-size:var(--fs-md); margin:0 0 12px;">
Mapare coloane — Mapare coloane —
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span> <span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
</h2> </h2>
@@ -20,19 +20,19 @@
</div> </div>
{% endif %} {% endif %}
<p class="muted" style="margin:0 0 12px; font-size:13px;"> <p class="muted" style="margin:0 0 12px; font-size:var(--fs-sm);">
Asociaza fiecare coloana din fisier cu campul canonic corespunzator. Asociaza fiecare coloana din fisier cu campul canonic corespunzator.
Maparea se retine automat pentru fisiere cu acelasi antet. Maparea se retine automat pentru fisiere cu acelasi antet.
</p> </p>
{# Tabel orizontal preview: antet + prima inregistrare (US-003) #} {# Tabel orizontal preview: antet + prima inregistrare (compatibilitate teste) #}
<div class="tablewrap" style="margin-bottom:16px;"> <div class="tablewrap" style="margin-bottom:16px;">
<table class="preview-antet" style="border-collapse:collapse; font-size:12px; width:100%; min-width:max-content;"> <table class="preview-antet" style="border-collapse:collapse; font-size:var(--fs-xs); width:100%; min-width:max-content;">
<thead> <thead>
<tr> <tr>
{% for col in columns %} {% for col in columns %}
<th style="padding:4px 10px; text-align:left; background:var(--card); border:1px solid var(--line); <th style="padding:4px 10px; text-align:left; background:var(--card); border:1px solid var(--line);
white-space:nowrap; font-weight:600; font-size:12px; color:var(--ink);"> white-space:nowrap; font-weight:600; font-size:var(--fs-xs); color:var(--ink);">
{{ col }} {{ col }}
</th> </th>
{% endfor %} {% endfor %}
@@ -44,7 +44,7 @@
{% for col in columns %} {% for col in columns %}
{%- set val = prima_inreg.get(col, '') | string -%} {%- set val = prima_inreg.get(col, '') | string -%}
<td style="padding:4px 10px; border:1px solid var(--line); white-space:nowrap; <td style="padding:4px 10px; border:1px solid var(--line); white-space:nowrap;
font-size:11px; color:var(--muted); max-width:160px; overflow:hidden; text-overflow:ellipsis;" font-size:var(--fs-xs); color:var(--muted); max-width:160px; overflow:hidden; text-overflow:ellipsis;"
title="{{ val }}"> title="{{ val }}">
{{ val[:40] }}{% if val | length > 40 %}…{% endif %} {{ val[:40] }}{% if val | length > 40 %}…{% endif %}
</td> </td>
@@ -53,7 +53,7 @@
{% else %} {% else %}
<tr> <tr>
<td colspan="{{ columns | length }}" <td colspan="{{ columns | length }}"
style="padding:6px 10px; border:1px solid var(--line); font-size:12px; style="padding:6px 10px; border:1px solid var(--line); font-size:var(--fs-xs);
color:var(--muted); font-style:italic; text-align:center;"> color:var(--muted); font-style:italic; text-align:center;">
Antet fara randuri de date Antet fara randuri de date
</td> </td>
@@ -69,7 +69,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;"> <div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
<label for="format-data" style="font-size:13px; color:var(--muted);"> <label for="format-data" style="font-size:var(--fs-sm); color:var(--muted);">
Format data Format data
</label> </label>
<input type="text" id="format-data" name="format_data" <input type="text" id="format-data" name="format_data"
@@ -77,66 +77,97 @@
placeholder="ex: DD.MM.YYYY" placeholder="ex: DD.MM.YYYY"
style="max-width:160px;" style="max-width:160px;"
aria-describedby="format-data-hint"> aria-describedby="format-data-hint">
<span id="format-data-hint" class="muted" style="font-size:12px;"> <span id="format-data-hint" class="muted" style="font-size:var(--fs-xs);">
sau YYYY-MM-DD, MM/DD/YYYY etc. sau YYYY-MM-DD, MM/DD/YYYY etc.
</span> </span>
</div> </div>
{% for col in columns %} {# Tabel mapare: coloana din fisier | exemplu | camp RAR (mockup 5.16 / US-013) #}
{%- set sugg = fuzzy_suggestions.get(col, []) -%} <div class="tablewrap" style="margin-bottom:16px;">
{%- set best = sugg[0].camp_canonic if sugg else '' -%} <table style="border-collapse:collapse; width:100%;">
<input type="hidden" name="colname" value="{{ col }}"> <thead>
<div class="maprow"> <tr>
<div class="mapcol grow"> <th style="font-size:var(--fs-xs); width:34%; padding:6px 10px; text-align:left;
<div><strong>{{ col }}</strong></div> background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
{% if sugg %} font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
<div class="muted" style="font-size:12px; margin-top:2px;"> Coloana din fisier
sugestie: <span class="sugg">{{ sugg[0].camp_canonic }} </th>
({{ sugg[0].score | round | int }}%)</span> <th style="font-size:var(--fs-xs); width:28%; padding:6px 10px; text-align:left;
</div> background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
{% endif %} font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
{%- set ns = namespace(samples=[]) -%} Exemplu
{%- for row in sample_rows -%} </th>
{%- if row.get(col) is not none and row.get(col) != '' -%} <th style="font-size:var(--fs-xs); padding:6px 10px; text-align:left;
{%- set ns.samples = ns.samples + [row[col] | string] -%} background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
{%- endif -%} font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
{%- endfor -%} Camp RAR
{% if ns.samples %} </th>
<div class="muted" style="font-size:11px; margin-top:2px;"> </tr>
ex: {{ ns.samples[:2] | join(", ") }} </thead>
</div> <tbody>
{% endif %} {% for col in columns %}
</div> {%- set sugg = fuzzy_suggestions.get(col, []) -%}
<div class="mapcol" style="min-width:200px;"> {%- set best = sugg[0].camp_canonic if sugg else '' -%}
<label for="canon-{{ loop.index }}" {%- set ns = namespace(samples=[]) -%}
style="display:block; font-size:12px; color:var(--muted); margin-bottom:2px;"> {%- for row in sample_rows -%}
Camp canonic {%- if row.get(col) is not none and row.get(col) != '' -%}
</label> {%- set ns.samples = ns.samples + [row[col] | string] -%}
<select id="canon-{{ loop.index }}" name="canon"> {%- endif -%}
<option value="">— ignorat —</option> {%- endfor -%}
{% for field_key, field_label in canonical_fields %} <tr style="border-bottom:1px solid var(--line);">
<option value="{{ field_key }}" <td style="padding:9px 10px; vertical-align:top;">
{% if field_key == best %}selected{% endif %}> <input type="hidden" name="colname" value="{{ col }}">
{{ field_key }} — {{ field_label }} <strong style="font-family:var(--font-mono); font-size:var(--fs-sm);">{{ col }}</strong>
</option> {% if sugg %}
<div class="muted" style="font-size:var(--fs-xs); margin-top:3px;">
sugestie: <span class="sugg">{{ sugg[0].camp_canonic }}
({{ sugg[0].score | round | int }}%)</span>
</div>
{% endif %}
</td>
<td style="padding:9px 10px; vertical-align:top;">
{% if ns.samples %}
<span style="font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted);">
{{ ns.samples[:2] | join(", ") }}
</span>
{% else %}
<span class="muted" style="font-size:var(--fs-xs);"></span>
{% endif %}
</td>
<td style="padding:9px 10px; vertical-align:top;">
<label for="canon-{{ loop.index }}"
style="display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:3px;">
Camp canonic
</label>
<select id="canon-{{ loop.index }}" name="canon"
style="width:100%; font-size:var(--fs-base); min-height:38px;">
<option value="">— ignorat —</option>
{% for field_key, field_label in canonical_fields %}
<option value="{{ field_key }}"
{% if field_key == best %}selected{% endif %}>
{{ field_key }} — {{ field_label }}
</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %} {% endfor %}
</select> </tbody>
</div> </table>
</div> </div>
{% endfor %}
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;"> <div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<button type="submit" <button type="submit"
{% if not prima_inreg %}disabled aria-disabled="true"{% endif %} {% if not prima_inreg %}disabled aria-disabled="true"{% endif %}
style="min-height:44px; padding:10px 24px; font-size:14px;{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}"> style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
Salveaza si continua la preview Salveaza si continua la preview
</button> </button>
{% if not prima_inreg %} {% if not prima_inreg %}
<span style="font-size:12px; color:var(--err);"> <span style="font-size:var(--fs-xs); color:var(--err);">
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare. Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.
</span> </span>
{% else %} {% else %}
<span class="muted" style="font-size:12px;"> <span class="muted" style="font-size:var(--fs-xs);">
maparea se retine pentru fisiere cu acelasi antet maparea se retine pentru fisiere cu acelasi antet
</span> </span>
{% endif %} {% endif %}
@@ -144,7 +175,7 @@
</form> </form>
<div style="margin-top:12px;"> <div style="margin-top:12px;">
<a href="/" class="muted" style="font-size:13px;">Incarca alt fisier</a> <a href="/" class="muted" style="font-size:var(--fs-sm);">Incarca alt fisier</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,11 +10,11 @@
{% set pas = 3 %}{% include '_stepper.html' %} {% set pas = 3 %}{% include '_stepper.html' %}
<div class="card"> <div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
<h2 style="font-size:15px; margin:0;"> <h2 style="font-size:var(--fs-md); margin:0;">
Preview — Preview —
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span> <span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
</h2> </h2>
<span class="muted" style="margin-left:auto; font-size:13px;">{{ total }} randuri</span> <span class="muted" style="margin-left:auto; font-size:var(--fs-sm);">{{ total }} randuri</span>
</div> </div>
{% if message %} {% if message %}
@@ -37,7 +37,8 @@
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%} {%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %} {% if cnt > 0 %}
<span class="pill s-{{ status_key }}">{{ cnt }} {{ label | lower }}</span> <span class="pill s-{{ status_key }}" style="display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-xs);">
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ cnt }} {{ label | lower }}</span>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
@@ -46,14 +47,14 @@
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group" <div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group"
aria-label="Filtrare dupa stare"> aria-label="Filtrare dupa stare">
<button type="button" class="filter-btn" data-filter="all" <button type="button" class="filter-btn" data-filter="all"
style="min-height:36px; font-size:13px; padding:4px 12px;"> style="min-height:36px; font-size:var(--fs-sm); padding:4px 12px;">
Toate ({{ total }}) Toate ({{ total }})
</button> </button>
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%} {%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %} {% if cnt > 0 %}
<button type="button" class="filter-btn" data-filter="{{ status_key }}" <button type="button" class="filter-btn" data-filter="{{ status_key }}"
style="min-height:36px; font-size:13px; padding:4px 12px; style="min-height:36px; font-size:var(--fs-sm); padding:4px 12px;
background:transparent; border-color:var(--line); color:var(--ink);"> background:transparent; border-color:var(--line); color:var(--ink);">
{{ label }} ({{ cnt }}) {{ label }} ({{ cnt }})
</button> </button>
@@ -66,7 +67,7 @@
{% if unmapped_ops %} {% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;"> <div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3> <h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
<p class="muted" style="margin:0 0 12px; font-size:13px;"> <p class="muted" style="margin:0 0 12px; font-size:var(--fs-sm);">
Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e
preselectata) si salveaza — randurile blocate trec automat in preselectata) si salveaza — randurile blocate trec automat in
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare. <span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
@@ -167,7 +168,7 @@
</table> </table>
<!-- Mesaj "filtrat la zero": afisat de JS cand filtrul ascunde toate randurile --> <!-- Mesaj "filtrat la zero": afisat de JS cand filtrul ascunde toate randurile -->
<p id="preview-zero-message" class="muted" <p id="preview-zero-message" class="muted"
style="display:none; text-align:center; padding:24px 16px; font-size:14px;"> style="display:none; text-align:center; padding:24px 16px; font-size:var(--fs-md);">
Niciun rand nu corespunde filtrului selectat. Niciun rand nu corespunde filtrului selectat.
</p> </p>
</div> </div>
@@ -190,7 +191,7 @@
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;"> <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<label for="n-confirmat" <label for="n-confirmat"
style="font-size:13px; color:var(--muted);"> style="font-size:var(--fs-sm); color:var(--muted);">
Confirma numarul Confirma numarul
</label> </label>
<input type="number" id="n-confirmat" name="n_confirmat" <input type="number" id="n-confirmat" name="n_confirmat"
@@ -198,7 +199,7 @@
min="0" required min="0" required
style="max-width:80px;" style="max-width:80px;"
aria-describedby="n-hint"> aria-describedby="n-hint">
<span id="n-hint" class="muted" style="font-size:12px;"> <span id="n-hint" class="muted" style="font-size:var(--fs-xs);">
din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
</span> </span>
</div> </div>
@@ -207,13 +208,13 @@ din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
<div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;"> <div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;">
<button type="submit" <button type="submit"
id="confirm-btn" id="confirm-btn"
style="min-height:44px; padding:10px 28px; font-size:14px;" style="min-height:44px; padding:10px 28px; font-size:var(--fs-md);"
{% if not summary.get('ok', 0) %}disabled title="Niciun rand ok de trimis"{% endif %}> {% if not summary.get('ok', 0) %}disabled title="Niciun rand ok de trimis"{% endif %}>
Trimite la RAR Trimite la RAR
</button> </button>
{% if summary.get('needs_data', 0) or summary.get('needs_mapping', 0) or summary.get('needs_review', 0) %} {% if summary.get('needs_data', 0) or summary.get('needs_mapping', 0) or summary.get('needs_review', 0) %}
<a href="/v1/import/{{ import_id }}/export-failed" download <a href="/v1/import/{{ import_id }}/export-failed" download
style="font-size:12px; text-align:center;"> style="font-size:var(--fs-xs); text-align:center;">
descarca randuri cu probleme (CSV) descarca randuri cu probleme (CSV)
</a> </a>
{% endif %} {% endif %}
@@ -226,7 +227,7 @@ din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
<span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span> <span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
<div style="padding:8px 0 4px;"> <div style="padding:8px 0 4px;">
<a href="#" class="muted" style="font-size:13px;" <a href="#" class="muted" style="font-size:var(--fs-sm);"
hx-get="/_import/reset" hx-target="#import-section" hx-swap="outerHTML">Incarca alt fisier</a> hx-get="/_import/reset" hx-target="#import-section" hx-swap="outerHTML">Incarca alt fisier</a>
</div> </div>

View File

@@ -25,7 +25,8 @@
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}"> style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}">
<td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td> <td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
<td class="col-stare" data-eticheta="Stare"> <td class="col-stare" data-eticheta="Stare">
<span class="pill {{ row.stare_css }}">{{ row.stare_eticheta }}</span> <span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;">
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ row.stare_eticheta }}</span>
</td> </td>
<td class="col-vehicul" data-eticheta="Vehicul"> <td class="col-vehicul" data-eticheta="Vehicul">
{{ row.prez.vehicul_nr }} {{ row.prez.vehicul_nr }}
@@ -44,7 +45,7 @@
<td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td> <td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td>
<td class="col-km" data-eticheta="KM final">{{ row.prez.odometru }}</td> <td class="col-km" data-eticheta="KM final">{{ row.prez.odometru }}</td>
<td class="col-note" data-eticheta="Note" <td class="col-note" data-eticheta="Note"
style="font-size:12px; white-space:normal;"> style="font-size:var(--fs-xs); white-space:normal;">
{% if status == 'already_sent' and row.get('already_sent_info') %} {% if status == 'already_sent' and row.get('already_sent_info') %}
{% set ai = row.already_sent_info %} {% set ai = row.already_sent_info %}
deja trimis {{ (ai.get('created_at') or '')[:10] }} deja trimis {{ (ai.get('created_at') or '')[:10] }}
@@ -78,7 +79,7 @@
style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%} {%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %}<span class="pill s-{{ status_key }}">{{ cnt }} {{ label | lower }}</span>{% endif %} {% if cnt > 0 %}<span class="pill s-{{ status_key }}" style="display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-xs);"><span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ cnt }} {{ label | lower }}</span>{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span> <span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span>

View File

@@ -14,55 +14,84 @@
</div> </div>
{% endif %} {% endif %}
{# === D6: Strip sanatate mereu-vizibil DEASUPRA contoarelor === {# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare
Verde: worker viu + RAR ok → "Declaratiile curg normal" dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
Rosu: worker oprit SAU RAR inaccesibil → "Blocat: ... — declaratiile NU pleaca" Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
Glife accesibile ✓/✗ (nu doar culoare). Layout: glifa+text stanga, ultima auth dreapta. {% if trial_expirat_recent|default(false) %}
<div id="banner-trial-expirat"
role="status"
style="margin-bottom:10px; padding:7px 12px;
border-left:3px solid var(--warn);
background:color-mix(in srgb, var(--warn) 10%, var(--card));
border-radius:6px; font-size:var(--fs-sm);
display:flex; align-items:center; justify-content:space-between; gap:8px;">
<span>Trial Pro expirat — esti pe Gratuit, 60/luna</span>
<button onclick="sessionStorage.setItem('tfx','1'); document.getElementById('banner-trial-expirat').style.display='none';"
style="background:transparent; border:none; color:var(--muted); cursor:pointer;
font-size:18px; padding:0 4px; line-height:1; flex-shrink:0;"
aria-label="Inchide bannerul">×</button>
</div>
<script>(function(){ if(sessionStorage.getItem('tfx')){ var el=document.getElementById('banner-trial-expirat'); if(el) el.style.display='none'; } })();</script>
{% endif %}
{# === US-003 (PRD 5.16): Banda de stare RAR — NUMAI cand BLOCAT (rosu, lat de 100%).
OK = dot verde in antet (base.html); banda nu mai apare cand totul e ok.
Elementul id="strip-sanatate" ramane in DOM mereu, dar goleste continutul cand OK,
astfel "hidden" + fara continut eroare in sursa = nu pica testele de prezenta id-ului.
#} #}
{% if sanatate_ok %}
<div id="strip-sanatate" role="status" aria-live="polite" hidden></div>
{% else %}
<div id="strip-sanatate" <div id="strip-sanatate"
role="status" role="status"
aria-live="polite" aria-live="polite"
style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;
padding:10px 14px; border-radius:8px; margin-bottom:14px; padding:10px 14px; border-radius:8px; margin-bottom:14px;
{% if sanatate_ok %}background:color-mix(in srgb, var(--ok) 13%, transparent); border:1px solid color-mix(in srgb, var(--ok) 30%, transparent); background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);">
{% else %}background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);
{% endif %}">
<div style="display:flex; align-items:center; gap:9px;"> <div style="display:flex; align-items:center; gap:9px;">
{% if sanatate_ok %}
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--ok);">&#10003;</span>
{% else %}
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">&#10007;</span> <span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">&#10007;</span>
{% endif %}
<span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span> <span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span>
</div> </div>
<span style="font:400 11px/1.4 'IBM Plex Mono',ui-monospace,monospace; color:var(--muted); white-space:nowrap;"> <span style="font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); white-space:nowrap;">
{{ eticheta_ultima_auth }}: {{ last_login }} {{ eticheta_ultima_auth }}: {{ last_login }}
</span> </span>
</div> </div>
{% endif %}
{# === D4: 3 carduri-contor (mockup exact: Trimise / In coada / De corectat) === {# === US-002 (PRD 5.16): 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
Responsive: flex-wrap => 3 pe rand desktop, 2/stivuite pe mobil (min-width:120px). Total / Luna asta / Azi / In coada / De corectat.
Trimise: all-time (cifra mare) + sub-linie "luna N · azi N" (D4 + E7).
De corectat: rosu cand >0 (s-error), muted cand 0.
#} #}
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:14px;"> {# Desktop: 5 carduri side-by-side. display:flex + layout stau in CSS (.contoare-desktop in
base.html), NU inline, ca media query-ul <=560px sa le poata ascunde pe mobil (bara compacta). #}
<div class="contoare-desktop">
{# Trimise (all-time principal, luna/azi secundar) #} {# Total trimise (all-time) #}
<div class="contor-card" style="flex:1; min-width:120px;"> <div class="contor-card" style="flex:1; min-width:100px;">
<div class="contor-cifra">{{ counts_sent }}</div> <div class="contor-cifra">{{ counts_sent }}</div>
<div class="contor-label">Trimise (total)</div> <div class="contor-label">Total</div>
<div class="contor-sub">luna {{ sent_month }} &middot; azi {{ sent_today }}</div>
</div> </div>
{# In coada (accent/albastru) #} {# Luna asta #}
<div class="contor-card" style="flex:1; min-width:120px;"> <div class="contor-card" style="flex:1; min-width:100px;">
<div class="contor-cifra s-accent">{{ sent_month }}</div>
<div class="contor-label">Luna asta</div>
</div>
{# Azi #}
<div class="contor-card" style="flex:1; min-width:80px;">
<div class="contor-cifra s-accent">{{ sent_today }}</div>
<div class="contor-label">Azi</div>
</div>
{# In coada #}
<div class="contor-card" style="flex:1; min-width:80px;">
<div class="contor-cifra s-queued">{{ counts_queued }}</div> <div class="contor-cifra s-queued">{{ counts_queued }}</div>
<div class="contor-label">In coada</div> <div class="contor-label">In coada</div>
</div> </div>
{# De corectat (rosu daca >0, muted la 0; link catre lista) #} {# De corectat (rosu daca >0, muted la 0; link catre lista) #}
<a href="/" class="contor-card" <a href="/" class="contor-card"
style="flex:1; min-width:120px; text-decoration:none; display:block; cursor:pointer;" style="flex:1; min-width:80px; text-decoration:none; display:block; cursor:pointer;"
aria-label="De corectat: {{ blocate_total }} — click pentru lista de trimiteri"> aria-label="De corectat: {{ blocate_total }} — click pentru lista de trimiteri">
<div class="contor-cifra {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div> <div class="contor-cifra {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
<div class="contor-label">De corectat</div> <div class="contor-label">De corectat</div>
@@ -70,6 +99,30 @@
</div> </div>
{# Mobil (<=560px): bara compacta — numerele + etichete scurte in-line #}
<div class="contoare-compact">
<div class="compact-item">
<div class="compact-nr">{{ counts_sent }}</div>
<div class="compact-lbl">Total</div>
</div>
<div class="compact-item">
<div class="compact-nr s-accent">{{ sent_month }}</div>
<div class="compact-lbl">Luna</div>
</div>
<div class="compact-item">
<div class="compact-nr s-accent">{{ sent_today }}</div>
<div class="compact-lbl">Azi</div>
</div>
<div class="compact-item">
<div class="compact-nr s-queued">{{ counts_queued }}</div>
<div class="compact-lbl">Coada</div>
</div>
<a class="compact-item" href="/" style="text-decoration:none; color:inherit;">
<div class="compact-nr {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
<div class="compact-lbl">Erori</div>
</a>
</div>
{# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping === {# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ. Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
#} #}
@@ -84,4 +137,20 @@
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a> class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
</nav> </nav>
{# US-006 (5.17): linia de plan — consum/trial (secundar, sub navigatie, non-blocant).
Warn=culoare+text (accesibilitate): >=80% -> --warn; limita atinsa -> --err.
Ierarhie: nu concureaza cu stripul de sanatate (E zero-silent-failures pastrat). #}
{% if plan_linie is defined and plan_linie %}
<div class="plan-status-line"
style="font-size:var(--fs-sm); margin-top:6px; padding-top:6px;
border-top:1px solid var(--line2);
color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--muted){% endif %};
{% if plan_warn|default(false) %}font-weight:600;{% endif %}">
{{ plan_linie }}
{% if plan_limita_atinsa|default(false) or plan_warn|default(false) %}
&nbsp;<a href="/?tab=cont" style="font-size:var(--fs-xs); font-weight:400; color:var(--accent);">Detalii plan</a>
{% endif %}
</div>
{% endif %}
</div> </div>

View File

@@ -52,15 +52,15 @@
role="region" aria-label="Zona de incarcare fisier" role="region" aria-label="Zona de incarcare fisier"
style="display:flex; align-items:center; gap:14px; flex-wrap:wrap; style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;
padding:12px 16px; text-align:left;"> padding:12px 16px; text-align:left;">
<strong style="font-size:14px;">Importa:</strong> <strong style="font-size:var(--fs-md);">Importa:</strong>
<input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv" <input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv"> style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
<button type="button" id="upload-btn" <button type="button" id="upload-btn"
style="min-height:44px; padding:10px 20px; font-size:14px;"> style="min-height:44px; padding:10px 20px; font-size:var(--fs-md);">
Alege fisier (xlsx/csv) Alege fisier (xlsx/csv)
</button> </button>
<span class="muted" style="font-size:13px;">sau trage aici</span> <span class="muted" style="font-size:var(--fs-sm);">sau trage aici</span>
<span class="muted" style="font-size:12px; margin-left:auto;"> <span class="muted" style="font-size:var(--fs-xs); margin-left:auto;">
NU se trimite nimic la RAR pana confirmi. NU se trimite nimic la RAR pana confirmi.
</span> </span>
</div> </div>
@@ -69,10 +69,10 @@
<div class="drop-zone" id="drop-zone" <div class="drop-zone" id="drop-zone"
role="region" aria-label="Zona de incarcare fisier"> role="region" aria-label="Zona de incarcare fisier">
{% if not sheets %} {% if not sheets %}
<p style="font-size:17px; margin:0 0 4px; font-weight:600;">Primul fisier? Trage-l aici.</p> <p style="font-size:var(--fs-lg); margin:0 0 4px; font-weight:600;">Primul fisier? Trage-l aici.</p>
<p class="muted" style="margin:0 0 16px; font-size:13px;">xlsx sau csv, max 5000 randuri</p> <p class="muted" style="margin:0 0 16px; font-size:var(--fs-sm);">xlsx sau csv, max 5000 randuri</p>
{% else %} {% else %}
<p class="muted" style="margin:0 0 16px; font-size:14px;"> <p class="muted" style="margin:0 0 16px; font-size:var(--fs-md);">
Incarca fisierul din nou dupa ce ai ales foaia. Incarca fisierul din nou dupa ce ai ales foaia.
</p> </p>
{% endif %} {% endif %}
@@ -80,18 +80,18 @@
<input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv" <input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv"> style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
<button type="button" id="upload-btn" <button type="button" id="upload-btn"
style="min-height:44px; padding:10px 24px; font-size:14px;"> style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);">
Alege fisier (xlsx/csv) Alege fisier (xlsx/csv)
</button> </button>
</div> </div>
<p class="muted" style="margin:8px 0 0; font-size:12px;"> <p class="muted" style="margin:8px 0 0; font-size:var(--fs-xs);">
NU se trimite nimic la RAR pana confirmi explicit. NU se trimite nimic la RAR pana confirmi explicit.
</p> </p>
{% endif %} {% endif %}
<span id="upload-spinner" class="htmx-indicator muted" <span id="upload-spinner" class="htmx-indicator muted"
style="font-size:13px; margin-top:6px; display:inline;"> style="font-size:var(--fs-sm); margin-top:6px; display:inline;">
se parseaza fisierul... se parseaza fisierul...
</span> </span>
</form> </form>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title> <title>{% block title %}ROMFAST AUTOPASS{% endblock %}</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script> <script>
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS // Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
@@ -36,78 +36,22 @@
})(); })();
</script> </script>
<style> <style>
/* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti). /* US-001 PRD 5.16: stive de font standard web. Toate regulile font-face IBM Plex sterse.
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex); Motiv: decizie user (risc AI-Slop #11 acceptat constient), uniformitate cross-page.
reflow-ul vizibil pe VIN/coduri e acceptat explicit. */ Fisierele woff2 raman pe disc (curatare = follow-up optional, non-blocant).
@font-face { Referinte catre directorul de fonturi statice eliminate — font-ui si font-mono sunt stive sistem. */
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Regular-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Regular-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Medium-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Medium-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Bold-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Bold-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexMono-Regular-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
/* Paleta dark (default) — accent azur ROMFAST. /* Paleta dark (default) — accent azur ROMFAST.
--card2: fundal input/contor (= --bg, nivelul cel mai adanc). --card2: fundal input/contor (= --bg, nivelul cel mai adanc).
--line2: separator subtire (intre --bg si --line). */ --line2: separator subtire (intre --bg si --line). */
:root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530; :root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; } --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6;
/* US-001 (PRD 5.16): stive font standard web — sursa unica de adevar */
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
/* US-002 (PRD 5.16): scala tipografica uniforma — sursa unica de adevar */
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px;
--fs-lg:18px; --fs-xl:20px; --fs-2xl:28px; --fs-3xl:34px;
--lh-tight:1.25; --lh-body:1.55; }
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */ /* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --card2:#f5f7fa; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea; --line2:#eaedf2; [data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --card2:#f5f7fa; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea; --line2:#eaedf2;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; } --ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
@@ -135,7 +79,7 @@
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout `@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */ desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; body { margin:0; font-family:var(--font-ui); font-size:var(--fs-base); line-height:var(--lh-body);
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; } background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
/* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */ /* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */
header { padding:16px 24px; border-bottom:1px solid var(--line); header { padding:16px 24px; border-bottom:1px solid var(--line);
@@ -200,6 +144,44 @@
flex-wrap:wrap; z-index:10; } flex-wrap:wrap; z-index:10; }
/* Indicator HTMX — ascuns pana la request */ /* Indicator HTMX — ascuns pana la request */
.htmx-indicator { display:none; } .htmx-indicator { display:none; }
/* US-011 (PRD 5.16): selector tema stil pill — icon + eticheta temei curente.
Eticheta se ascunde pe <=560px (spatiu ingust), ramane iconita. */
.tema-btn { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 12px;
border-radius:8px; background:transparent; border:1px solid var(--line);
color:var(--muted); font-family:var(--font-ui); font-size:var(--fs-sm);
cursor:pointer; transition:border-color .15s, color .15s; line-height:1; }
.tema-btn:hover { border-color:var(--accent); color:var(--ink); }
.tema-btn:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
@media (max-width:560px) { #tema-label { display:none; } }
/* US-003 (PRD 5.16): dot RAR compact in antet.
Stare OK: dot verde pulsant + "RAR online". Stare BLOCAT: dot rosu.
Stilat ca pill; sensul NU depinde de culoare (aria-label + title). */
.rar-chip { display:inline-flex; align-items:center; gap:7px; height:36px; padding:0 12px;
border-radius:99px; font-size:var(--fs-sm); font-weight:600; cursor:default; white-space:nowrap; }
.rar-chip.rar-ok { border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line));
background:color-mix(in srgb,var(--ok) 10%,transparent); color:var(--ok); }
.rar-chip.rar-err { border:1px solid color-mix(in srgb,var(--err) 35%,var(--line));
background:color-mix(in srgb,var(--err) 10%,transparent); color:var(--err); }
.rar-dot { width:9px; height:9px; border-radius:99px; background:currentColor; flex-shrink:0;
box-shadow:0 0 0 3px color-mix(in srgb,currentColor 20%,transparent); }
.rar-dot.live { animation:rar-pulse 2s ease-in-out infinite; }
@keyframes rar-pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
@media (max-width:560px) { .rar-chip .rar-tx { display:none; } }
/* US-010 (PRD 5.16): sub-titlu cu numele service-ului (cand autentificat). */
.h-sub { font-size:var(--fs-xs); color:var(--muted); margin-top:2px; line-height:1.2; }
.h-sub .svc { color:var(--ink); font-weight:600; }
/* Badge env (test/prod) si badge tier (plan cont) langa titlu. */
.badge-env { display:inline-block; margin-left:8px; padding:1px 7px; border-radius:99px;
font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em;
color:var(--warn); background:color-mix(in srgb,var(--warn) 16%,transparent); vertical-align:middle; }
.badge-tier { display:inline-block; margin-left:6px; padding:1px 8px; border-radius:99px;
font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em;
color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent); vertical-align:middle; }
/* Menu RAR status line in burger (prima intrare) */
.menu-rar-line { display:flex; align-items:center; gap:7px; padding:8px 10px;
font-size:var(--fs-sm); border-radius:6px; cursor:default; }
.menu-rar-line.ok { color:var(--ok); }
.menu-rar-line.err { color:var(--err); background:color-mix(in srgb,var(--err) 6%,transparent); }
.htmx-indicator.htmx-request { display:inline; } .htmx-indicator.htmx-request { display:inline; }
/* Link-uri de actiune in antetul cardurilor: zona de atins mai mare (>=36px) si /* Link-uri de actiune in antetul cardurilor: zona de atins mai mare (>=36px) si
feedback la hover; pe ecrane inguste antetul se rupe curat sub titlu. */ feedback la hover; pe ecrane inguste antetul se rupe curat sub titlu. */
@@ -317,7 +299,7 @@
border-radius:0 6px 6px 0; } border-radius:0 6px 6px 0; }
.eroare-3n-sep { margin-top:6px; } .eroare-3n-sep { margin-top:6px; }
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; } .eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; }
.eroare-3n-camp { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; opacity:.85; } .eroare-3n-camp { font-family:var(--font-mono); font-size:var(--fs-xs); opacity:.85; }
.eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; } .eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; } .eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
.eroare-3n-label { font-weight:500; } .eroare-3n-label { font-weight:500; }
@@ -424,8 +406,8 @@
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */ /* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; } .tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
/* Codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */ /* Codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace; .tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:var(--font-mono);
font-size:12px; padding:1px 7px; border:1px solid var(--line); font-size:var(--fs-xs); padding:1px 7px; border:1px solid var(--line);
border-radius:99px; color:var(--muted); } border-radius:99px; color:var(--muted); }
/* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza /* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza
(apare doar pe error/needs_*). Stare prin text, nu doar culoare. */ (apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
@@ -696,37 +678,56 @@
} }
/* === SENTINEL-COMPONENTE-SLIM: inceput componente slim US-002 (PRD 5.15). /* === SENTINEL-COMPONENTE-SLIM: inceput componente slim US-002 (PRD 5.15).
Testele ancoreaza pe acest marker. Nu muta/sterge. === */ Testele ancoreaza pe acest marker. Nu muta/sterge. === */
/* .contor-card — card cifra contor: fundal --card2, bordura --line, radius 8px, padding 10-12px. /* .contor-card — card cifra contor: fundal --card2, bordura --line, radius 8px.
US-002 PRD 5.16: padding marit (18px), cifra pe --fs-2xl, label pe --fs-sm, sub pe --fs-xs.
Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */ Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */
.contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; } .contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:18px 18px; }
.contor-cifra { font-size:22px; font-weight:700; line-height:1; } .contor-cifra { font-size:var(--fs-2xl); font-weight:700; line-height:1; }
.contor-label { font-size:11px; color:var(--muted); margin-top:5px; } .contor-label { font-size:var(--fs-sm); color:var(--muted); margin-top:8px; }
.contor-sub { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:10px; color:var(--muted); margin-top:3px; } .contor-sub { font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); margin-top:4px; }
/* Contoarele desktop = 5 carduri side-by-side. display:flex sta in CSS (NU inline pe
element) ca media query-ul de mai jos sa-l poata ascunde pe mobil — un inline
style="display:flex" ar bate regula @media si ar duce la contoare duplicate pe 390px. */
.contoare-desktop { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px; }
/* Bara compacta contoare pe mobil (<=560px): un singur rand, numere + etichete scurte, fara carduri mari. */
.contoare-compact { display:none; }
@media (max-width:560px) {
.contoare-desktop { display:none; }
.contoare-compact { display:flex; align-items:center; gap:0; margin-bottom:14px;
background:var(--card2); border:1px solid var(--line); border-radius:8px;
overflow:hidden; }
.compact-item { flex:1; display:flex; flex-direction:column; align-items:center; padding:10px 6px;
border-right:1px solid var(--line); min-width:0; text-align:center; }
.compact-item:last-child { border-right:none; }
.compact-nr { font-size:var(--fs-xl); font-weight:700; line-height:1; }
.compact-lbl { font-size:10px; color:var(--muted); margin-top:3px; white-space:nowrap; }
}
/* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2. /* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2.
Randul e clickabil (rol button), tinta min-height:44px pe mobil. */ Randul e clickabil (rol button), tinta min-height:44px pe mobil. */
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; } .lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
.trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px; .trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px;
padding:11px 14px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; } padding:14px 16px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; }
.trimitere-slim:last-child { border-bottom:none; } .trimitere-slim:last-child { border-bottom:none; }
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); } .trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } .trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.slim-vin { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:13px; font-weight:500; color:var(--ink); } .slim-vin { font-family:var(--font-mono); font-size:var(--fs-md); font-weight:500; color:var(--ink); }
.slim-meta { font-size:11px; color:var(--muted); margin-top:3px; } .slim-meta { font-size:var(--fs-sm); color:var(--muted); margin-top:3px; }
/* .camp-slim — varianta compacta camp formular: label 11px muted deasupra, input ~30px, fundal --card2. /* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */ Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
.camp-slim { margin-bottom:8px; } .camp-slim { margin-bottom:8px; }
.camp-slim label { font-size:11px; color:var(--muted); display:block; margin-bottom:4px; } .camp-slim label { font-size:var(--fs-sm); color:var(--muted); display:block; margin-bottom:4px; }
.camp-slim input, .camp-slim textarea, .camp-slim select { background:var(--card2); height:30px; width:100%; .camp-slim input, .camp-slim textarea, .camp-slim select { background:var(--card2); min-height:36px; width:100%;
padding:0 10px; border:1px solid var(--line); border-radius:6px; font:inherit; color:var(--ink); } padding:0 10px; border:1px solid var(--line); border-radius:6px; font-family:var(--font-ui);
.camp-slim textarea { height:auto; min-height:48px; padding:8px 10px; resize:vertical; } font-size:var(--fs-md); color:var(--ink); }
.camp-slim .camp-mono { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; } .camp-slim textarea { min-height:52px; height:auto; padding:8px 10px; resize:vertical; }
.camp-slim .camp-mono { font-family:var(--font-mono); font-size:var(--fs-sm); }
/* .chips + .chip — prestatii multi-select cu buton de stergere accesibil (.chip-del). /* .chips + .chip — prestatii multi-select cu buton de stergere accesibil (.chip-del).
Fundal accent 18%, font IBM Plex Mono 11px. */ Fundal accent 18%, font mono --fs-xs. */
.chips { min-height:30px; display:flex; align-items:center; gap:6px; flex-wrap:wrap; .chips { min-height:30px; display:flex; align-items:center; gap:6px; flex-wrap:wrap;
padding:4px 8px; border:1px solid var(--line); border-radius:6px; background:var(--card2); } padding:4px 8px; border:1px solid var(--line); border-radius:6px; background:var(--card2); }
.chip { display:inline-flex; align-items:center; gap:5px; padding:3px 8px; border-radius:5px; .chip { display:inline-flex; align-items:center; gap:5px; padding:3px 8px; border-radius:5px;
background:color-mix(in srgb, var(--accent) 18%, transparent); color:var(--accent); background:color-mix(in srgb, var(--accent) 18%, transparent); color:var(--accent);
font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:11px; font-weight:600; } font-family:var(--font-mono); font-size:var(--fs-xs); font-weight:600; }
.chip .chip-del { background:transparent; border:none; color:inherit; opacity:.7; cursor:pointer; .chip .chip-del { background:transparent; border:none; color:inherit; opacity:.7; cursor:pointer;
padding:0; font-size:13px; line-height:1; display:inline-flex; padding:0; font-size:13px; line-height:1; display:inline-flex;
align-items:center; justify-content:center; min-width:16px; min-height:16px; } align-items:center; justify-content:center; min-width:16px; min-height:16px; }
@@ -737,13 +738,13 @@
/* .add-code — buton dashed pentru adaugare cod in chipbox */ /* .add-code — buton dashed pentru adaugare cod in chipbox */
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent; .add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent;
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line)); border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
border-radius:5px; color:var(--accent); font:500 10px inherit; cursor:pointer; } border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; }
.add-code:hover, .add-code:focus-visible { border-style:solid; } .add-code:hover, .add-code:focus-visible { border-style:solid; }
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */ /* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px; .op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
padding:8px 10px; border:1px solid var(--line); border-radius:6px; padding:8px 10px; border:1px solid var(--line); border-radius:6px;
background:var(--card2); margin-bottom:8px; } background:var(--card2); margin-bottom:8px; }
.op-row-name { font-size:12px; font-weight:500; color:var(--ink); } .op-row-name { font-size:var(--fs-xs); font-weight:500; color:var(--ink); }
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); } .op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */ /* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
@media (max-width:767px) { @media (max-width:767px) {
@@ -753,7 +754,9 @@
</style> </style>
</head> </head>
<body> <body>
{# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #} {# US-010 (PRD 5.16): antet branduit ROMFAST AUTOPASS.
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
<header> <header>
{# Celula stanga: logo ROMFAST #} {# Celula stanga: logo ROMFAST #}
<div class="header-left"> <div class="header-left">
@@ -763,35 +766,86 @@
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo"> <img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
</a> </a>
</div> </div>
{# Celula centru: titlu + badge env mic. {# Celula centru: titlu ROMFAST AUTOPASS + badge env + badge tier + sub-titlu account_name.
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #} Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
<div class="header-center"> <div class="header-center">
<a href="/" style="text-decoration:none; color:inherit;"><h1>Gateway RAR AUTOPASS</h1></a> <a href="/" style="text-decoration:none; color:inherit;">
<span class="env">{{ rar_env }}</span> <h1>ROMFAST AUTOPASS<span class="badge-env">{{ rar_env }}</span>{% if is_authenticated|default(false) and tier_label|default('') %}<span class="badge-tier">{{ tier_label }}</span>{% endif %}</h1>
</a>
{% if is_authenticated|default(false) and account_name|default('') %}
<div class="h-sub">Service auto: <span class="svc">{{ account_name }}</span></div>
{% endif %}
</div> </div>
{# Celula dreapta: comutator tema + versiune + meniu cont #} {# Celula dreapta: dot RAR (numai cand logat) + selector tema + versiune + meniu burger #}
<div class="header-right"> <div class="header-right">
<button id="tema-toggle" class="icon-btn" {# US-003 (PRD 5.16): dot RAR in antet — OK = chip verde pulsant, BLOCAT = chip rosu.
aria-label="Comuta tema (luminos/intunecat)" Banda plina apare DOAR in _status.html cand BLOCAT (nu mai e mereu vizibila). #}
title="Comuta tema">&#9728;</button> {% if is_authenticated|default(false) %}
<span class="muted" style="font-size:13px;">v{{ version }}</span> {% if sanatate_ok|default(true) %}
<div class="rar-chip rar-ok" role="status"
title="RAR online{% if last_login|default('') %} — Ultima autentificare: {{ last_login }}{% endif %}"
aria-label="RAR online">
<span class="rar-dot live" aria-hidden="true"></span>
<span class="rar-tx">RAR online</span>
</div>
{% else %}
<div class="rar-chip rar-err" role="status"
title="RAR indisponibil"
aria-label="RAR indisponibil">
<span class="rar-dot" aria-hidden="true"></span>
<span class="rar-tx">RAR blocat</span>
</div>
{% endif %}
{% endif %}
{# US-011 (PRD 5.16): selector tema = pill cu icon + eticheta temei curente.
Eticheta ascunsa pe <=560px via CSS. JS actualizeaza .tema-icon si #tema-label. #}
<button id="tema-toggle" class="tema-btn"
aria-label="Comuta tema"
title="Comuta tema">
<span class="tema-icon" aria-hidden="true">&#9728;</span>
<span id="tema-label">Light</span>
</button>
<span class="muted" style="font-size:var(--fs-xs);">v{{ version }}</span>
{% if is_authenticated|default(false) %} {% if is_authenticated|default(false) %}
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout. {# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout.
US-010: structura cu <hr> separatori + RAR status (prima intrare) + Plan tier.
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #} Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
<div class="cont-menu-wrap"> <div class="cont-menu-wrap">
<button id="cont-menu-toggle" class="icon-btn" <button id="cont-menu-toggle" class="icon-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu" aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button> aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden> <div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
{# Prima intrare: Trimiteri (Acasa) — pagina principala cu import + lista trimiterilor. #} {# Prima intrare: starea RAR (US-003) #}
{% if sanatate_ok|default(true) %}
<div class="menu-rar-line ok" role="menuitem" aria-disabled="true">
<span style="width:8px;height:8px;border-radius:99px;background:currentColor;display:inline-block;"></span>
RAR online
</div>
{% else %}
<div class="menu-rar-line err" role="menuitem" aria-disabled="true">
<span style="width:8px;height:8px;border-radius:99px;background:currentColor;display:inline-block;"></span>
RAR indisponibil
</div>
{% endif %}
{# Plan cont curent (US-006 PRD 5.17): linie detaliata cu trial/consum/warn.
Warn = culoare + text (accesibilitate, decizie #14). #}
<div class="menu-rar-line" role="menuitem" aria-disabled="true"
style="color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--muted){% endif %};
{% if plan_warn|default(false) %}font-weight:600;{% endif %}">
{{ plan_linie|default('Plan: ' + (tier_label|default('Gratuit'))) }}
</div>
<hr>
{# Navigare principala: Trimiteri + Mapari #}
<a role="menuitem" href="/">Trimiteri</a> <a role="menuitem" href="/">Trimiteri</a>
{# Mapari, cu badge needs_mapping. #}
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %} {% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a> <a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
<hr> <hr>
{# Nomenclator: coduri RAR — public, dar in meniu arata mai logic la logat #}
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
<hr>
{# Setari cont #}
<a role="menuitem" href="/?tab=cont">Cont</a> <a role="menuitem" href="/?tab=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a> <a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
<a role="menuitem" href="/?tab=jurnal">Jurnal</a> <a role="menuitem" href="/?tab=jurnal">Jurnal</a>
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %} {% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %}
<hr> <hr>
@@ -863,7 +917,11 @@
} }
function _syncButton(stored) { function _syncButton(stored) {
var s = VALID[stored] ? stored : 'auto'; var s = VALID[stored] ? stored : 'auto';
btn.innerHTML = ICONS[s]; // US-011: actualizeaza iconita si eticheta separat (btn e pill, nu se inlocuieste innerHTML intreg)
var icon = btn.querySelector('.tema-icon');
if (icon) icon.innerHTML = ICONS[s];
var label = document.getElementById('tema-label');
if (label) label.textContent = LABELS[s];
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]); btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg
} }
@@ -1129,7 +1187,7 @@
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc. // Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
overlay.addEventListener('click', function(e) { overlay.addEventListener('click', function(e) {
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-modal-close')) close(); if (e.target && e.target.closest && e.target.closest('[data-modal-close]')) close();
}); });
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isOpen()) { e.preventDefault(); close(); } if (e.key === 'Escape' && isOpen()) { e.preventDefault(); close(); }

View File

@@ -4,21 +4,15 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gateway RAR AUTOPASS — declară automat la RAR | ROMFAST</title> <title>Gateway RAR AUTOPASS — declară automat la RAR | ROMFAST</title>
<meta name="description" content="Gateway web care declară prestațiile de service-auto la RAR AUTOPASS, automat. Conform Legii 142/2023. Gratuit până la 100 de prestații/lună, fără card bancar."> <meta name="description" content="Gateway web care declară prestațiile de service-auto la RAR AUTOPASS, automat. Conform Legii 142/2023. Gratuit până la 60 de prestații/lună, fără card bancar.">
<style> <style>
@font-face{font-family:"IBM Plex Sans";font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexSans-Regular-latin-ext.woff2") format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF;} /* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
@font-face{font-family:"IBM Plex Sans";font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexSans-Regular-latin.woff2") format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+20AC,U+2122;} Tokenurile --font-ui / --font-mono definite in :root (sursa unica de adevar). */
@font-face{font-family:"IBM Plex Sans";font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexSans-Medium-latin-ext.woff2") format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF;} :root{--font-ui:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--font-mono:ui-monospace,"SF Mono","Cascadia Code","Segoe UI Mono","Roboto Mono",Menlo,Consolas,monospace;}
@font-face{font-family:"IBM Plex Sans";font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexSans-Medium-latin.woff2") format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+20AC,U+2122;}
@font-face{font-family:"IBM Plex Sans";font-weight:700;font-display:swap;src:url("/static/fonts/IBMPlexSans-Bold-latin-ext.woff2") format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF;}
@font-face{font-family:"IBM Plex Sans";font-weight:700;font-display:swap;src:url("/static/fonts/IBMPlexSans-Bold-latin.woff2") format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+20AC,U+2122;}
@font-face{font-family:"IBM Plex Mono";font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexMono-Regular-latin-ext.woff2") format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF;}
@font-face{font-family:"IBM Plex Mono";font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+20AC,U+2122;}
@font-face{font-family:"IBM Plex Mono";font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+20AC,U+2122;}
*{box-sizing:border-box;} *{box-sizing:border-box;}
html,body{margin:0;padding:0;} html,body{margin:0;padding:0;}
body{font-family:'IBM Plex Sans',system-ui,sans-serif;-webkit-font-smoothing:antialiased;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);} body{font-family:var(--font-ui);-webkit-font-smoothing:antialiased;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);}
body[data-theme="grafit"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473} body[data-theme="grafit"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473}
body[data-theme="cobalt"]{--bg:#080d1c;--card:#111a33;--card2:#0b1226;--text:#e9ecfb;--sub:#8a93b8;--line:#1d2747;--line2:#161f3a;--accent:#4068FF;--hbg:rgba(8,13,28,.9);--okt:#2fd0a6;--infot:#8aa0ff;--errt:#f06a7a;--mut:#5a6390} body[data-theme="cobalt"]{--bg:#080d1c;--card:#111a33;--card2:#0b1226;--text:#e9ecfb;--sub:#8a93b8;--line:#1d2747;--line2:#161f3a;--accent:#4068FF;--hbg:rgba(8,13,28,.9);--okt:#2fd0a6;--infot:#8aa0ff;--errt:#f06a7a;--mut:#5a6390}
body[data-theme="cupru"]{--bg:#15110b;--card:#211a12;--card2:#15110b;--text:#efe6d6;--sub:#a89a85;--line:#36291c;--line2:#281e14;--accent:#D98A3D;--hbg:rgba(21,17,11,.9);--okt:#67b98c;--infot:#dfa45c;--errt:#e2685a;--mut:#6d5f4c} body[data-theme="cupru"]{--bg:#15110b;--card:#211a12;--card2:#15110b;--text:#efe6d6;--sub:#a89a85;--line:#36291c;--line2:#281e14;--accent:#D98A3D;--hbg:rgba(21,17,11,.9);--okt:#67b98c;--infot:#dfa45c;--errt:#e2685a;--mut:#6d5f4c}
@@ -53,7 +47,7 @@
.lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;} .lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;}
} }
@media (max-width:430px){ @media (max-width:430px){
.lp-hactions [data-act="auth"][data-tab="login"]{display:none!important;} .lp-hactions a.auth-login-link{display:none!important;}
} }
</style> </style>
</head> </head>
@@ -61,8 +55,8 @@
<script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script> <script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script>
<main class="page"> <main class="page">
<!-- ANNOUNCE BAR --> <!-- ANNOUNCE BAR -->
<div style="display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 40px;background:var(--card,#181c24);border-bottom:1px solid var(--line,#262b36);font:500 13px 'IBM Plex Sans';color:var(--text,#e6e9ef);flex-wrap:wrap;"> <div style="display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 40px;background:var(--card,#181c24);border-bottom:1px solid var(--line,#262b36);font:500 13px var(--font-ui);color:var(--text,#e6e9ef);flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Vrei să testezi sau ai un service mic? Este <strong style="font-weight:700;color:#1F9D5C;">gratuit</strong> — până la 100 de prestații/lună, fără card bancar.</span> <span style="display:inline-flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Vrei să testezi sau ai un service mic? Este <strong style="font-weight:700;color:#1F9D5C;">gratuit</strong> — până la 60 de prestații/lună, fără card bancar.</span>
<a data-act="auth" data-tab="register" style="display:inline-flex;align-items:center;gap:5px;color:var(--accent,#2E74D6);font-weight:700;cursor:pointer;text-decoration:none;transition:color .18s ease, transform .18s ease;" style-hover="color:#17a96e;transform:translateX(2px)">Creează cont în 2 minute <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a> <a data-act="auth" data-tab="register" style="display:inline-flex;align-items:center;gap:5px;color:var(--accent,#2E74D6);font-weight:700;cursor:pointer;text-decoration:none;transition:color .18s ease, transform .18s ease;" style-hover="color:#17a96e;transform:translateX(2px)">Creează cont în 2 minute <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
</div> </div>
@@ -70,34 +64,34 @@
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;"> <div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
<div style="display:flex;align-items:center;gap:48px;"> <div style="display:flex;align-items:center;gap:48px;">
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" /> <img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px 'IBM Plex Sans';color:var(--sub,#8b93a7);"> <div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
<span>Cum funcționează</span><span>API</span><span>Preț</span> <span>Cum funcționează</span><span>API</span><span>Preț</span>
</div> </div>
</div> </div>
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;"> <div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px 'IBM Plex Sans';cursor:pointer;"> <button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg> <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
<span id="theme-label">Grafit</span> <span id="theme-label">Grafit</span>
</button> </button>
<button data-act="auth" data-tab="login" style="height:44px;padding:0 18px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 'IBM Plex Sans';cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</button> <a href="/login" class="auth-login-link" style="display:inline-flex;align-items:center;height:44px;padding:0 18px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</a>
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px 'IBM Plex Sans';cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button> <button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
</div> </div>
</div> </div>
<!-- HERO --> <!-- HERO -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
<div> <div>
<div style="display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:99px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 13px 'IBM Plex Sans';margin-bottom:24px;"> <div style="display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:99px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 13px var(--font-ui);margin-bottom:24px;">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>
<span><strong style="font-weight:700;color:#1F9D5C;">Gratuit</strong> pentru testare și service-uri mici · 100 prestații/lună</span> <span><strong style="font-weight:700;color:#1F9D5C;">Gratuit</strong> pentru testare și service-uri mici · 60 prestații/lună</span>
</div> </div>
<h1 class="lp-h1" style="font:700 50px/1.06 'IBM Plex Sans';letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1> <h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
<p style="font:400 17px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 32px;max-width:480px;">Conformitate RAR fără bătaie de cap. Încarci un fișier sau conectezi softul de service — noi trimitem prezentările la RAR în siguranță, conform Legii 142/2023.</p> <p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 32px;max-width:480px;">Conformitate RAR fără bătaie de cap. Încarci un fișier sau conectezi softul de service — noi trimitem prezentările la RAR în siguranță, conform Legii 142/2023.</p>
<div style="display:flex;gap:12px;margin-bottom:22px;"> <div style="display:flex;gap:12px;margin-bottom:22px;">
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px 'IBM Plex Sans';cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button> <button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
<button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px 'IBM Plex Sans';cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button> <button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button>
</div> </div>
<div style="display:flex;align-items:center;gap:14px;font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span> <span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
<span style="color:var(--line,#262b36);">·</span> <span style="color:var(--line,#262b36);">·</span>
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span> <span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span>
@@ -110,34 +104,34 @@
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);"> <div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
<div> <div>
<div style="font:700 14px 'IBM Plex Sans';color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div> <div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div>
<div style="font:400 12px 'IBM Plex Mono';color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div> <div style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div>
</div> </div>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<div style="display:flex;align-items:center;gap:5px;padding:4px 9px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 11px 'IBM Plex Sans';color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Live</div> <div style="display:flex;align-items:center;gap:5px;padding:4px 9px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 11px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Live</div>
</div> </div>
</div> </div>
<div style="display:flex;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line,#262b36);"> <div style="display:flex;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line,#262b36);">
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px 'IBM Plex Sans';color:var(--text,#e6e9ef);">847</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Trimise luna asta</div></div> <div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:var(--text,#e6e9ef);">847</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Trimise luna asta</div></div>
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px 'IBM Plex Sans';color:var(--accent,#2E74D6);">12</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">În coadă</div></div> <div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:var(--accent,#2E74D6);">12</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">În coadă</div></div>
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px 'IBM Plex Sans';color:#E05D5D;">2</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">De corectat</div></div> <div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:#E05D5D;">2</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">De corectat</div></div>
</div> </div>
<div style="padding:6px 0;"> <div style="padding:6px 0;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);"> <div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
<div><div style="font:500 13px 'IBM Plex Mono';color:var(--text,#e6e9ef);">WBA8E9...K7F2</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Inspecție tehnică · 09:42</div></div> <div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">WBA8E9...K7F2</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Inspecție tehnică · 09:42</div></div>
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px 'IBM Plex Sans';color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div> <div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div>
</div> </div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);"> <div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
<div><div style="font:500 13px 'IBM Plex Mono';color:var(--text,#e6e9ef);">WVWZZZ...3M1</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Revizie periodică · 09:38</div></div> <div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">WVWZZZ...3M1</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Revizie periodică · 09:38</div></div>
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,var(--accent,#2E74D6) 14%,transparent);font:500 12px 'IBM Plex Sans';color:var(--infot,#6ea2ec);"><span style="width:6px;height:6px;border-radius:99px;background:var(--accent,#2E74D6);"></span>În coadă</div> <div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,var(--accent,#2E74D6) 14%,transparent);font:500 12px var(--font-ui);color:var(--infot,#6ea2ec);"><span style="width:6px;height:6px;border-radius:99px;background:var(--accent,#2E74D6);"></span>În coadă</div>
</div> </div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);"> <div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
<div><div style="font:500 13px 'IBM Plex Mono';color:var(--text,#e6e9ef);">VF1RFB...A88</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Sistem frânare · 09:31</div></div> <div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">VF1RFB...A88</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Sistem frânare · 09:31</div></div>
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);font:500 12px 'IBM Plex Sans';color:var(--errt,#E05D5D);"><span style="width:6px;height:6px;border-radius:99px;background:#E05D5D;"></span>Eroare VIN</div> <div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);font:500 12px var(--font-ui);color:var(--errt,#E05D5D);"><span style="width:6px;height:6px;border-radius:99px;background:#E05D5D;"></span>Eroare VIN</div>
</div> </div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;"> <div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;">
<div><div style="font:500 13px 'IBM Plex Mono';color:var(--text,#e6e9ef);">ZAR937...C04</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Schimb ulei · 09:24</div></div> <div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">ZAR937...C04</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Schimb ulei · 09:24</div></div>
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px 'IBM Plex Sans';color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div> <div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div>
</div> </div>
</div> </div>
</div> </div>
@@ -147,27 +141,27 @@
<div style="padding:80px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));"> <div style="padding:80px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
<div style="display:grid;grid-template-columns:1.05fr .95fr;gap:48px;align-items:start;margin:0 auto;"> <div style="display:grid;grid-template-columns:1.05fr .95fr;gap:48px;align-items:start;margin:0 auto;">
<div> <div>
<h2 style="font:700 38px/1.14 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 23 minute și tastezi pe rar-autopass.ro</h2> <h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 23 minute și tastezi pe rar-autopass.ro</h2>
<p style="font:400 16px/1.65 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 16px;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi.</p> <p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 16px;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi.</p>
<p style="font:400 16px/1.65 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0;">Iar dacă greșești o cifră din VIN, prestația e respinsă și o iei de la capăt — cu risc de amendă pentru raportare incompletă sau întârziată.</p> <p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Iar dacă greșești o cifră din VIN, prestația e respinsă și o iei de la capăt — cu risc de amendă pentru raportare incompletă sau întârziată.</p>
</div> </div>
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:20px;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px 'IBM Plex Mono';color:var(--sub,#8b93a7);">rar-autopass.ro · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px 'IBM Plex Mono';"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">rar-autopass.ro · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
<div style="display:flex;flex-direction:column;gap:10px;"> <div style="display:flex;flex-direction:column;gap:10px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px 'IBM Plex Mono';color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div> <div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
<div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă Vin</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px 'IBM Plex Mono';color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div> <div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă Vin</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px 'IBM Plex Mono';color:var(--text,#e6e9ef);">2026-06-22</div></div> <div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">2026-06-22</div></div>
<div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:4px;">Număr Înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px 'IBM Plex Mono';color:var(--text,#e6e9ef);">CT88NOE</div></div> <div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Număr Înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">CT88NOE</div></div>
</div> </div>
<div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px 'IBM Plex Sans';color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div> <div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-ui);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div>
<div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px 'IBM Plex Sans';"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px 'IBM Plex Sans';"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div> <div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div>
<div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px 'IBM Plex Mono';color:var(--text,#e6e9ef);">39000</div></div> <div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">39000</div></div>
</div> </div>
<button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px 'IBM Plex Sans';cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button> <button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px var(--font-ui);cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button>
<div style="margin-top:12px;font:400 12px 'IBM Plex Sans';color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div> <div style="margin-top:12px;font:400 12px var(--font-ui);color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div>
</div> </div>
</div> </div>
</div> </div>
@@ -175,37 +169,37 @@
<!-- AGITATE / CALCULATOR --> <!-- AGITATE / CALCULATOR -->
<div style="padding:80px 40px;"> <div style="padding:80px 40px;">
<div style="text-align:center;max-width:720px;margin:0 auto 40px;"> <div style="text-align:center;max-width:720px;margin:0 auto 40px;">
<div style="font:500 13px 'IBM Plex Sans';color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Cât te costă de fapt</div> <div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Cât te costă de fapt</div>
<h2 style="font:700 36px 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 12px;color:var(--text,#e6e9ef);">Fă socoteala. Minutele acelea sunt bani.</h2> <h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 12px;color:var(--text,#e6e9ef);">Fă socoteala. Minutele acelea sunt bani.</h2>
<p style="font:400 16px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0;">Mută cursorul la volumul service-ului tău și vezi cât timp și câți bani pleacă pe raportarea manuală.</p> <p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Mută cursorul la volumul service-ului tău și vezi cât timp și câți bani pleacă pe raportarea manuală.</p>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:0 auto;align-items:stretch;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:0 auto;align-items:stretch;">
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:32px;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:32px;">
<div style="margin-bottom:28px;"> <div style="margin-bottom:28px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px 'IBM Plex Sans';color:var(--text,#e6e9ef);">Prestații pe lună</span><span style="font:700 20px 'IBM Plex Mono';color:var(--accent,#2E74D6);" id="out-pres">300</span></div> <div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px var(--font-ui);color:var(--text,#e6e9ef);">Prestații pe lună</span><span style="font:700 20px var(--font-mono);color:var(--accent,#2E74D6);" id="out-pres">300</span></div>
<input type="range" min="50" max="1500" step="10" value="300" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" /> <input type="range" min="50" max="1500" step="10" value="300" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
</div> </div>
<div style="margin-bottom:24px;"> <div style="margin-bottom:24px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px 'IBM Plex Sans';color:var(--text,#e6e9ef);">Cost manoperă</span><span style="font:700 20px 'IBM Plex Mono';color:var(--accent,#2E74D6);"><span id="out-rate">60</span> lei/h</span></div> <div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px var(--font-ui);color:var(--text,#e6e9ef);">Cost manoperă</span><span style="font:700 20px var(--font-mono);color:var(--accent,#2E74D6);"><span id="out-rate">60</span> lei/h</span></div>
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" /> <input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
</div> </div>
<div style="display:flex;align-items:center;gap:9px;padding-top:18px;border-top:1px solid var(--line,#262b36);font:400 13px/1.5 'IBM Plex Sans';color:var(--sub,#8b93a7);"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="flex-shrink:0;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de introdus manual pentru fiecare prestație.</div> <div style="display:flex;align-items:center;gap:9px;padding-top:18px;border-top:1px solid var(--line,#262b36);font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="flex-shrink:0;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de introdus manual pentru fiecare prestație.</div>
</div> </div>
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E05D5D 32%,var(--line,#262b36));border-radius:12px;padding:32px;display:flex;flex-direction:column;justify-content:center;"> <div style="background:color-mix(in srgb,#E05D5D 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E05D5D 32%,var(--line,#262b36));border-radius:12px;padding:32px;display:flex;flex-direction:column;justify-content:center;">
<div style="font:600 12px 'IBM Plex Sans';color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;">Pierdut pe raportare manuală</div> <div style="font:600 12px var(--font-ui);color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;">Pierdut pe raportare manuală</div>
<div style="display:flex;align-items:baseline;gap:8px;"><span style="font:700 52px/1 'IBM Plex Sans';letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></span><span style="font:500 15px 'IBM Plex Sans';color:var(--sub,#8b93a7);">lei / lună</span></div> <div style="display:flex;align-items:baseline;gap:8px;"><span style="font:700 52px/1 var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></span><span style="font:500 15px var(--font-ui);color:var(--sub,#8b93a7);">lei / lună</span></div>
<div style="font:400 14px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-top:8px;"><span data-calc="hMonth">0</span> ore pe lună &middot; <span data-calc="leiYear">0</span> lei pe an &middot;<span data-calc="days">0</span> zile lucrătoare/an doar cu raportarea.</div> <div style="font:400 14px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin-top:8px;"><span data-calc="hMonth">0</span> ore pe lună &middot; <span data-calc="leiYear">0</span> lei pe an &middot;<span data-calc="days">0</span> zile lucrătoare/an doar cu raportarea.</div>
<div style="margin-top:20px;padding-top:18px;border-top:1px solid color-mix(in srgb,#E05D5D 24%,var(--line,#262b36));"> <div style="margin-top:20px;padding-top:18px;border-top:1px solid color-mix(in srgb,#E05D5D 24%,var(--line,#262b36));">
<div style="display:flex;align-items:center;gap:9px;font:600 14px 'IBM Plex Sans';color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROMFAST: câteva secunde pentru tot lotul</div> <div style="display:flex;align-items:center;gap:9px;font:600 14px var(--font-ui);color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROMFAST: câteva secunde pentru tot lotul</div>
<div style="font:400 13px/1.55 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div> <div style="font:400 13px/1.55 var(--font-ui);color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div>
</div> </div>
</div> </div>
</div> </div>
<div style="margin:24px auto 0;display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;"> <div style="margin:24px auto 0;display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;">
<div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div> <div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div>
<div> <div>
<div style="font:700 16px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div> <div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div>
<p style="font:400 14px/1.65 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0;">Conform <strong style="color:var(--text,#e6e9ef);font-weight:600;">Legii nr. 142/2023</strong> și <strong style="color:var(--text,#e6e9ef);font-weight:600;">OMTI nr. 210/2024</strong>, service-urile auto autorizate RAR trebuie să transmită, la finalizarea fiecărei prestații, informațiile obligatorii (VIN, kilometraj și, după caz, date privind intervențiile asupra odometrului și reparațiile rezultate din avarii grave). Nerespectarea obligației se sancționează cu amendă între <span style="color:var(--errt,#E05D5D);font-weight:600;">2.000 și 5.000 lei</span>, iar transmiterea unor informații eronate cu amendă între <span style="color:#E0A93B;font-weight:600;">1.000 și 2.000 lei</span>.</p> <p style="font:400 14px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Conform <strong style="color:var(--text,#e6e9ef);font-weight:600;">Legii nr. 142/2023</strong> și <strong style="color:var(--text,#e6e9ef);font-weight:600;">OMTI nr. 210/2024</strong>, service-urile auto autorizate RAR trebuie să transmită, la finalizarea fiecărei prestații, informațiile obligatorii (VIN, kilometraj și, după caz, date privind intervențiile asupra odometrului și reparațiile rezultate din avarii grave). Nerespectarea obligației se sancționează cu amendă între <span style="color:var(--errt,#E05D5D);font-weight:600;">2.000 și 5.000 lei</span>, iar transmiterea unor informații eronate cu amendă între <span style="color:#E0A93B;font-weight:600;">1.000 și 2.000 lei</span>.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -213,10 +207,10 @@
<!-- SOLVE --> <!-- SOLVE -->
<div style="padding:80px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);"> <div style="padding:80px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
<div style="max-width:780px;margin:0 auto;text-align:center;"> <div style="max-width:780px;margin:0 auto;text-align:center;">
<h2 style="font:700 36px 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2> <h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
<p style="font:400 19px/1.75 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA Auto-Pass îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p> <p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA Auto-Pass îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
</div> </div>
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);"> <div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
<span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">23 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;">&nbsp;&nbsp; câteva secunde pentru tot lotul.</span> <span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">23 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;">&nbsp;&nbsp; câteva secunde pentru tot lotul.</span>
</div> </div>
</div> </div>
@@ -225,17 +219,17 @@
<div style="padding:0 40px 80px;"> <div style="padding:0 40px 80px;">
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
<div> <div>
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px 'IBM Plex Sans';margin-bottom:18px;">Pentru service-uri cu soft propriu · ROAAUTO</div> <div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu · ROAAUTO</div>
<h2 style="font:700 30px/1.15 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2> <h2 style="font:700 30px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2>
<p style="font:400 15px/1.65 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p> <p style="font:400 15px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p>
<button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 'IBM Plex Sans';cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button> <button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
</div> </div>
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;"> <div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;"> <div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;">
<span style="width:11px;height:11px;border-radius:99px;background:#E05D5D;"></span><span style="width:11px;height:11px;border-radius:99px;background:#E0A93B;"></span><span style="width:11px;height:11px;border-radius:99px;background:#2FBF8F;"></span> <span style="width:11px;height:11px;border-radius:99px;background:#E05D5D;"></span><span style="width:11px;height:11px;border-radius:99px;background:#E0A93B;"></span><span style="width:11px;height:11px;border-radius:99px;background:#2FBF8F;"></span>
<span style="font:400 12px 'IBM Plex Mono';color:#8b93a7;margin-left:8px;">request.sh</span> <span style="font:400 12px var(--font-mono);color:#8b93a7;margin-left:8px;">request.sh</span>
</div> </div>
<pre style="margin:0;padding:18px;font:400 13px/1.7 'IBM Plex Mono';color:#e6e9ef;overflow-x:auto;"><span style="color:#2FBF8F;">POST</span> /v1/prezentari <pre style="margin:0;padding:18px;font:400 13px/1.7 var(--font-mono);color:#e6e9ef;overflow-x:auto;"><span style="color:#2FBF8F;">POST</span> /v1/prezentari
<span style="color:#8b93a7;">Authorization:</span> <span style="color:#E0A93B;">rfak_••••••••</span> <span style="color:#8b93a7;">Authorization:</span> <span style="color:#E0A93B;">rfak_••••••••</span>
<span style="color:#8b93a7;">Content-Type:</span> application/json <span style="color:#8b93a7;">Content-Type:</span> application/json
@@ -251,69 +245,69 @@
<!-- PRICING --> <!-- PRICING -->
<div style="padding:0 40px 80px;"> <div style="padding:0 40px 80px;">
<div style="text-align:center;margin-bottom:44px;"> <div style="text-align:center;margin-bottom:44px;">
<div style="font:500 13px 'IBM Plex Sans';color:var(--accent,#2E74D6);letter-spacing:.08em;text-transform:uppercase;margin-bottom:12px;">Preț</div> <div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.08em;text-transform:uppercase;margin-bottom:12px;">Preț</div>
<h2 style="font:700 34px 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2> <h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
<p style="font:400 15px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0;">Fiecare cont începe cu <strong style="color:var(--text,#e6e9ef);font-weight:600;">Premium gratuit 30 de zile</strong>. Apoi trece automat pe Gratuit — fără plată, dacă nu alegi alt plan. Fără card bancar.</p> <p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Fiecare cont începe cu <strong style="color:var(--text,#e6e9ef);font-weight:600;">Pro gratuit 30 de zile</strong>. Apoi trece automat pe Gratuit — fără plată, dacă nu alegi alt plan. Fără card bancar.</p>
</div> </div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:start;"> <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:start;">
<!-- Gratuit --> <!-- Gratuit -->
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;"> <div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;">
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px 'IBM Plex Sans';letter-spacing:.04em;text-transform:uppercase;">Testare și firme mici</div> <div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Testare și firme mici</div>
<div style="font:700 17px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div> <div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">/lună</span></div> <div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
<div style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:20px;">Fără card bancar</div> <div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Fără card bancar</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;"> <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Până la 100 de prestații/lună</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Până la 60 de prestații/lună</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de prestații RAR (din mii)</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de prestații RAR (din mii)</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare manuală coloane, cu salvare</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare manuală coloane, cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport contact/email în 48h</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport contact/email în 48h</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
<span style="display:none;"></span> <span style="display:none;"></span>
</div> </div>
<button data-act="auth" data-tab="register" data-plan="Gratuit" style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px 'IBM Plex Sans';cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button> <button data-act="auth" data-tab="register" data-plan="Gratuit" style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
</div> </div>
<!-- Standard --> <!-- Standard -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
<div style="font:700 17px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div> <div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">39 lei</span><span style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">/lună</span></div> <div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">39 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
<div style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:20px;">Volum nelimitat, fără API</div> <div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Volum nelimitat, fără API</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;"> <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Gratuit</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Gratuit</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Prestații nelimitate</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Prestații nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport contact/email în 48h</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport contact/email în 48h</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
<span style="display:none;"></span> <span style="display:none;"></span>
</div> </div>
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 'IBM Plex Sans';cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Standard">Creează cont gratuit</button> <button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Standard">Creează cont gratuit</button>
</div> </div>
<!-- Pro --> <!-- Pro -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;position:relative;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;position:relative;">
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 90%,#000);color:#fff;font:700 10px 'IBM Plex Sans';letter-spacing:.04em;text-transform:uppercase;">Cel mai ales</div> <div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 90%,#000);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Cel mai ales</div>
<div style="font:700 17px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div> <div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">/lună</span></div> <div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
<div style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:20px;">Cu acces API</div> <div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Cu acces API</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;"> <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Standard</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Standard</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API + cheie API per cont</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API + cheie API per cont</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport pe email în 24h</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport pe email în 24h</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare la operațiile incerte</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare la operațiile incerte</div>
<span style="display:none;"></span> <span style="display:none;"></span>
</div> </div>
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 'IBM Plex Sans';cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Pro">Creează cont gratuit</button> <button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Pro">Creează cont gratuit</button>
</div> </div>
<!-- Premium --> <!-- Premium -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
<div style="font:700 17px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div> <div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 30px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div> <div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
<div style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div> <div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;"> <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Pro</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Pro</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Opțiune de integrare în softul tău</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Opțiune de integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic și online</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic și online</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Asistență și onboarding dedicate</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Asistență și onboarding dedicate</div>
</div> </div>
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 'IBM Plex Sans';cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Premium">Creează cont gratuit</button> <button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Premium">Creează cont gratuit</button>
</div> </div>
</div> </div>
</div> </div>
@@ -321,19 +315,19 @@
<!-- PRIVACY --> <!-- PRIVACY -->
<div style="padding:80px 40px;border-top:1px solid var(--line,#262b36);"> <div style="padding:80px 40px;border-top:1px solid var(--line,#262b36);">
<div style="margin:0 auto;display:grid;grid-template-columns:minmax(240px,330px) 1fr;gap:48px;align-items:center;"> <div style="margin:0 auto;display:grid;grid-template-columns:minmax(240px,330px) 1fr;gap:48px;align-items:center;">
<h2 style="font:700 30px/1.2 'IBM Plex Sans';letter-spacing:-.02em;margin:0;color:var(--text,#e6e9ef);">Datele clienților tăi nu devin marfă</h2> <h2 style="font:700 30px/1.2 var(--font-ui);letter-spacing:-.02em;margin:0;color:var(--text,#e6e9ef);">Datele clienților tăi nu devin marfă</h2>
<div style="display:flex;flex-wrap:wrap;"> <div style="display:flex;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);"> <div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:7px;">Reținem doar strict necesarul</div> <div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Reținem doar strict necesarul</div>
<div style="font:400 14px/1.55 'IBM Plex Sans';color:var(--sub,#8b93a7);">Doar datele de care e nevoie ca să trimitem la RAR — nimic adunat în plus, nici la conturile gratuite.</div> <div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Doar datele de care e nevoie ca să trimitem la RAR — nimic adunat în plus, nici la conturile gratuite.</div>
</div> </div>
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);"> <div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:7px;">Doar pentru scopul declarat</div> <div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Doar pentru scopul declarat</div>
<div style="font:400 14px/1.55 'IBM Plex Sans';color:var(--sub,#8b93a7);">Maparea și trimiterea la RAR. Nu le vindem și nu le dăm mai departe.</div> <div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Maparea și trimiterea la RAR. Nu le vindem și nu le dăm mai departe.</div>
</div> </div>
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);"> <div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:7px;">Se șterg la 3 luni</div> <div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Se șterg la 3 luni</div>
<div style="font:400 14px/1.55 'IBM Plex Sans';color:var(--sub,#8b93a7);">Automat, fără să ceri — sau chiar acum, cu un singur click.</div> <div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Automat, fără să ceri — sau chiar acum, cu un singur click.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -343,39 +337,39 @@
<div id="inregistrare" style="padding:80px 40px;border-top:1px solid var(--line,#262b36);background:color-mix(in srgb,var(--accent,#2E74D6) 5%,var(--bg,#0f1218));"> <div id="inregistrare" style="padding:80px 40px;border-top:1px solid var(--line,#262b36);background:color-mix(in srgb,var(--accent,#2E74D6) 5%,var(--bg,#0f1218));">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;margin:0 auto;align-items:center;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;margin:0 auto;align-items:center;">
<div> <div>
<div style="font:500 13px 'IBM Plex Sans';color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div> <div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div>
<h2 style="font:700 34px/1.15 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2> <h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
<p style="font:400 16px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit, fără card bancar. Imediat poți încărca primul fișier sau conecta softul de service.</p> <p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit, fără card bancar. Imediat poți încărca primul fișier sau conecta softul de service.</p>
<div style="display:flex;flex-direction:column;gap:12px;"> <div style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;gap:10px;align-items:center;font:400 15px 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Premium gratuit 30 de zile, apoi automat pe Gratuit</div> <div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Pro gratuit 30 de zile, apoi automat pe Gratuit</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Fără card bancar la înscriere</div> <div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Fără card bancar la înscriere</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div> <div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div> <div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
</div> </div>
</div> </div>
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:32px;box-shadow:0 20px 50px -24px rgba(0,0,0,.5);"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:32px;box-shadow:0 20px 50px -24px rgba(0,0,0,.5);">
<div style="display:flex;gap:28px;border-bottom:1px solid var(--line,#262b36);margin-bottom:24px;"> <div style="display:flex;gap:28px;border-bottom:1px solid var(--line,#262b36);margin-bottom:24px;">
<button type="button" data-act="tab" data-tab="register" class="auth-tab is-active" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px 'IBM Plex Sans';color:var(--text,#e6e9ef);cursor:pointer;">Creează cont<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button> <button type="button" data-act="tab" data-tab="register" class="auth-tab is-active" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px var(--font-ui);color:var(--text,#e6e9ef);cursor:pointer;">Creează cont<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button>
<button type="button" data-act="tab" data-tab="login" class="auth-tab" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px 'IBM Plex Sans';color:var(--sub,#8b93a7);cursor:pointer;">Autentificare<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button> <button type="button" data-act="tab" data-tab="login" class="auth-tab" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;">Autentificare<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button>
</div> </div>
<form method="post" action="/signup" data-pane="register"> <form method="post" action="/signup" data-pane="register">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Nume contact</span><input type="text" name="name" required placeholder="Ion Popescu" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px 'IBM Plex Sans';outline:none;" /></label> <label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Nume contact</span><input type="text" name="name" required placeholder="Ion Popescu" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px 'IBM Plex Mono';outline:none;" /></label> <label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px 'IBM Plex Sans';outline:none;" /></label> <label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px 'IBM Plex Sans';outline:none;" /></label> <label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px 'IBM Plex Sans';outline:none;cursor:pointer;"><option value="Gratuit" selected>Gratuit — 0 lei/lună</option><option value="Standard">Standard — 39 lei/lună</option><option value="Pro">Pro — 59 lei/lună</option><option value="Premium">Premium — la cerere</option></select></label> <label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="Gratuit" selected>Gratuit — 0 lei/lună</option><option value="Standard">Standard — 39 lei/lună</option><option value="Pro">Pro — 59 lei/lună</option><option value="Premium">Premium — la cerere</option></select></label>
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 'IBM Plex Sans';color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label> <label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px 'IBM Plex Sans';cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button> <button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
<div style="text-align:center;margin-top:14px;font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div> <div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
</form> </form>
<form method="post" action="/login" data-pane="login" style="display:none;"> <form method="post" action="/login" data-pane="login" style="display:none;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px 'IBM Plex Sans';outline:none;" /></label> <label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:10px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required placeholder="Parola ta" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px 'IBM Plex Sans';outline:none;" /></label> <label style="display:block;margin-bottom:10px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required placeholder="Parola ta" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<div style="text-align:right;margin-bottom:18px;"><a href="/login" style="font:400 13px 'IBM Plex Sans';color:var(--accent,#2E74D6);cursor:pointer;">Ai uitat parola?</a></div> <div style="text-align:right;margin-bottom:18px;"><a href="/login" style="font:400 13px var(--font-ui);color:var(--accent,#2E74D6);cursor:pointer;">Ai uitat parola?</a></div>
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px 'IBM Plex Sans';cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease;">Autentificare</button> <button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease;">Autentificare</button>
<div style="text-align:center;margin-top:14px;font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Nu ai cont? <a data-act="tab" data-tab="register" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Creează unul gratuit</a></div> <div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Nu ai cont? <a data-act="tab" data-tab="register" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Creează unul gratuit</a></div>
</form> </form>
</div> </div>
</div> </div>
@@ -384,22 +378,22 @@
<!-- FINAL CTA --> <!-- FINAL CTA -->
<div style="padding:0 40px 80px;"> <div style="padding:0 40px 80px;">
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:16px;padding:56px 40px;text-align:center;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:16px;padding:56px 40px;text-align:center;">
<h2 style="font:700 36px 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Începe să declari la RAR în câteva minute</h2> <h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Începe să declari la RAR în câteva minute</h2>
<p style="font:400 16px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 28px;">Gratuit până la 100 de prezentări pe lună. Fără card bancar.</p> <p style="font:400 16px var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 28px;">Gratuit până la 60 de prezentări pe lună. Fără card bancar.</p>
<div style="display:flex;gap:12px;justify-content:center;"> <div style="display:flex;gap:12px;justify-content:center;">
<button data-act="auth" data-tab="register" style="height:50px;padding:0 28px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px 'IBM Plex Sans';cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button> <button data-act="auth" data-tab="register" style="height:50px;padding:0 28px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
<button data-act="auth" data-tab="login" style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px 'IBM Plex Sans';cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</button> <button data-act="auth" data-tab="login" style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</button>
</div> </div>
</div> </div>
</div> </div>
<!-- FOOTER --> <!-- FOOTER -->
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;"> <div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
<div style="font:700 18px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div> <div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
<div style="display:flex;gap:26px;font:400 14px 'IBM Plex Sans';color:var(--sub,#8b93a7);"> <div style="display:flex;gap:26px;font:400 14px var(--font-ui);color:var(--sub,#8b93a7);">
<span>Termeni</span><span>Confidențialitate / GDPR</span><span>Documentație API</span><span>Contact</span> <span>Termeni</span><span>Confidențialitate / GDPR</span><span>Documentație API</span><span>Contact</span>
</div> </div>
<div style="font:400 13px 'IBM Plex Sans';color:var(--mut,#5c6473);">© 2026 ROMFAST</div> <div style="font:400 13px var(--font-ui);color:var(--mut,#5c6473);">© 2026 ROMFAST</div>
</div> </div>
</main> </main>
<script> <script>

View File

@@ -1,28 +1,98 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Autentificare — Gateway RAR AUTOPASS{% endblock %} {% block title %}Autentificare — ROMFAST AUTOPASS{% endblock %}
{% block content %} {% block content %}
<div class="card auth-card" style="max-width:400px;margin:40px auto;"> {# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
<h2 style="margin-top:0;">Autentificare</h2> Stanga: logo + tagline + puncte de incredere.
Dreapta: formular de autentificare (neschimbat: CSRF, POST /login, link signup).
Pe mobil (<640px): se stivuiesc, partea dreapta (formular) iese prima. #}
<div class="login-2col" style="max-width:860px; margin:32px auto;">
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
<div class="login-shell">
{# === Coloana stanga: brand + trust === #}
<aside class="login-aside" aria-label="Despre ROMFAST AUTOPASS">
<div class="login-brand-row">
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:36px; width:auto;">
</div>
<h2 class="login-headline">ROMFAST <span style="color:var(--accent);">AUTOPASS</span></h2>
<p class="login-tagline">Declara prestatiile de service-auto la RAR AUTOPASS, automat.
Conform Legii 142/2023 si OMTI 210/2024.</p>
<ul class="login-trust">
<li>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
Conform Legii 142/2023 si OMTI 210/2024
</li>
<li>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>
Datele criptate, sterse la 3 luni
</li>
<li>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
Parte din familia ROA — Romfast Applications
</li>
</ul>
</aside>
{% if error %} {# === Coloana dreapta: formular (NESCHIMBAT — CSRF, POST /login, link signup) === #}
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div> <div class="login-form-col">
{% endif %} <h3 style="font-size:var(--fs-xl); margin:0 0 4px;">Autentificare</h3>
<p style="font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;">
Intra in contul service-ului tau.
</p>
<form method="post" action="/login"> {% if error %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <div class="banner" style="margin-bottom:14px; padding:8px 12px;">{{ error }}</div>
<p> {% endif %}
<label>Email</label><br>
<input type="email" name="email" required style="width:100%;">
</p>
<p>
<label>Parola</label><br>
<input type="password" name="parola" required style="width:100%;">
</p>
<button type="submit" style="width:100%;margin-top:8px;">Intra in cont</button>
</form>
<p style="text-align:center;font-size:13px;margin-top:16px;"> <form method="post" action="/login">
Cont nou? <a href="/signup">Inregistrare</a> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
</p> <div class="camp-slim">
<label for="lf-email">Email</label>
<input id="lf-email" type="email" name="email" required autocomplete="email">
</div>
<div class="camp-slim" style="margin-bottom:14px;">
<label for="lf-parola">Parola</label>
<input id="lf-parola" type="password" name="parola" required autocomplete="current-password">
</div>
<button type="submit" class="btn-primary-full">Intra in cont</button>
</form>
<p class="login-foot">
Cont nou? <a href="/signup" style="color:var(--accent);">Inregistreaza service-ul</a>
</p>
</div>
</div>
</div> </div>
<style>
/* US-010 PRD 5.16: layout /login profesional 2 coloane. */
.login-shell {
display:grid; grid-template-columns:1.1fr 0.9fr;
border:1px solid var(--line); border-radius:16px; overflow:hidden;
background:var(--card); min-height:480px;
}
.login-aside {
padding:40px 38px;
background:linear-gradient(160deg, color-mix(in srgb,var(--accent) 12%,var(--card)), var(--card));
border-right:1px solid var(--line);
display:flex; flex-direction:column; justify-content:center;
}
.login-brand-row { display:flex; align-items:center; gap:10px; margin-bottom:24px; }
.login-headline { font-size:var(--fs-2xl); line-height:var(--lh-tight); margin:0 0 12px; letter-spacing:-.02em; }
.login-tagline { font-size:var(--fs-md); color:var(--muted); line-height:1.6; margin:0 0 20px; max-width:340px; }
.login-trust { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:10px; }
.login-trust li { display:flex; align-items:center; gap:9px; font-size:var(--fs-sm); color:var(--ink); }
.login-trust svg { flex-shrink:0; color:var(--ok); }
.login-form-col { padding:40px 38px; display:flex; flex-direction:column; justify-content:center; }
.btn-primary-full { width:100%; min-height:46px; font-family:var(--font-ui); font-size:var(--fs-md);
font-weight:600; background:var(--accent); color:#fff; border:none;
border-radius:8px; cursor:pointer; margin-top:4px; }
.btn-primary-full:hover { filter:brightness(1.08); }
.login-foot { text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px; }
/* Mobil: stivuire verticala, formular sus */
@media (max-width:640px) {
.login-shell { grid-template-columns:1fr; grid-template-rows:auto auto; }
.login-aside { order:2; border-right:none; border-top:1px solid var(--line); padding:28px 22px; }
.login-form-col { order:1; padding:28px 22px; }
}
</style>
{% endblock %} {% endblock %}

View File

@@ -494,5 +494,18 @@ Record de test creat: `data.id = 68514` (FINALIZATA, permanent pe test). Confirm
- header `User-Agent` obligatoriu (altfel 403 WAF). - header `User-Agent` obligatoriu (altfel 403 WAF).
Rămas neprobat: ce alte valori `sistemReparat` (în afară de `"null"`) acceptă (Open Q #2). Rămas neprobat: ce alte valori `sistemReparat` (în afară de `"null"`) acceptă (Open Q #2).
## Note integrare — planuri de cont (PRD 5.17)
**Poți dezvolta și testa pe planul Gratuit** fără niciun upgrade — `POST /v1/prezentari/valideaza`
(dry-run) e permis pe orice plan, nu face enqueue și nu consumă cotă lunară. Primești același
răspuns de validare (câmpuri, cod_prestatie, rezolvare operație) ca la trimiterea reală.
**Trimiterea reală cere planul Pro** (sau trial Pro activ): rutele `POST /v1/prezentari`,
`POST /v1/import` și `POST /v1/import/{id}/commit` sunt gate-uite pe `api_access=True`
(Pro/Premium). Un cont Free/Standard primește `403 PLAN_FARA_API`. Contactează-ne pentru upgrade.
Planul Gratuit are limită de **60 prezentări/lună** (indiferent de canal). La depășire: `422 PLAN_LIMITA_LUNARA`.
Planul Pro nu are limită de volum. `GET /v1/nomenclator` rămâne public pe orice plan (exploatare pre-upgrade).
</content> </content>
</invoke> </invoke>

View File

@@ -112,7 +112,7 @@ def test_list_accounts_ordonat_fara_creds(conn):
assert ids == sorted(ids) assert ids == sorted(ids)
for r in rows: for r in rows:
assert "rar_creds_enc" not in r assert "rar_creds_enc" not in r
assert set(r.keys()) == {"id", "name", "cui", "email", "active", "status", "created_at"} assert set(r.keys()) == {"id", "name", "cui", "email", "active", "status", "created_at", "tier", "trial_until"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -170,3 +170,221 @@ def test_account_is_complete_false_pe_legacy_incomplet(conn):
# contul sistem id=1 e EXCEPTAT (returneaza True indiferent) # contul sistem id=1 e EXCEPTAT (returneaza True indiferent)
row_sys = conn.execute("SELECT * FROM accounts WHERE id=1").fetchone() row_sys = conn.execute("SELECT * FROM accounts WHERE id=1").fetchone()
assert account_is_complete(row_sys) is True assert account_is_complete(row_sys) is True
# ---------------------------------------------------------------------------
# 5.17 US-001/US-008: schema tier + trial_until + set_tier + CLI set-tier
# ---------------------------------------------------------------------------
def test_migrare_tier_trial_defensiva(conn):
"""_migrate adauga tier + trial_until pe conturi existente, e idempotenta."""
from app.db import _migrate
cols_before = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "tier" in cols_before
assert "trial_until" in cols_before
# a doua rulare: idempotenta (nu arunca)
_migrate(conn)
cols_after = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "tier" in cols_after
assert "trial_until" in cols_after
def test_cont_nou_tier_free_si_trial_30z(conn):
"""create_account seteaza tier='free' si trial_until = acum + ~30 zile."""
from datetime import datetime, timezone, timedelta
from app.accounts import create_account
before = datetime.now(timezone.utc)
acct_id = create_account(conn, "Service Trial", cui="RO_T1")
after = datetime.now(timezone.utc)
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["tier"] == "free"
assert row["trial_until"] is not None
tu = datetime.fromisoformat(row["trial_until"].replace(" ", "T"))
if tu.tzinfo is None:
tu = tu.replace(tzinfo=timezone.utc)
# trial_until trebuie sa fie intre now+29z si now+31z
assert tu >= before + timedelta(days=29)
assert tu <= after + timedelta(days=31)
def test_cont_nou_effective_tier_pro_in_trial(conn):
"""Cont nou are effective_tier='pro' (trial activ)."""
from datetime import datetime, timezone
from app.accounts import create_account
from app.plans import effective_tier
acct_id = create_account(conn, "Service Pro Trial", cui="RO_T2")
row = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id,)).fetchone()
now = datetime.now(timezone.utc)
assert effective_tier(row, now) == "pro"
def test_set_tier_valid(conn):
"""set_tier seteaza tier-ul corect."""
from app.accounts import create_account, set_tier
acct_id = create_account(conn, "Service Tier", cui="RO_T3")
set_tier(conn, acct_id, "pro")
row = conn.execute("SELECT tier FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["tier"] == "pro"
def test_set_tier_cu_trial(conn):
"""set_tier cu trial_until seteaza ambele campuri."""
from app.accounts import create_account, set_tier
acct_id = create_account(conn, "Service Tier Trial", cui="RO_T4")
set_tier(conn, acct_id, "standard", trial_until="2026-12-31 00:00:00")
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["tier"] == "standard"
assert row["trial_until"] == "2026-12-31 00:00:00"
def test_set_tier_no_trial_sterge_trial_until(conn):
"""set_tier cu trial_until=None sterge trial-ul existent."""
from app.accounts import create_account, set_tier
acct_id = create_account(conn, "Service No Trial", cui="RO_T5")
# mai intai setam un trial
set_tier(conn, acct_id, "pro", trial_until="2026-12-31 00:00:00")
# acum stergem trial-ul
set_tier(conn, acct_id, "pro", trial_until=None)
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["tier"] == "pro"
assert row["trial_until"] is None
def test_set_tier_invalid_respins(conn):
"""set_tier cu tier invalid ridica ValueError cu mesaj clar."""
from app.accounts import create_account, set_tier
acct_id = create_account(conn, "Service Tier Invalid", cui="RO_T6")
with pytest.raises(ValueError, match="tier invalid"):
set_tier(conn, acct_id, "gold")
def test_set_tier_protejeaza_id1(conn):
"""set_tier pe contul de sistem id=1 ridica ValueError (protejat)."""
from app.accounts import set_tier
with pytest.raises(ValueError):
set_tier(conn, 1, "pro")
def test_set_tier_cont_inexistent_ridica(conn):
"""set_tier pe cont inexistent ridica ValueError."""
from app.accounts import set_tier
with pytest.raises(ValueError, match="inexistent"):
set_tier(conn, 9999, "pro")
def test_list_accounts_include_tier_trial(conn):
"""list_accounts include coloanele tier si trial_until."""
from app.accounts import create_account, list_accounts
create_account(conn, "Service List", cui="RO_L1")
rows = list_accounts(conn)
for r in rows:
assert "tier" in r
assert "trial_until" in r
def test_default_account_tier_free_fara_trial(conn):
"""Contul implicit id=1 (creat de schema) are tier='free' si trial_until=NULL."""
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=1").fetchone()
assert row["tier"] == "free"
assert row["trial_until"] is None
def test_cli_set_tier(monkeypatch):
"""CLI set-tier seteaza tier-ul unui cont (--no-trial)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier.db"))
from app.config import get_settings
get_settings.cache_clear()
from tools.account import main
from app.db import init_db, get_connection
from app.accounts import create_account
init_db()
conn_tmp = get_connection()
acct_id = create_account(conn_tmp, "CLI Test", cui="RO_CLI1")
conn_tmp.close()
rc = main(["set-tier", "--account", str(acct_id), "--tier", "pro", "--no-trial"])
assert rc == 0
conn_tmp2 = get_connection()
row = conn_tmp2.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
conn_tmp2.close()
assert row["tier"] == "pro"
assert row["trial_until"] is None
get_settings.cache_clear()
def test_cli_set_tier_cu_trial_days(monkeypatch):
"""CLI set-tier cu --trial-days 14 seteaza trial_until = acum + 14z."""
from datetime import datetime, timezone, timedelta
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier2.db"))
from app.config import get_settings
get_settings.cache_clear()
from tools.account import main
from app.db import init_db, get_connection
from app.accounts import create_account
init_db()
conn_tmp = get_connection()
acct_id = create_account(conn_tmp, "CLI Trial", cui="RO_CLI2")
conn_tmp.close()
before = datetime.now(timezone.utc)
rc = main(["set-tier", "--account", str(acct_id), "--tier", "pro", "--trial-days", "14"])
assert rc == 0
after = datetime.now(timezone.utc)
conn_tmp2 = get_connection()
row = conn_tmp2.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
conn_tmp2.close()
assert row["tier"] == "pro"
assert row["trial_until"] is not None
tu = datetime.fromisoformat(row["trial_until"].replace(" ", "T")).replace(tzinfo=timezone.utc)
assert tu >= before + timedelta(days=13)
assert tu <= after + timedelta(days=15)
get_settings.cache_clear()
def test_cli_set_tier_invalid(monkeypatch):
"""CLI set-tier cu tier invalid: exit code != 0."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier3.db"))
from app.config import get_settings
get_settings.cache_clear()
from tools.account import main
from app.db import init_db, get_connection
from app.accounts import create_account
init_db()
conn_tmp = get_connection()
acct_id = create_account(conn_tmp, "CLI Invalid", cui="RO_CLI3")
conn_tmp.close()
rc = main(["set-tier", "--account", str(acct_id), "--tier", "diamond"])
assert rc != 0
get_settings.cache_clear()

View File

@@ -224,3 +224,309 @@ def test_get_listare_filtru_status_nu_sparge_scope(client):
assert VIN_A not in vins, ( assert VIN_A not in vins, (
"Filtrul status a scos date din alt cont (scurgere cross-account prin filtru)." "Filtrul status a scos date din alt cont (scurgere cross-account prin filtru)."
) )
# ============================================================================
# PRD 5.17 — enforcement planuri: gate API (T4) + volum (T3) + kill-switch (T5)
# ============================================================================
_PREZ_PLAN = {
"vin": "WVWZZZ1KZAW900001",
"nr_inmatriculare": "B900TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "OP-PLAN-T4", "denumire": "Test plan gate"}],
}
def _set_tier_acct(account_id: int, tier: str, trial_until=None) -> None:
"""Seteaza tier si trial_until pe un cont existent."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
(tier, trial_until, account_id),
)
conn.commit()
finally:
conn.close()
def _insert_n_submissions(account_id: int, n: int) -> None:
"""Insereaza N submissions queued in luna curenta pentru cont."""
from datetime import datetime, timezone
from app.db import get_connection
conn = get_connection()
try:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
for i in range(n):
conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, created_at) "
"VALUES (?, ?, 'queued', '{}', ?)",
(f"plan-seed-{account_id}-{i}-{os.urandom(4).hex()}", account_id, now_str),
)
conn.commit()
finally:
conn.close()
# ---------------------------------------------------------------------------
# T4: Gate API — free/standard NU pot accesa rutele de ingestie API
# ---------------------------------------------------------------------------
def test_free_fara_api_403(client):
"""Cont free (non-default, cu cheie API) → 403 PLAN_FARA_API pe POST /v1/prezentari.
T4 PRD 5.17: gate API refuza conturi cu api_access=False in PLANS.
"""
acct_id, key = _create_account_with_key("FreePlanGate")
_set_tier_acct(acct_id, "free", trial_until=None)
resp = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 403, f"Asteptat 403, primit {resp.status_code}: {resp.text}"
detail = resp.json().get("detail", {})
assert detail.get("cod") == "PLAN_FARA_API", f"Cod eroare gresit: {detail}"
def test_standard_fara_api_403(client):
"""Cont standard (non-default) → 403 PLAN_FARA_API pe POST /v1/prezentari.
Standard are api_access=False, deci gate-ul API respinge la fel ca free.
"""
acct_id, key = _create_account_with_key("StdPlanGate")
_set_tier_acct(acct_id, "standard", trial_until=None)
resp = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 403, f"Asteptat 403, primit {resp.status_code}"
assert resp.json().get("detail", {}).get("cod") == "PLAN_FARA_API"
def test_pro_api_ok(client):
"""Cont pro (non-default) → 200 pe POST /v1/prezentari (trece gate-ul API)."""
acct_id, key = _create_account_with_key("ProPlanGate")
_set_tier_acct(acct_id, "pro", trial_until=None)
resp = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, (
f"Pro trebuie sa aiba acces API, primit: {resp.status_code}: {resp.text}"
)
def test_trial_pro_api_ok(client):
"""Cont free cu trial Pro activ → 200 pe POST /v1/prezentari.
effective_tier() intoarce 'pro' cand trial_until > now, deci gate-ul permite.
"""
from datetime import datetime, timedelta, timezone
acct_id, key = _create_account_with_key("TrialPlanGate")
trial_until = (datetime.now(timezone.utc) + timedelta(days=15)).strftime("%Y-%m-%d %H:%M:%S")
_set_tier_acct(acct_id, "free", trial_until=trial_until)
resp = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, (
f"Trial Pro trebuie sa treaca gate-ul API, primit: {resp.status_code}"
)
def test_dry_run_valideaza_ramane_permis(client):
"""POST /v1/prezentari/valideaza (dry-run) e permis pe orice plan, inclusiv free.
Decizie design (PRD 5.17): valideaza nu face enqueue si nu consuma cota,
deci NU e protejat de gate-ul API — integratorii pot testa fara upgrade.
"""
acct_id, key = _create_account_with_key("FreeValideazaTest")
_set_tier_acct(acct_id, "free", trial_until=None)
resp = client.post(
"/v1/prezentari/valideaza",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, (
f"valideaza trebuie permis pe plan free (fara gate API), "
f"primit {resp.status_code}: {resp.text}"
)
# ---------------------------------------------------------------------------
# T3: Enforce volum — limita 60/luna pe planul Gratuit
# Dev account (id=1) are bypass la gate-ul API, dar NU la verificarea de volum
# ---------------------------------------------------------------------------
def test_free_peste_60_respins_api(client):
"""Dev account (id=1) la 60/60 pe free → a 61-a prezentare respinsa 422 PLAN_LIMITA_LUNARA.
Dev account (id=1) in dev mode (require_api_key=False) are bypass la gate-ul API (T4),
dar NU la verificarea de volum (T3). La 60/60, urmatoarea cerere trebuie respinsa 422.
"""
_set_tier_acct(1, "free", trial_until=None)
_insert_n_submissions(1, 60)
# A 61-a cerere (fara cheie = cont 1 in dev mode, bypass gate API, dar volumul e plin)
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
assert resp.status_code == 422, (
f"Asteptat 422 la depasire volum, primit {resp.status_code}: {resp.text}"
)
detail = resp.json().get("detail", {})
assert detail.get("cod") == "PLAN_LIMITA_LUNARA", f"Cod gresit: {detail}"
def test_eroare_3_niveluri_plan_limita(client):
"""Eroarea PLAN_LIMITA_LUNARA contine toate 3 nivelurile: cod, problema, cauza, fix.
Pattern standard (PRD 5.17): eroare structurata pe 3 niveluri — nu 500, nu catch-all.
"""
_set_tier_acct(1, "free", trial_until=None)
_insert_n_submissions(1, 60)
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
assert resp.status_code == 422
detail = resp.json().get("detail", {})
assert detail.get("cod") == "PLAN_LIMITA_LUNARA", f"Camp 'cod' gresit: {detail}"
assert detail.get("problema"), f"Camp 'problema' lipsa: {detail}"
assert detail.get("cauza"), f"Camp 'cauza' lipsa: {detail}"
assert detail.get("fix"), f"Camp 'fix' lipsa: {detail}"
def test_pro_si_trial_nelimitat(client):
"""Pro si trial Pro nu sunt blocati de volum indiferent de numarul de submissions.
PLANS['pro']['monthly_limit'] is None -> nelimitat; la fel trial Pro activ.
"""
from datetime import datetime, timedelta, timezone
# Cont pro cu 70 submissions (peste limita free de 60)
pro_id, pro_key = _create_account_with_key("ProVolumTest")
_set_tier_acct(pro_id, "pro", trial_until=None)
_insert_n_submissions(pro_id, 70)
# Cont trial Pro cu 70 submissions
trial_id, trial_key = _create_account_with_key("TrialVolumTest")
trial_until = (datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
_set_tier_acct(trial_id, "free", trial_until=trial_until)
_insert_n_submissions(trial_id, 70)
# Pro: trebuie 200 (nu 422 de volum)
r_pro = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": pro_key},
)
assert r_pro.status_code == 200, f"Pro trebuie sa fie nelimitat, primit: {r_pro.text}"
# Trial Pro: trebuie 200 (nu 422 de volum)
prez2 = dict(_PREZ_PLAN, vin="WVWZZZ1KZAW900002", nr_inmatriculare="B900TS2")
r_trial = client.post(
"/v1/prezentari",
json={"prezentari": [prez2]},
headers={"X-API-Key": trial_key},
)
assert r_trial.status_code == 200, f"Trial Pro trebuie sa fie nelimitat, primit: {r_trial.text}"
def test_retry_idempotent_nu_consuma_cota(client):
"""Un retry idempotent al aceleiasi prestatii nu consuma cota de doua ori.
Invariant idempotenta (arhitectura): monthly_usage creste O SINGURA DATA
per submission unic. Retryurile (acelasi idempotency_key) sunt dedup-ate,
deci usage ramine la 1 dupa doua trimiteri identice.
Folsim cod_prestatie "OE-1" (in nomenclatorul seed) ca sa obtinem status
'queued' (statusuri "needs_mapping" nu se numara in monthly_usage).
"""
from datetime import datetime, timezone
from app.plans import monthly_usage
from app.db import get_connection
_set_tier_acct(1, "free", trial_until=None)
# cod_prestatie "OE-1" e in nomenclatorul seed -> submission va fi 'queued' (contat in usage)
prez_unic = {
"vin": "WVWZZZ1KZAW901001",
"nr_inmatriculare": "B901TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
# Prima trimitere — submission noua
r1 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
assert r1.status_code == 200, f"Prima trimitere trebuie sa treaca: {r1.text}"
assert not r1.json()["results"][0].get("deduped"), "Prima trimitere nu trebuia sa fie deduped"
conn = get_connection()
try:
usage_1 = monthly_usage(conn, 1, datetime.now(timezone.utc))
finally:
conn.close()
assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou)
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"
conn = get_connection()
try:
usage_2 = monthly_usage(conn, 1, datetime.now(timezone.utc))
finally:
conn.close()
assert usage_2 == 1, (
f"Retry nu trebuia sa creasca usage: inainte={usage_1}, dupa retry={usage_2}"
)
def test_dev_id1_neblocat(client):
"""Dev account (id=1) in dev mode (require_api_key=False) nu e blocat de gate-ul API.
Bypass explicit in require_api_access: require_api_key=False + account_id==DEFAULT_ACCOUNT_ID
-> skip gate, indiferent de tier. DB proaspata (0 submissions -> fara blocare volum).
"""
_set_tier_acct(1, "free", trial_until=None)
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
assert resp.status_code == 200, (
f"Dev id=1 nu trebuie blocat de gate-ul API in dev mode, "
f"primit {resp.status_code}: {resp.text}"
)
def test_flag_enforce_plans_false_sare_enforcement(client, monkeypatch):
"""Kill-switch AUTOPASS_ENFORCE_PLANS=false sare toate gate-urile de plan.
T5 PRD 5.17: flag de operare pentru debugging sau rollback rapid fara revert de cod.
Chiar si cu free la 60/60, nu trebuie 422 cand flag-ul e oprit.
"""
from app.config import get_settings
monkeypatch.setenv("AUTOPASS_ENFORCE_PLANS", "false")
get_settings.cache_clear()
# Cont dev (id=1) pe free la 60/60 (normalmente respins)
_set_tier_acct(1, "free", trial_until=None)
_insert_n_submissions(1, 60)
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
assert resp.status_code == 200, (
f"Cu enforce_plans=False, enforcement trebuia sarat. Primit: {resp.status_code}"
)
get_settings.cache_clear() # curata cache-ul dupa test

View File

@@ -51,7 +51,8 @@ def test_export_doar_contul_cheii(env):
from app.db import get_connection from app.db import get_connection
conn = get_connection() conn = get_connection()
try: try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") # tier='pro' ca sa treaca gate-ul API (T4 PRD 5.17); testul masoara scoping, nu planuri.
conn.execute("INSERT INTO accounts (id, name, tier) VALUES (2, 'al-doilea', 'pro')")
k1 = create_api_key(conn, 1) k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2) k2 = create_api_key(conn, 2)
finally: finally:

View File

@@ -47,7 +47,9 @@ def test_lista_doar_contul_cheii(env):
from app.db import get_connection from app.db import get_connection
conn = get_connection() conn = get_connection()
try: try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") # tier='pro' pe ambele conturi — testul verifica scoping GET, nu planuri (T4 PRD 5.17).
conn.execute("UPDATE accounts SET tier='pro' WHERE id=1")
conn.execute("INSERT INTO accounts (id, name, tier) VALUES (2, 'al-doilea', 'pro')")
k1 = create_api_key(conn, 1) k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2) k2 = create_api_key(conn, 2)
finally: finally:

359
tests/test_plans.py Normal file
View File

@@ -0,0 +1,359 @@
"""Teste US-001/US-002 (PRD 5.17): app/plans.py — definitia planurilor + helperi tier/consum."""
from __future__ import annotations
import os
import tempfile
from datetime import datetime, timedelta, timezone
import pytest
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_plans.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# PLANS — sursa de adevar
# ---------------------------------------------------------------------------
def test_plan_definitii_free():
from app.plans import PLANS, FREE_MONTHLY_LIMIT
p = PLANS["free"]
assert p["monthly_limit"] == FREE_MONTHLY_LIMIT
assert p["monthly_limit"] == 60
assert p["api_access"] is False
assert p["label"] == "Gratuit"
def test_plan_definitii_standard():
from app.plans import PLANS
p = PLANS["standard"]
assert p["monthly_limit"] is None
assert p["api_access"] is False
assert "label" in p
def test_plan_definitii_pro():
from app.plans import PLANS
p = PLANS["pro"]
assert p["monthly_limit"] is None
assert p["api_access"] is True
assert "label" in p
def test_plan_definitii_premium():
from app.plans import PLANS
p = PLANS["premium"]
assert p["monthly_limit"] is None
assert p["api_access"] is True
assert "label" in p
def test_toate_tierurile_prezente():
from app.plans import PLANS
assert set(PLANS.keys()) == {"free", "standard", "pro", "premium"}
def test_consumed_statuses_exportata():
from app.plans import CONSUMED_STATUSES
assert "queued" in CONSUMED_STATUSES
assert "sending" in CONSUMED_STATUSES
assert "sent" in CONSUMED_STATUSES
# statusuri blocate nu se numara
assert "error" not in CONSUMED_STATUSES
assert "needs_mapping" not in CONSUMED_STATUSES
assert "needs_data" not in CONSUMED_STATUSES
def test_free_monthly_limit_constanta():
"""FREE_MONTHLY_LIMIT e o singura constanta (DRY), referita din PLANS."""
from app.plans import FREE_MONTHLY_LIMIT, PLANS
assert isinstance(FREE_MONTHLY_LIMIT, int)
assert FREE_MONTHLY_LIMIT == 60
# PLANS["free"]["monthly_limit"] refera aceeasi valoare (nu hardcodat separat)
assert PLANS["free"]["monthly_limit"] == FREE_MONTHLY_LIMIT
# ---------------------------------------------------------------------------
# effective_tier
# ---------------------------------------------------------------------------
def _now_utc():
return datetime.now(timezone.utc)
def test_effective_tier_trial_activ_returneaza_pro():
from app.plans import effective_tier
now = _now_utc()
trial_until = (now + timedelta(days=15)).strftime("%Y-%m-%d %H:%M:%S")
account = {"tier": "free", "trial_until": trial_until}
assert effective_tier(account, now) == "pro"
def test_effective_tier_trial_expirat_returneaza_tier_baza():
from app.plans import effective_tier
now = _now_utc()
trial_until = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
account = {"tier": "free", "trial_until": trial_until}
assert effective_tier(account, now) == "free"
def test_effective_tier_fara_trial_returneaza_tier():
from app.plans import effective_tier
now = _now_utc()
account = {"tier": "standard", "trial_until": None}
assert effective_tier(account, now) == "standard"
def test_effective_tier_plan_platit_nu_downgradat_de_trial_expirat():
"""Un cont pro setat de admin NU e downgradat de expirarea trial-ului."""
from app.plans import effective_tier
now = _now_utc()
# tier=pro, trial_until in trecut: downgrade nu se produce (pro > free)
trial_until = (now - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
account = {"tier": "pro", "trial_until": trial_until}
# tier de baza e pro, deci effective = pro (nu se coboara la free)
assert effective_tier(account, now) == "pro"
def test_effective_tier_trial_malformat_fallback_defensiv():
from app.plans import effective_tier
now = _now_utc()
account = {"tier": "free", "trial_until": "nu-e-o-data-valida"}
# malformat -> fallback la tier de baza, fara exceptie
assert effective_tier(account, now) == "free"
def test_effective_tier_trial_null_fallback():
from app.plans import effective_tier
now = _now_utc()
account = {"tier": "free", "trial_until": None}
assert effective_tier(account, now) == "free"
def test_effective_tier_injectat_determinist():
"""now injectabil: putem simula orice moment — teste deterministe fara datetime.now()."""
from app.plans import effective_tier
# trial_until fix
trial_until = "2026-07-10 12:00:00"
account = {"tier": "free", "trial_until": trial_until}
# inainte de expirare
now_before = datetime(2026, 7, 5, 12, 0, 0, tzinfo=timezone.utc)
assert effective_tier(account, now_before) == "pro"
# dupa expirare
now_after = datetime(2026, 7, 15, 12, 0, 0, tzinfo=timezone.utc)
assert effective_tier(account, now_after) == "free"
def test_effective_tier_premium_cu_trial_pro():
"""premium are api_access=True oricum; trial_until viitor nu strica."""
from app.plans import effective_tier
now = _now_utc()
trial_until = (now + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
account = {"tier": "premium", "trial_until": trial_until}
# trial activ -> 'pro', dar premium e oricum superior (nu ne intereseaza downgrade)
# functia intoarce 'pro' cand trial e activ; consumatorul vede pro (care are api_access)
assert effective_tier(account, now) == "pro"
# ---------------------------------------------------------------------------
# monthly_usage
# ---------------------------------------------------------------------------
def _uid():
"""Cheie idempotenta unica per apel (pentru INSERT in teste)."""
import binascii
return binascii.hexlify(os.urandom(8)).decode()
def _insert_submission(conn, account_id, status, created_at_str):
"""Insereaza o submisie de test cu timestamp explicit."""
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, created_at) "
"VALUES (?, ?, ?, '{}', ?)",
(_uid(), account_id, status, created_at_str),
)
def test_consum_lunar_numara_consumed_statuses(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Test Consum", cui="RO1001")
# 3 statusuri consumate
_insert_submission(conn, account_id, "queued", now_str)
_insert_submission(conn, account_id, "sending", now_str)
_insert_submission(conn, account_id, "sent", now_str)
assert monthly_usage(conn, account_id, now) == 3
def test_consum_lunar_exclude_statusuri_blocate(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Test Blocat", cui="RO1002")
# statusuri care NU se numara
for status in ("error", "needs_mapping", "needs_data"):
_insert_submission(conn, account_id, status, now_str)
assert monthly_usage(conn, account_id, now) == 0
def test_consum_lunar_scoped_pe_cont(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
acct_a = create_account(conn, "Cont A", cui="RO1003")
acct_b = create_account(conn, "Cont B", cui="RO1004")
_insert_submission(conn, acct_a, "sent", now_str)
_insert_submission(conn, acct_a, "sent", now_str)
_insert_submission(conn, acct_b, "sent", now_str)
assert monthly_usage(conn, acct_a, now) == 2
assert monthly_usage(conn, acct_b, now) == 1
def test_consum_lunar_luna_trecuta_nu_se_numara(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
account_id = create_account(conn, "Test Luna Trecuta", cui="RO1005")
# Calculam o data din luna trecuta (prima zi a lunii curente - 1 zi)
first_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
last_of_prev_month = first_of_month - timedelta(days=1)
prev_str = last_of_prev_month.strftime("%Y-%m-%d %H:%M:%S")
_insert_submission(conn, account_id, "sent", prev_str)
# luna curenta: 0
assert monthly_usage(conn, account_id, now) == 0
def test_consum_lunar_granita_luna_noua(conn):
"""Submisii la granita intre luni sunt bucketate corect (timp local RO = UTC in container)."""
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Test Granita", cui="RO1006")
# Prima secunda a lunii curente (calculata consistent cu 'localtime' = UTC in container)
first_of_month = now.replace(day=1, hour=0, minute=0, second=1, microsecond=0)
first_str = first_of_month.strftime("%Y-%m-%d %H:%M:%S")
# Ultima secunda a lunii trecute
last_of_prev_month = first_of_month - timedelta(seconds=2)
prev_str = last_of_prev_month.strftime("%Y-%m-%d %H:%M:%S")
_insert_submission(conn, account_id, "sent", first_str) # luna curenta
_insert_submission(conn, account_id, "sent", prev_str) # luna trecuta
_insert_submission(conn, account_id, "sent", now_str) # luna curenta
assert monthly_usage(conn, account_id, now) == 2
def test_consum_lunar_zero_pe_cont_gol(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
account_id = create_account(conn, "Cont Gol", cui="RO1007")
assert monthly_usage(conn, account_id, now) == 0
def test_consum_lunar_nu_numara_cross_account(conn):
"""Verificare scoping: contul default (id=1) nu influenteaza alt cont."""
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Cont Izolat", cui="RO1008")
# Inseram pentru contul default (id=1)
_insert_submission(conn, 1, "sent", now_str)
_insert_submission(conn, 1, "sent", now_str)
# Contul nou nu trebuie sa numere al celor de pe id=1
assert monthly_usage(conn, account_id, now) == 0
assert monthly_usage(conn, 1, now) == 2
# ---------------------------------------------------------------------------
# PRD 5.17 enforcement — logica de limita + kill-switch config
# ---------------------------------------------------------------------------
def test_volume_la_limita_exacta(conn):
"""La exact FREE_MONTHLY_LIMIT submissions, usage == limita (nu inca depasit).
Enforcer-ul verifica usage + nr_cerut > limit, deci la usage=60, nr_cerut=1 ->
61 > 60 -> respins; dar usage=60 in sine (inainte de cerere) e valid.
"""
from app.plans import monthly_usage, FREE_MONTHLY_LIMIT
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Test La Limita", cui="RO2001")
for _ in range(FREE_MONTHLY_LIMIT):
_insert_submission(conn, account_id, "queued", now_str)
conn.commit()
usage = monthly_usage(conn, account_id, now)
assert usage == FREE_MONTHLY_LIMIT, (
f"La limita exacta: asteptat {FREE_MONTHLY_LIMIT}, primit {usage}"
)
# Simulam logica enforcer: 1 cerere noua depaseste limita
assert usage + 1 > FREE_MONTHLY_LIMIT, "O cerere noua trebuia sa depaseasca limita"
# La 0 cereri noi: nu depaseste
assert usage + 0 <= FREE_MONTHLY_LIMIT, "La 0 cereri noi, limita nu e depasita"
def test_enforce_plans_config_default_true(monkeypatch):
"""AUTOPASS_ENFORCE_PLANS implicit True — enforcement activ de la deploy.
Decizie user (autoplan 2026-06-28): nu exista conturi legacy, produs in TESTE,
enforcement DUR activ implicit. Kill-switch oprit explicit cand e necesar.
"""
from app.config import Settings
# Creem Settings fresh (fara env var setata) -> default True
monkeypatch.delenv("AUTOPASS_ENFORCE_PLANS", raising=False)
s = Settings()
assert s.enforce_plans is True, (
"AUTOPASS_ENFORCE_PLANS trebuia sa fie True implicit (enforcement activ din start)"
)
def test_enforce_plans_kill_switch_false(monkeypatch):
"""AUTOPASS_ENFORCE_PLANS=false dezactiveaza enforcement."""
from app.config import Settings
monkeypatch.setenv("AUTOPASS_ENFORCE_PLANS", "false")
s = Settings()
assert s.enforce_plans is False

View File

@@ -148,6 +148,8 @@ def test_prod_requires_key(env):
conn = get_connection() conn = get_connection()
try: try:
key = create_api_key(conn, 1) key = create_api_key(conn, 1)
# Testul verifica autentificarea, nu planul — tier='pro' ca sa treaca gate-ul API (T4).
conn.execute("UPDATE accounts SET tier='pro' WHERE id=1")
finally: finally:
conn.close() conn.close()
r2 = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": key}) r2 = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": key})
@@ -184,7 +186,9 @@ def test_key_account_routes_idempotency(env):
conn = get_connection() conn = get_connection()
try: try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") # tier='pro' pe ambele conturi — testul verifica idempotenta, nu planuri (T4 PRD 5.17).
conn.execute("UPDATE accounts SET tier='pro' WHERE id=1")
conn.execute("INSERT INTO accounts (id, name, tier) VALUES (2, 'al-doilea', 'pro')")
k1 = create_api_key(conn, 1) k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2) k2 = create_api_key(conn, 2)
finally: finally:

View File

@@ -370,3 +370,84 @@ def test_token_critic_in_tema_parametrizat(client, tema, token):
f"Componentele cu var({token}) vor arata gresit pe aceasta tema. " f"Componentele cu var({token}) vor arata gresit pe aceasta tema. "
f"Adauga '{token}:<valoare>;' in blocul CSS al temei '{tema}'." f"Adauga '{token}:<valoare>;' in blocul CSS al temei '{tema}'."
) )
# ── US-001 PRD 5.16: Stiva font sistem standard web ───────────────────────────
def test_font_stack_system_in_base(client):
"""T-E2 (PRD 5.16): base.html DEFINESTE --font-ui si --font-mono in :root
si body foloseste var(--font-ui). Niciun @font-face IBM Plex nu mai exista."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "--font-ui" in html, "Token --font-ui lipseste din :root (US-001 PRD 5.16)"
assert "--font-mono" in html, "Token --font-mono lipseste din :root (US-001 PRD 5.16)"
assert "var(--font-ui)" in html, "body nu foloseste var(--font-ui) (US-001 PRD 5.16)"
assert "@font-face" not in html, \
"@font-face inca prezent in base.html — sterge toate regulile IBM Plex (US-001 PRD 5.16)"
def test_zero_referinte_static_fonts(client):
"""T-E1 (PRD 5.16): nicio referinta /static/fonts/ in template-urile randate de app.
Toate literalele 'IBM Plex Sans' si 'IBM Plex Mono' sunt eliminate."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "/static/fonts/" not in html, \
"Referinta /static/fonts/ inca prezenta in HTML randat — @font-face nestersi complet"
assert "IBM Plex Sans" not in html, \
"Literalul 'IBM Plex Sans' inca prezent in HTML — inlocuieste cu var(--font-ui)"
assert "IBM Plex Mono" not in html, \
"Literalul 'IBM Plex Mono' inca prezent in HTML — inlocuieste cu var(--font-mono)"
def test_landing_fara_font_face_ibm_plex():
"""T-E1 (PRD 5.16): landing.html nu contine @font-face IBM Plex si niciun
literal 'IBM Plex Sans' sau 'IBM Plex Mono' ca font primary."""
landing = Path(__file__).parent.parent / "app" / "web" / "templates" / "landing.html"
assert landing.exists(), f"landing.html negasit la {landing}"
content = landing.read_text(encoding="utf-8")
assert "@font-face" not in content, \
"@font-face inca in landing.html — sterge toate regulile IBM Plex (US-008 PRD 5.16)"
assert "IBM Plex Sans" not in content, \
"Literal 'IBM Plex Sans' inca in landing.html — inlocuieste cu var(--font-ui)"
assert "IBM Plex Mono" not in content, \
"Literal 'IBM Plex Mono' inca in landing.html — inlocuieste cu var(--font-mono)"
# ── US-002 PRD 5.16: Scala tipografica ────────────────────────────────────────
def test_tokeni_scala_fs_definiti(client):
"""US-002 (PRD 5.16): tokenurile de scala tipografica --fs-xs..--fs-3xl si
--lh-tight/--lh-body sunt definiti in :root."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
tokeni = [
"--fs-xs", "--fs-sm", "--fs-base", "--fs-md",
"--fs-lg", "--fs-xl", "--fs-2xl", "--fs-3xl",
"--lh-tight", "--lh-body",
]
for tok in tokeni:
assert tok in html, f"Token {tok} lipseste din :root (US-002 PRD 5.16)"
# ── US-011 PRD 5.16: Selector tema pill cu eticheta ───────────────────────────
def test_selector_tema_are_eticheta(client):
"""US-011 (PRD 5.16): butonul de tema este un pill cu clasa .tema-btn,
contine .tema-icon si #tema-label (eticheta vizibila a temei curente)."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "tema-btn" in html, \
"Clasa .tema-btn lipseste din HTML — butonul de tema nu e pill (US-011 PRD 5.16)"
assert "tema-icon" in html, \
".tema-icon lipseste — iconita temei nu e separat de eticheta (US-011 PRD 5.16)"
assert 'id="tema-label"' in html, \
'#tema-label lipseste — eticheta temei nu e prezenta in pill (US-011 PRD 5.16)'

View File

@@ -544,3 +544,316 @@ def test_repune_select_afiseaza_denumirea(client):
assert "AAA — Schimb ulei motor" in html, ( assert "AAA — Schimb ulei motor" in html, (
f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}" f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}"
) )
# ============================================================================= #
# Teste noi 5.16: US-004 (denumiri picker), US-005 (add_extra), #
# US-006 (save picker fara buton), T-E3 (by-index), T-D1/T-E5, T-C1/T-E4 #
# ============================================================================= #
def test_picker_flat_arata_cod_si_denumire(client):
"""US-004 (5.16): picker plat afiseaza 'cod — denumire', nu doar codul.
RED: _chips_prestatii.html:147 afiseaza doar {{ n.cod_prestatie }};
modul operatii (:101) afiseaza deja 'cod — nume'. Fix: uniformizare.
"""
acct = _create_account_user("picker.flat.denu@test.com")
_login(client, "picker.flat.denu@test.com")
_seed_cod("FRN1", "Sistem de franare")
# Submission flat: fara cod_op_service (mod plat)
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0US4001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [], # mod plat: fara operatii cu cod_op_service
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
# Optiunea trebuie sa arate 'FRN1 — Sistem de franare', nu doar 'FRN1'
assert "FRN1 — Sistem de franare" in resp.text, (
f"Picker plat nu arata denumirea: "
f"{resp.text[resp.text.find('FRN1'):resp.text.find('FRN1')+80] if 'FRN1' in resp.text else 'FRN1 absent'}"
)
def test_adauga_cod_extra_in_mod_operatii(client):
"""US-005 (5.16): in mod operatii, actiunea add_extra adauga un cod RAR liber.
RED: post_form_chips nu are actiunea 'add_extra' -> chips_action ignorata.
"""
acct = _create_account_user("add.extra.ops@test.com")
_login(client, "add.extra.ops@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
# Chips stare: 1 operatie deja mapata (mod ops) → _has_ops = True
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"], # chip existent (op mapata)
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei motor"],
"chips_action": "add_extra",
"chips_add_cod_flat": "FRN1", # codul extra de adaugat
},
)
assert resp.status_code == 200, resp.text[:300]
# FRN1 trebuie sa apara in raspuns (chip extra adaugat)
assert "FRN1" in resp.text, (
f"Codul extra FRN1 nu a fost adaugat in mod operatii: {resp.text[:300]}"
)
# OE-1 trebuie sa ramana (chip original neatins)
assert "OE-1" in resp.text, f"Chip original OE-1 disparut: {resp.text[:300]}"
def test_extra_cod_persistat_la_salvare(client):
"""US-005 (5.16): codul extra adaugat via form-chips e salvat la /corecteaza.
Simulam starea form dupa add_extra: hidden inputs pentru op mapata (OE-1)
+ hidden inputs pentru chip extra flat (FRN1, fara op_service).
"""
acct = _create_account_user("extra.persist@test.com")
_login(client, "extra.persist@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0XP001",
[("SchimbUlei", "Schimb ulei motor")],
))
csrf = _csrf(client)
# Form state dupa add_extra: op mapata (idx=0, OE-1) + chip extra flat (idx=1, FRN1)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "FRN1"], # OE-1 pt op, FRN1 chip extra
"chip_op_service": ["SchimbUlei", ""], # idx 0 are op_service, idx 1 nu
"chip_denumire": ["Schimb ulei motor", ""],
},
)
assert resp.status_code == 200, resp.text[:300]
r = _row(sid)
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 (op mapata) lipsa: {prestatii}"
assert "FRN1" in coduri, f"FRN1 (chip extra) lipsa: {prestatii}"
def test_extra_cod_validat_nomenclator(client):
"""US-005 (5.16): add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899).
RED: actiunea add_extra nu exista; dupa fix, cod invalid nu se adauga.
"""
acct = _create_account_user("extra.valid@test.com")
_login(client, "extra.valid@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# add_extra cu cod INVALID (XX-99 nu e in nomenclator)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "XX-99", # cod necunoscut
},
)
assert resp.status_code == 200
html = resp.text
# XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99)
import re as _re
hidden_xx99 = _re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="XX-99"', html)
assert hidden_xx99 is None, (
f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}"
)
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
"""US-006 (5.16): codul ales in picker flat se aplica la /corecteaza fara a apasa '+'.
RED: post_corectie_trimitere citeste form.getlist('cod_prestatie') (hidden inputs)
dar ignora 'chips_add_cod_flat' (picker neselectat ca chip) → submission ramane
needs_mapping desi codul e ales.
"""
acct = _create_account_user("picker.save.nobutton@test.com")
_login(client, "picker.save.nobutton@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
# Submission flat fara prestatii
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0PS001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
csrf = _csrf(client)
# Browser trimite chips_add_cod_flat=OE-1 (ales in picker) dar FARA hidden cod_prestatie
# (userul nu a apasat '+' sa promoveze selectia intr-un chip).
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"chips_add_cod_flat": "OE-1", # ales in picker, ne-aprobat prin '+'
# NU exista 'cod_prestatie' in form (zero hidden chips)
},
)
assert resp.status_code == 200, resp.text[:300]
r = _row(sid)
assert r["status"] == "queued", (
f"Codul ales in picker trebuia sa se aplice la salvare fara '+': status={r['status']}"
)
prestatii = _payload_json(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 (ales in picker) lipsa din prestatii: {prestatii}"
def test_salvare_fara_chip_explicit_nu_e_no_op(client):
"""US-006 (5.16): o trimitere needs_mapping cu cod ales in picker nu ramane no-op.
Complementar cu test_cod_ales_in_picker_se_salveaza_fara_buton_add: verifica
explicit ca statusul se schimba (nu ramane needs_mapping).
"""
acct = _create_account_user("noop.previne@test.com")
_login(client, "noop.previne@test.com")
_seed_cod("FRN1", "Sistem de franare")
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0NP001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
old_status = _row(sid)["status"]
assert old_status == "needs_mapping"
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"chips_add_cod_flat": "FRN1",
},
)
assert resp.status_code == 200
new_status = _row(sid)["status"]
assert new_status != "needs_mapping", (
f"Salvarea cu cod ales in picker trebuia sa nu fie no-op: status ramas {new_status}"
)
assert new_status == "queued", f"status asteptat queued, got {new_status}"
def test_picker_by_index_op2_nu_op1(client):
"""T-E3 (5.16): codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1.
Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1
actualizeaza chips[1] (op#2), nu chips[0] (op#1).
"""
acct = _create_account_user("byindex.op2@test.com")
_login(client, "byindex.op2@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
# Chips: op#1 (idx=0) deja mapata cu OE-1, op#2 (idx=1) nemapata (cod gol)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata)
"chip_op_service": ["Op-A", "Op-B"],
"chip_denumire": ["Prima", "A doua"],
"chips_action": "add",
"chips_add_op_index": "1", # adauga pe op#2 (idx=1)
"chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1
},
)
assert resp.status_code == 200, resp.text[:300]
html = resp.text
import re as _re
hidden_vals = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="([^"]*)"', html)
assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}"
assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}"
# By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1)
oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1
frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1
assert oe1_pos < frn1_pos, (
f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}"
)
def test_empty_state_picker_nomenclator_gol(client):
"""T-D1/T-E5 (5.16): empty-state vizibil cand nomenclatorul e gol.
RED: {% if nomenclator_rar %} fara {% else %} -> silentios; un rand needs_mapping
fara nomenclator nu are nicio cale de a adauga cod (nereparabil silentios).
GREEN: div.chips-nom-gol vizibil.
"""
acct = _create_account_user("empty.nom@test.com")
_login(client, "empty.nom@test.com")
# Golim nomenclatorul: seed_nomenclator_if_empty populeaza la initializare DB;
# testul simuleaza cazul extrem cand tabla e goala (post-update, inainte de re-seed).
from app.db import get_connection as _gconn
_c = _gconn()
_c.execute("DELETE FROM nomenclator_rar")
_c.commit()
_c.close()
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0EN001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
assert "chips-nom-gol" in resp.text, (
f"Empty state 'chips-nom-gol' lipsa cand nomenclatorul e gol: {resp.text[resp.text.find('chips'):resp.text.find('chips')+200] if 'chips' in resp.text else resp.text[:500]}"
)
def test_add_extra_semnal_vizibil_cod_invalid(client):
"""T-C1/T-E4 (5.16): add_extra cu cod invalid da semnal vizibil (nu esua silentios).
RED: actiunea add_extra nu exista → nu exista niciun semnal.
GREEN: div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
"""
acct = _create_account_user("extra.err.signal@test.com")
_login(client, "extra.err.signal@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# add_extra cu cod necunoscut in nomenclator
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "XX-99", # cod inexistent
},
)
assert resp.status_code == 200
assert "chips-extra-error" in resp.text, (
f"Semnalul 'chips-extra-error' lipsa pentru cod invalid: {resp.text[:300]}"
)

View File

@@ -0,0 +1,195 @@
"""Teste E2E enforcement plan pe canalul web de import (PRD 5.17 T3).
Verifica ca limita de volum (60/luna free) e respectata si pe canalul web
(web_confirma_import in routes.py), nu doar pe canalul API.
"""
from __future__ import annotations
import csv
import io
import os
import re
import tempfile
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixture client web izolat (WEB_AUTH_REQUIRED=false -> fara login, cont 1)
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
"""Client cu DB izolata, WEB_AUTH_REQUIRED=false (dev — fara login necesar)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "web-e2e-plan.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
# ---------------------------------------------------------------------------
# Utilitare
# ---------------------------------------------------------------------------
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _seed_nomenclator_si_mapare(cod_prestatie: str = "OE-1", cod_op: str = "OP-WEB-PLAN") -> None:
"""Semeaza nomenclatorul RAR si o mapare operatie->cod (necesare pentru randuri ok)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?,?)",
(cod_prestatie, "Operatie test plan"),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (1,?,?,1)",
(cod_op, cod_prestatie),
)
conn.commit()
finally:
conn.close()
def _insert_60_submissions_luna() -> None:
"""Insereaza 60 submissions queued in luna curenta pentru contul 1 (la limita free)."""
from app.db import get_connection
conn = get_connection()
try:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
for i in range(60):
conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, created_at) "
"VALUES (?, 1, 'queued', '{}', ?)",
(f"web-vol60-{i}-{os.urandom(4).hex()}", now_str),
)
conn.commit()
finally:
conn.close()
def _upload_preview_si_commit(client: TestClient, rows: list[dict]): # type: ignore[return]
"""Parcurge fluxul web: upload -> mapare coloane (daca e necesar) -> confirma.
Intoarce (import_id, raspuns_confirma). Presupune nomenclatorul si maparea semanate.
"""
data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("plan_test.csv", io.BytesIO(data), "text/csv")},
)
assert r.status_code == 200, f"Upload esuat: {r.text[:300]}"
m = re.search(r"/_import/(\d+)/", r.text)
assert m, f"import_id negasit in raspunsul de upload: {r.text[:400]}"
iid = int(m.group(1))
if f"/_import/{iid}/mapare-coloane" in r.text:
r2 = client.post(
f"/_import/{iid}/mapare-coloane",
data={
"colname": ["VIN", "Nr", "Data", "KM", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "YYYY-MM-DD",
},
)
assert r2.status_code == 200, f"Mapare coloane esuata: {r2.text[:300]}"
# GET preview pentru n_ok
rp = client.get(f"/_import/{iid}/preview")
assert rp.status_code == 200, f"Preview esuat: {rp.text[:300]}"
m_ok = re.search(r'id="n-confirmat"[^>]*?value="(\d+)"', rp.text)
n_ok = int(m_ok.group(1)) if m_ok else len(rows)
r_conf = client.post(
f"/_import/{iid}/confirma",
data={
"csrf_token": "",
"n_confirmat": str(n_ok),
"confirmed_by": "test-plan@autopass.ro",
},
)
return iid, r_conf
# Date CSV: un singur rand ok
_ROWS_PLAN_WEB = [
{
"VIN": "WVWZZZ1KZAW700001",
"Nr": "B700TST",
"Data": "2026-06-15",
"KM": "70000",
"Operatie": "OP-WEB-PLAN",
},
]
# ---------------------------------------------------------------------------
# Test T3 — volum pe canalul web
# ---------------------------------------------------------------------------
def test_free_peste_60_respins_import_web(client):
"""Canal WEB de import: free la 60/60 → commit respins cu mesaj de limita plan.
T3 PRD 5.17: enforcement volum pe canalul web (web_confirma_import in routes.py).
Contul 1 e pe tier=free, fara trial, la 60/60 prestatii in luna curenta.
Commit-ul unui lot nou trebuie respins (intregul lot, nu partial) cu mesaj clar.
"""
from app.db import get_connection
# Seteaza contul 1 (implicit web in dev mode) pe free fara trial
conn = get_connection()
try:
conn.execute("UPDATE accounts SET tier='free', trial_until=NULL WHERE id=1")
conn.commit()
finally:
conn.close()
# Seed nomenclator si mapare operatie->cod
_seed_nomenclator_si_mapare()
# Insereaza 60 submissions (la limita)
_insert_60_submissions_luna()
# Parcurge fluxul web pana la commit
_iid, r_conf = _upload_preview_si_commit(client, _ROWS_PLAN_WEB)
assert r_conf.status_code == 200, ( # type: ignore[union-attr]
f"Commit trebuia sa intoarca 200 HTML (nu 5xx): {r_conf.status_code}" # type: ignore[union-attr]
)
# Raspunsul HTML trebuie sa contina mesajul de limita de plan
html = r_conf.text.lower() # type: ignore[union-attr]
assert ("limita" in html or "gratuit" in html or "60" in html), (
f"Mesajul de limita plan lipseste din raspunsul HTML al commit-ului:\n{r_conf.text[:600]}" # type: ignore[union-attr]
)
# Verifica ca nu s-au creat submissions noi (lotul a fost respins total)
from app.plans import monthly_usage
conn2 = get_connection()
try:
usage = monthly_usage(conn2, 1, datetime.now(timezone.utc))
finally:
conn2.close()
assert usage == 60, (
f"Lotul respins nu trebuia sa adauge submissions: asteptat usage=60, primit {usage}"
)

View File

@@ -291,3 +291,94 @@ def test_import_forms_pastreaza_csrf(client):
if "mapare-coloane" in text_map: # s-a primit fragmentul de mapare if "mapare-coloane" in text_map: # s-a primit fragmentul de mapare
assert 'name="csrf_token"' in text_map, \ assert 'name="csrf_token"' in text_map, \
"name='csrf_token' nu a fost gasit in formularul mapare-coloane" "name='csrf_token' nu a fost gasit in formularul mapare-coloane"
# ---------------------------------------------------------------------------
# US-013 Teste: import colapsat + tokeni scala + pill-uri cu dot (PRD 5.16)
# ---------------------------------------------------------------------------
def test_import_colapsat_implicit(client):
"""Pe Acasa (first-run, fara trimiteri), sectiunea de import e deschisa implicit.
La first-run (are_trimiteri=False), <details> trebuie sa aiba atributul `open`.
Summary-ul trebuie sa contina textul slim 'Importa fisier' (bara colapsabila).
Verifica si ca <details id="import-details"> este prezent pe pagina principala.
"""
r = client.get("/")
assert r.status_code == 200
text = r.text
# Elementul <details> trebuie sa fie prezent
assert 'id="import-details"' in text, \
"Elementul <details id='import-details'> lipseste de pe pagina principala"
# La first-run (nu exista trimiteri), details trebuie sa fie deschis (atribut open)
assert 'id="import-details" open' in text, \
"La first-run, <details id='import-details'> trebuie sa aiba atributul 'open'"
# Textul summary trebuie sa contina 'Importa fisier' (bara slim colapsabila)
assert "Importa fisier" in text, \
"Textul 'Importa fisier' nu a fost gasit in summary-ul sectiunii de import"
def test_wizard_foloseste_scala_tokeni(client):
"""Fragmentele wizard-ului de import folosesc tokeni var(--fs-*) in loc de px hardcodat.
Verifica ca fragmentul de mapare coloane (_mapcoloane.html) si cel de upload
(_upload.html) contin referinte la tokenii de scala --fs-* in inline styles,
nu font-size hardcodat in px sub 12px.
"""
# Fragment upload (/_import/reset) → _upload.html
r_upload = client.get("/_import/reset")
assert r_upload.status_code == 200
upload_text = r_upload.text
# Tokenii trebuie sa apara in inline styles
assert "var(--fs-" in upload_text, \
"Tokenii var(--fs-*) nu au fost gasiti in fragmentul de upload (_upload.html)"
# Fragment mapare coloane → _mapcoloane.html
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
r_map = client.post(
"/_import/upload",
files={"file": ("test.csv", csv_bytes, "text/csv")},
)
assert r_map.status_code == 200
map_text = r_map.text
# Mapcoloane trebuie sa contina tokeni
assert "var(--fs-" in map_text, \
"Tokenii var(--fs-*) nu au fost gasiti in fragmentul mapare coloane (_mapcoloane.html)"
# Verifica ca nu exista font-size sub 12px hardcodat in fragmentele wizard
import re
for fragment_text, fragment_name in [(upload_text, "upload"), (map_text, "mapcoloane")]:
for size_str in re.findall(r'font-size:\s*(\d+)px', fragment_text):
size = int(size_str)
assert size >= 12, \
f"font-size:{size}px sub 12px gasit in fragmentul {fragment_name} — trebuie var(--fs-*)"
def test_preview_stari_pill_dot(client):
"""Pill-urile de stare din preview contin un dot consistent cu designul 5.16.
Verifica ca pill-urile din tabelul de preview si din rezumatul de stari contin
un element dot (span cu border-radius:99px ca inline style), consistent cu stripul
slim si cu designul 5.16 (dot + text, nu text gol).
Eticheta umana: din STARI_PREVIEW ('Gata de trimis', 'Cod RAR lipsa' etc.) — nicio
eticheta noua.
"""
_seed_op_mapping(client)
import_id = _upload_and_get_import_id(client)
text = _get_preview_via_mapare(client, import_id)
# Preview trebuie sa fie prezent
assert "confirm-form" in text or "Preview" in text, \
"Fragmentul de preview nu a fost randat"
# Pill-urile de stare trebuie sa contina un dot (span cu border-radius:99px)
assert "border-radius:99px" in text, \
"Dot-ul (border-radius:99px) nu a fost gasit in pill-urile de stare din preview"
# Etichetele umane din STARI_PREVIEW trebuie sa fie prezente (nicio eticheta noua)
# 'Gata de trimis' apare in rezumatul de stari (pill) sau in tabelul de randuri
assert "Gata de trimis" in text or "Cod RAR lipsa" in text or "Verifica valori" in text, \
"Etichetele umane din STARI_PREVIEW nu au fost gasite in preview"

View File

@@ -224,8 +224,8 @@ def test_logo_linkeaza_acasa(client):
"In prezent logo-ul nu e un link." "In prezent logo-ul nu e un link."
) )
# Titlul "Gateway RAR AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/"> # Titlul "ROMFAST AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
# (PRD AC: Logo-ul ROMFAST + titlul linkeaza la /) # (PRD AC US-010: Logo-ul ROMFAST + titlul linkeaza la /; titlul a fost redenumit in 5.16)
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?Gateway RAR AUTOPASS', header_html, re.DOTALL), ( assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?ROMFAST AUTOPASS', header_html, re.DOTALL), (
"Titlul 'Gateway RAR AUTOPASS' trebuie sa fie intr-un <a href='/'> in header." "Titlul 'ROMFAST AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
) )

View File

@@ -738,3 +738,141 @@ def test_strip_sanatate_fara_hex_hardcodat():
f"Hex literal de culoare in _status.html — strip sanatate va arata gresit pe " f"Hex literal de culoare in _status.html — strip sanatate va arata gresit pe "
f"tema hartie (luminoasa) / light. Folositi var(--token). Gasite: {hex_in_culori}" f"tema hartie (luminoasa) / light. Folositi var(--token). Gasite: {hex_in_culori}"
) )
# ============================================================
# PRD 5.16 US-010: Titlu ROMFAST AUTOPASS + account_name in antet
# ============================================================
def test_titlu_romfast_autopass(client):
"""US-010 (PRD 5.16): titlul din antet si tag-ul <title> sunt 'ROMFAST AUTOPASS',
nu 'Gateway RAR AUTOPASS'."""
_create_account_user("titlutest@test.com", name="Service Titlu")
_login(client, "titlutest@test.com")
html = client.get("/?tab=acasa").text
assert "ROMFAST AUTOPASS" in html, \
"Titlul 'ROMFAST AUTOPASS' lipseste din antet (US-010 PRD 5.16)"
assert "Gateway RAR AUTOPASS" not in html, \
"Titlul vechi 'Gateway RAR AUTOPASS' inca prezent — inlocuieste cu 'ROMFAST AUTOPASS'"
def test_header_arata_nume_service_logat(client):
"""US-010 (PRD 5.16): cand utilizatorul e logat, antetul afiseaza numele service-ului
(accounts.name) ca sub-titlu cu clasa .h-sub."""
_create_account_user("numeservice@test.com", name="Service Auto Cluj SRL")
_login(client, "numeservice@test.com")
html = client.get("/?tab=acasa").text
assert "Service Auto Cluj SRL" in html, \
"Numele service-ului nu apare in antet (US-010 PRD 5.16) — verifica .h-sub"
assert "h-sub" in html, \
"Clasa .h-sub lipseste din antet (US-010 PRD 5.16) — sub-titlul account_name lipseste"
def test_login_branded_nu_schelet(client):
"""US-010 (PRD 5.16): /login are layout 2-coloane branduit cu clasa .login-shell,
titlul 'ROMFAST AUTOPASS', si formular cu POST /login (CSRF intact)."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "login-shell" in html, \
"Clasa .login-shell lipseste din /login (US-010 PRD 5.16) — layout 2-coloane nenimplementat"
assert "login-aside" in html, \
"Clasa .login-aside lipseste — coloana stanga de brand lipseste (US-010)"
assert "ROMFAST AUTOPASS" in html, \
"Titlul 'ROMFAST AUTOPASS' lipseste din /login (US-010 PRD 5.16)"
# Formular intact: POST /login cu csrf_token
assert 'action="/login"' in html, "Actiunea formularului /login s-a schimbat — CSRF route invalida"
assert 'name="csrf_token"' in html, "csrf_token lipseste din formular — securitate compromisa"
assert 'name="parola"' in html, "Campul 'parola' lipseste din formular"
# ============================================================
# PRD 5.17 T7 (US-007): landing copy — limita 60 + trial Pro
# PRD 5.16 US-012: Autentificare → /login
# ============================================================
def _citeste_landing() -> str:
"""Returneaza continutul landing.html (template static; variabilele Jinja2 nu
afecteaza copy-ul de limita/plan/buton verificat mai jos)."""
from pathlib import Path
p = Path(__file__).parent.parent / "app" / "web" / "templates" / "landing.html"
assert p.exists(), f"landing.html negasit la {p}"
return p.read_text(encoding="utf-8")
def test_landing_limita_60():
"""5.17 T7 (US-007): limita planului Gratuit este 60 de prestatii/luna in landing,
nu 100. Verifica meta description, announce bar, hero badge, cardul Gratuit si
CTA-ul final."""
html = _citeste_landing()
assert "100 de prestații" not in html, \
"'100 de prestații' inca prezent in landing — limita trebuie sa fie 60 (5.17 T7)"
assert "100 prestații" not in html, \
"'100 prestații' inca prezent in landing — limita trebuie sa fie 60 (5.17 T7)"
assert "60 de prestații" in html, \
"'60 de prestații' lipseste din landing — verifica meta, announce bar, cardul Gratuit (5.17 T7)"
assert "60 prestații" in html, \
"'60 prestații' lipseste din hero badge in landing (5.17 T7)"
assert "60 de prezentări" in html, \
"'60 de prezentări' lipseste din CTA-ul final al landing-ului (5.17 T7)"
def test_landing_trial_pro_nu_premium():
"""5.17 T7 (US-007): trial-ul de 30 de zile este pe Pro, NU pe Premium.
Verifica sectiunea PRICING (subtitle) si sectiunea AUTH (lista beneficii)."""
html = _citeste_landing()
assert "Pro gratuit 30 de zile" in html, \
"'Pro gratuit 30 de zile' lipseste din landing — verifica sectiunile PRICING + AUTH (5.17 T7)"
assert "Premium gratuit 30 de zile" not in html, \
"'Premium gratuit 30 de zile' inca in landing — trial-ul e pe Pro, nu Premium (5.17 T7)"
def test_landing_autentificare_link_login():
"""5.16 US-012: butonul 'Autentificare' din header-ul landing este un link <a href='/login'>
cu clasa auth-login-link, NU un buton care deschide modalul de login.
CSS-ul responsive (.lp-hactions) trebuie sa foloseasca noul selector, nu cel vechi."""
html = _citeste_landing()
# Link real catre /login in header (cu clasa de identificare)
assert 'href="/login"' in html, \
"href='/login' lipseste din landing — 'Autentificare' din header trebuie sa fie link (5.16 US-012)"
assert "auth-login-link" in html, \
"Clasa auth-login-link lipseste — header 'Autentificare' nu a fost convertit la <a> (5.16 US-012)"
# CSS-ul responsive ascunde linkul pe <430px prin noul selector (nu cel vechi cu atribute)
assert "a.auth-login-link" in html, \
"Selectorul CSS 'a.auth-login-link' lipseste — CSS responsive neactualizat (5.16 US-012)"
# Selectorul CSS vechi cu [data-act="auth"][data-tab="login"] nu mai exista in CSS
assert '[data-act="auth"][data-tab="login"]' not in html, \
"Selectorul CSS vechi [data-act='auth'][data-tab='login'] inca prezent (5.16 US-012)"
def test_contoare_desktop_ascunse_pe_mobil_fara_inline_display():
"""US-002 (PRD 5.16): pe <=560px se vad DOAR contoarele compacte, nu si cele 5 carduri mari.
Regresie prinsa la VERIFY E2E (390px): un inline `style="display:flex"` pe `.contoare-desktop`
batea regula `@media (max-width:560px) { .contoare-desktop { display:none } }` (inline > stylesheet)
-> contoare DUPLICATE pe mobil. Lock: `display:flex` sta in CSS (nu inline pe element), iar media
query-ul ascunde cardurile mari pe mobil.
"""
from pathlib import Path
tdir = Path(__file__).parent.parent / "app" / "web" / "templates"
base = (tdir / "base.html").read_text(encoding="utf-8")
status = (tdir / "_status.html").read_text(encoding="utf-8")
# _status.html: containerul de carduri NU mai are inline display (altfel bate media query-ul).
assert 'class="contoare-desktop" style="display:flex' not in status, (
"containerul .contoare-desktop are inline display:flex -> media query-ul nu-l mai poate ascunde pe mobil"
)
# base.html: regula CSS default (display:flex) + ascunderea pe <=560px.
assert re.search(r"\.contoare-desktop\s*\{[^}]*display:\s*flex", base), (
"lipseste regula CSS .contoare-desktop { display:flex } in base.html"
)
assert re.search(r"\.contoare-desktop\s*\{[^}]*display:\s*none", base), (
"lipseste ascunderea .contoare-desktop { display:none } (media <=560px) in base.html"
)

View File

@@ -81,7 +81,10 @@ def client(monkeypatch):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_status_are_bife_verzi_cand_totul_ok(client): def test_status_are_bife_verzi_cand_totul_ok(client):
"""Worker viu + RAR login recent -> glifa verde ✓ + text 'declaratiile curg normal'.""" """US-003 PRD 5.16: worker viu + RAR login recent -> strip-sanatate in DOM dar ASCUNS (hidden).
Banda rosie apare DOAR cand BLOCAT. Starea OK e indicata de dot-ul verde din antet (base.html).
Elementul id=strip-sanatate ramane in DOM pentru compatibilitate (nu dispare complet).
"""
_create_account_user("bifeok@test.com") _create_account_user("bifeok@test.com")
_login(client, "bifeok@test.com", "parolasecreta10") _login(client, "bifeok@test.com", "parolasecreta10")
@@ -91,12 +94,11 @@ def test_status_are_bife_verzi_cand_totul_ok(client):
resp = client.get("/_fragments/status") resp = client.get("/_fragments/status")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Glifa accesibila ✓ (nu doar culoare) # US-003: elementul strip-sanatate e prezent in DOM dar ascuns cand totul e ok
assert "&#10003;" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}" assert 'id="strip-sanatate"' in html, f"id=strip-sanatate lipseste complet din fragment. HTML: {html[:600]}"
# US-003 D6: strip unificat (nu bife individuale worker/RAR) # Cand OK, banda nu trebuie sa afiseze ✗ (eroare) — ✓ nu mai apare (banda e ascunsa)
assert "curg normal" in html.lower(), ( assert "&#10007;" not in html, \
f"Textul 'curg normal' din strip sanatate lipseste. HTML: {html[:600]}" f"Glifa ✗ (eroare) apare cand starea e ok — banda e gresit afisata. HTML: {html[:600]}"
)
def test_status_are_bife_rosii_cand_worker_oprit(client): def test_status_are_bife_rosii_cand_worker_oprit(client):
@@ -183,7 +185,8 @@ def test_strip_rosu_worker_oprit(client):
def test_trei_contoare_card(client): def test_trei_contoare_card(client):
"""US-003: fragment status contine exact 3 carduri .contor-card (In coada / Trimise / De corectat).""" """US-002 PRD 5.16: fragment status contine 5 carduri .contor-card separate:
Total / Luna asta / Azi / In coada / De corectat."""
_create_account_user("treicont@test.com") _create_account_user("treicont@test.com")
_login(client, "treicont@test.com", "parolasecreta10") _login(client, "treicont@test.com", "parolasecreta10")
@@ -192,12 +195,15 @@ def test_trei_contoare_card(client):
html = resp.text html = resp.text
count = html.count("contor-card") count = html.count("contor-card")
assert count >= 3, ( assert count >= 5, (
f"Trebuie minim 3 elemente contor-card in fragment, gasit: {count}. HTML: {html[:800]}" f"Trebuie minim 5 elemente contor-card (US-002 PRD 5.16: Total/Luna/Azi/Coada/Corectat), "
f"gasit: {count}. HTML: {html[:800]}"
) )
# Etichete asteptate # Etichete asteptate (US-002 PRD 5.16: 5 carduri separate)
assert "Total" in html, "Eticheta 'Total' lipseste din contoare (US-002 PRD 5.16)."
assert "Luna asta" in html, "Eticheta 'Luna asta' lipseste din contoare (US-002 PRD 5.16)."
assert "Azi" in html, "Eticheta 'Azi' lipseste din contoare (US-002 PRD 5.16)."
assert "In coada" in html, "Eticheta 'In coada' lipseste din contoare." assert "In coada" in html, "Eticheta 'In coada' lipseste din contoare."
assert "Trimise" in html, "Eticheta 'Trimise' lipseste din contoare."
assert "De corectat" in html, "Eticheta 'De corectat' lipseste din contoare." assert "De corectat" in html, "Eticheta 'De corectat' lipseste din contoare."
@@ -250,6 +256,98 @@ def test_fara_bara_veche(client):
) )
def test_banda_apare_doar_cand_blocat(client):
"""US-003 (PRD 5.16): banda rosie completa apare NUMAI cand BLOCAT.
Cand totul e ok, strip-sanatate are atributul 'hidden' (ascuns, nu disparut).
Cand worker e oprit, strip-sanatate NU are 'hidden' (e vizibil, rosu).
"""
_create_account_user("bandablocat@test.com")
_login(client, "bandablocat@test.com", "parolasecreta10")
# Stare OK: strip ascuns
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html_ok = resp.text
# Cand OK, elementul e ascuns
assert 'id="strip-sanatate"' in html_ok, "strip-sanatate lipseste din DOM cand totul e ok"
assert "&#10007;" not in html_ok, "Glifa eroare apare cand sanatate=ok (banda nu trebuie sa fie rosie)"
# Stare BLOCAT: strip vizibil cu glifa ✗
_set_heartbeat(last_beat=None, last_rar_login_ok=None)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html_err = resp.text
assert 'id="strip-sanatate"' in html_err, "strip-sanatate lipseste din DOM cand blocat"
assert "&#10007;" in html_err, "Glifa ✗ lipseste cand BLOCAT (banda trebuie sa fie rosie)"
def test_rar_dot_in_antet_ok(client):
"""US-003 (PRD 5.16): cand logat si sanatate_ok, antetul contine chip-ul RAR cu clasa rar-ok.
Starea ok se vede din header (dot verde pulsant), nu din banda de stare (care e ascunsa).
"""
_create_account_user("rardot@test.com")
_login(client, "rardot@test.com", "parolasecreta10")
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Chip RAR in antet (nu in banda de stare)
assert "rar-chip" in html, "Clasa rar-chip lipseste din HTML (dot RAR in antet, US-003)"
assert "rar-ok" in html, "Clasa rar-ok lipseste — dot verde cand sanatate ok (US-003)"
assert "rar-dot" in html, "Clasa rar-dot lipseste din chip (US-003)"
def test_rar_in_meniu_burger(client):
"""US-003/010 (PRD 5.16): meniul burger contine starea RAR ca prima intrare (RAR online / RAR indisponibil)."""
_create_account_user("rarmeniu@test.com")
_login(client, "rarmeniu@test.com", "parolasecreta10")
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Meniul burger (cont-menu) contine indicatorul RAR
assert "cont-menu" in html, "Meniu burger (cont-menu) lipseste din HTML"
assert "RAR online" in html or "RAR indisponibil" in html, \
"Starea RAR nu apare in meniu burger (US-003/010)"
# Prima intrare e starea RAR — prezenta class menu-rar-line
assert "menu-rar-line" in html, "Clasa menu-rar-line lipseste din burger (US-003)"
def test_anuleaza_are_data_modal_close(client):
"""US-007 (PRD 5.16): overlay-ul modal si butonul de inchidere au atributul data-modal-close."""
# Butonul si overlay-ul trebuie sa aiba data-modal-close pentru ca handler-ul cu .closest() sa functioneze
# Verificam in baza template-ului base.html (modal e definit acolo, randat pe toate paginile)
# Testam pe dashboard dupa login (unde baza e incarcata)
_create_account_user("modalclose@test.com")
_login(client, "modalclose@test.com", "parolasecreta10")
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "data-modal-close" in html, \
"data-modal-close lipseste din template — modalul nu se poate inchide (US-007)"
def test_modal_close_pe_element_interior(client):
"""US-007 (PRD 5.16): handler-ul modal foloseste .closest('[data-modal-close]') nu
.hasAttribute directe — astfel click pe un element interior al backdrop-ului functioneaza."""
_create_account_user("modalclosest@test.com")
_login(client, "modalclosest@test.com", "parolasecreta10")
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Verificam ca JS-ul foloseste closest, nu hasAttribute
assert "closest('[data-modal-close]')" in html, \
"Handler-ul modal foloseste hasAttribute in loc de closest (US-007) — click pe copil nu va inchide modalul"
def _set_tz_bucuresti(monkeypatch, request): def _set_tz_bucuresti(monkeypatch, request):
"""Forteaza TZ=Europe/Bucharest pentru ca modificatorul SQLite 'localtime' sa """Forteaza TZ=Europe/Bucharest pentru ca modificatorul SQLite 'localtime' sa
rezolve la fusul RO indiferent de TZ-ul runner-ului (CI ruleaza de regula in UTC). rezolve la fusul RO indiferent de TZ-ul runner-ului (CI ruleaza de regula in UTC).
@@ -359,3 +457,227 @@ def test_iarna_nu_bleed_in_ziua_urmatoare(monkeypatch, request):
finally: finally:
conn.close() conn.close()
get_settings.cache_clear() get_settings.cache_clear()
# ===========================================================================
# US-006 (PRD 5.17) — Afisaj plan curent: trial / consum / warn / banner
# ===========================================================================
def _set_trial_until(account_id: int, trial_until_str: str | None) -> None:
"""Seteaza direct trial_until pentru un cont (helper de test)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET trial_until=? WHERE id=?",
(trial_until_str, account_id),
)
conn.commit()
finally:
conn.close()
def _insert_submissions_sent(account_id: int, n: int) -> None:
"""Insereaza N submissions sent in luna curenta (helper de test)."""
from app.db import get_connection
import json as _json
conn = get_connection()
try:
for i in range(n):
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, created_at) "
"VALUES (?, 'sent', ?, ?, datetime('now'))",
(account_id, _json.dumps({"vin": f"VIN{i:013d}"}), f"key-plan-{account_id}-{i}"),
)
conn.commit()
finally:
conn.close()
def test_afisaj_plan_si_zile_trial(client):
"""US-006: cont in trial Pro -> fragment status arata 'trial N zile ramase'.
Contul nou primeste trial_until=now+30z automat la creare.
"""
acct_id, _ = _create_account_user("trialzile@test.com")
_login(client, "trialzile@test.com", "parolasecreta10")
# trial_until = now + 18 zile + 12h (buffer pt a evita delta.days=17 din timing test)
future = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "Plan: Pro" in html, f"Textul 'Plan: Pro' lipseste in trial. HTML: {html[:800]}"
assert "trial" in html.lower(), f"Cuvantul 'trial' lipseste in starea de trial. HTML: {html[:800]}"
assert "18" in html, f"Numarul de zile (18) nu apare in afisaj. HTML: {html[:800]}"
assert "zile" in html, f"Cuvantul 'zile' lipseste (pluralizare). HTML: {html[:800]}"
def test_afisaj_consum_lunar(client):
"""US-006: cont free (fara trial) -> fragment status arata 'Gratuit · N/60 luna asta'."""
acct_id, _ = _create_account_user("consumlun@test.com")
_login(client, "consumlun@test.com", "parolasecreta10")
# Dezactiveaza trial-ul (cont free pur)
_set_trial_until(acct_id, None)
# Insereaza 5 submissions sent luna asta
_insert_submissions_sent(acct_id, 5)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "Gratuit" in html, f"'Gratuit' lipseste din afisajul de consum. HTML: {html[:800]}"
assert "5" in html, f"Contorul de consum (5) nu apare. HTML: {html[:800]}"
assert "60" in html, f"Limita (60) nu apare in afisajul de consum. HTML: {html[:800]}"
assert "luna asta" in html, f"'luna asta' lipseste din afisajul de consum. HTML: {html[:800]}"
def test_avertizare_aproape_de_limita(client):
"""US-006: >=80% din 60 -> avertizare cu text 'aproape de limita' + culoare warn."""
acct_id, _ = _create_account_user("aproapelim@test.com")
_login(client, "aproapelim@test.com", "parolasecreta10")
_set_trial_until(acct_id, None)
# 50/60 = 83% -> warn
_insert_submissions_sent(acct_id, 50)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "aproape de limita" in html, (
f"Textul 'aproape de limita' lipseste la 50/60. HTML: {html[:800]}"
)
assert "50" in html, f"Contorul 50 nu apare. HTML: {html[:800]}"
# Warn = culoare (var(--warn) in inline style)
assert "var(--warn)" in html or "plan-warn" in html, (
f"Stilul de warn (var(--warn) sau clasa plan-warn) lipseste la aproape-de-limita. HTML: {html[:800]}"
)
def test_limita_atinsa(client):
"""US-006: 60/60 -> text 'limita atinsa'."""
acct_id, _ = _create_account_user("limitaatinsa@test.com")
_login(client, "limitaatinsa@test.com", "parolasecreta10")
_set_trial_until(acct_id, None)
_insert_submissions_sent(acct_id, 60)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "limita atinsa" in html, (
f"Textul 'limita atinsa' lipseste la 60/60. HTML: {html[:800]}"
)
def test_copy_pluralizare_zi_zile(client):
"""US-006: pluralizare RO corecta — 1 zi (nu '1 zile'), 18 zile (nu '18 zi')."""
acct_id, _ = _create_account_user("pluralzile@test.com")
_login(client, "pluralzile@test.com", "parolasecreta10")
# 18 zile: trebuie "18 zile ramase" (buffer 12h pt delta.days determinist)
future_18 = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future_18)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "18 zile" in html, f"'18 zile' lipseste. HTML: {html[:800]}"
assert "18 zi " not in html and "18 zi<" not in html, (
f"'18 zi' (plural gresit) apare in loc de '18 zile'. HTML: {html[:800]}"
)
# 1 zi: trebuie "1 zi ramasa" (singular); buffer 12h
future_1 = (datetime.now(timezone.utc) + timedelta(days=1, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future_1)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "1 zi" in html, f"'1 zi' (singular) lipseste la o zi ramasa. HTML: {html[:800]}"
assert "1 zile" not in html, (
f"'1 zile' (plural gresit) apare in loc de '1 zi'. HTML: {html[:800]}"
)
def test_banner_one_time_trial_expirat(client):
"""US-006 T-DES-1: dupa expirarea trial-ului, banner 'Trial Pro expirat' apare in _status.html."""
acct_id, _ = _create_account_user("trialexp@test.com")
_login(client, "trialexp@test.com", "parolasecreta10")
# trial_until in trecut -> trial expirat -> banner one-time
past = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, past)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "Trial Pro expirat" in html, (
f"Banner 'Trial Pro expirat' lipseste dupa expirarea trial-ului. HTML: {html[:800]}"
)
assert "Gratuit" in html, (
f"Dupa expirarea trial-ului, planul trebuie sa afiseze 'Gratuit'. HTML: {html[:800]}"
)
# Bannerul are buton de dismiss
assert "banner-trial-expirat" in html, (
f"Elementul id=banner-trial-expirat lipseste. HTML: {html[:800]}"
)
def test_cont_arata_plan(client):
"""US-006: tab-ul Cont (/tab=cont) afiseaza planul curent si explicatia de upgrade."""
acct_id, _ = _create_account_user("contplan@test.com")
_login(client, "contplan@test.com", "parolasecreta10")
_set_trial_until(acct_id, None) # free fara trial
resp = client.get("/?tab=cont", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "Plan curent" in html or "sectiune-plan" in html, (
f"Sectiunea 'Plan curent' lipseste din tab-ul Cont. HTML: {html[:1000]}"
)
assert "Gratuit" in html, f"'Gratuit' lipseste din planul afisat in Cont. HTML: {html[:1000]}"
assert "Standard" in html or "Pro" in html, (
f"Optiunile de upgrade (Standard/Pro) lipsesc din sectiunea Plan. HTML: {html[:1000]}"
)
def test_plan_linie_in_burger(client):
"""US-006: meniul burger contine linia de plan (Plan: Gratuit / Pro · trial N zile)."""
acct_id, _ = _create_account_user("burgerplan@test.com")
_login(client, "burgerplan@test.com", "parolasecreta10")
_set_trial_until(acct_id, None) # free fara trial
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Meniul burger trebuie sa contina linia de plan
assert "Plan: Gratuit" in html, (
f"'Plan: Gratuit' lipseste din meniu burger. HTML (fragment): {html[html.find('cont-menu'):html.find('cont-menu')+500] if 'cont-menu' in html else html[:500]}"
)
def test_trial_pro_arata_zile_in_burger(client):
"""US-006: cont in trial -> burger arata 'Plan: Pro · trial N zile ramase'."""
acct_id, _ = _create_account_user("burgertrial@test.com")
_login(client, "burgertrial@test.com", "parolasecreta10")
future = (datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "Plan: Pro" in html, f"'Plan: Pro' lipseste din burger in trial. HTML: {html[:800]}"
assert "trial" in html.lower(), f"'trial' lipseste din linia de plan din burger. HTML: {html[:800]}"

View File

@@ -137,60 +137,57 @@ def test_paleta_accent_azur_definita(client):
) )
# ── test_font_ibm_plex_aplicat ──────────────────────────────────────────────── # ── test_font_system_stack_aplicat ───────────────────────────────────────────
def test_font_ibm_plex_aplicat(client): def test_font_system_stack_aplicat(client):
"""IBM Plex Sans si IBM Plex Mono declarate in font-family si @font-face cu font-display:swap. """US-001 (PRD 5.16): IBM Plex eliminat; body foloseste stiva de fonturi sistem.
Verifica: Verifica:
- body font-family contine 'IBM Plex Sans' (sau alias ibm-plex-sans) - body font-family foloseste var(--font-ui) (CSS custom property)
- exista cel putin un @font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' - --font-ui este definit in :root si contine un system font stack (system-ui / -apple-system)
- @font-face include font-display:swap - ZERO @font-face cu 'IBM Plex' in <style> (IBM Plex eliminat complet)
- @font-face pointeaza spre /static/fonts/ - ZERO referinte catre /static/fonts/ in HTML (nu se mai servesc fisiere woff2)
""" """
resp = client.get("/login") resp = client.get("/login")
assert resp.status_code == 200 assert resp.status_code == 200
style = _get_style_block(resp.text) style = _get_style_block(resp.text)
# 1. body font-family contine IBM Plex Sans # 1. body font-family refera var(--font-ui) (nu IBM Plex inline)
body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL) body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL)
assert body_m, "Regula 'body { ... }' negasita in <style>" assert body_m, "Regula 'body { ... }' negasita in <style>"
body_block = body_m.group(1) body_block = body_m.group(1)
assert "IBM Plex Sans" in body_block or "ibm-plex-sans" in body_block.lower(), ( assert "var(--font-ui)" in body_block, (
f"'IBM Plex Sans' lipseste din font-family al body. body block: {body_block.strip()}" f"body font-family trebuie sa foloseasca var(--font-ui) (sistem font stack). "
f"body block: {body_block.strip()}"
) )
# 2. Exista cel putin un @font-face cu IBM Plex # 2. --font-ui definit in :root si contine un system font stack
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
assert root_m, "Blocul :root negasit in <style>"
root_block = root_m.group(1)
assert "--font-ui" in root_block, (
f"--font-ui lipseste din :root. Continut :root: {root_block.strip()}"
)
font_ui_m = re.search(r"--font-ui\s*:\s*([^;]+)", root_block)
assert font_ui_m, "--font-ui negasit in blocul :root"
font_ui_val = font_ui_m.group(1).lower()
assert "system-ui" in font_ui_val or "-apple-system" in font_ui_val, (
f"--font-ui trebuie sa contina system-ui sau -apple-system (stiva sistem). "
f"Valoare gasita: {font_ui_m.group(1).strip()}"
)
# 3. ZERO @font-face cu IBM Plex (eliminat in US-001)
font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL) font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL)
assert font_face_blocks, "@font-face negasit in <style>"
ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()] ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()]
assert ibm_face, ( assert not ibm_face, (
"@font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' negasit. " f"@font-face cu IBM Plex trebuia eliminat (US-001 PRD 5.16). "
f"Blocuri @font-face gasite: {font_face_blocks}" f"Blocat gasit: {ibm_face}"
) )
# 3. font-display:swap prezent in cel putin un bloc IBM Plex @font-face # 4. ZERO referinte /static/fonts/ in HTML randat (nu mai servim woff2)
swap_present = any("swap" in b.lower() for b in ibm_face) html = resp.text
assert swap_present, ( assert "/static/fonts/" not in html, (
"font-display:swap lipseste din @font-face IBM Plex. " "Referinte catre /static/fonts/ gasite in HTML — trebuie eliminate (US-001 PRD 5.16)."
f"Blocuri @font-face IBM Plex: {ibm_face}"
)
# 4. @font-face pointeaza spre /static/fonts/
fonts_src = any("/static/fonts/" in b for b in ibm_face)
assert fonts_src, (
"@font-face IBM Plex nu pointeaza spre /static/fonts/. "
f"Blocuri: {ibm_face}"
)
# 5. IBM Plex Mono pentru monospace: exista un context monospace cu IBM Plex Mono
# (fie @font-face, fie o regula font-family cu monospace)
has_mono = any("IBM Plex Mono" in b or "ibm-plex-mono" in b.lower() for b in font_face_blocks)
if not has_mono:
# Acceptam si daca e in o regula CSS (nu neaparat @font-face)
has_mono = "IBM Plex Mono" in style
assert has_mono, (
"'IBM Plex Mono' lipseste din <style> (trebuie pentru coduri RAR/VIN/nr)."
) )

View File

@@ -22,7 +22,7 @@ import argparse
import sqlite3 import sqlite3
import sys import sys
from app.accounts import create_account, list_accounts, set_active from app.accounts import create_account, list_accounts, set_active, set_tier
from app.auth import create_api_key from app.auth import create_api_key
from app.db import get_connection, init_db from app.db import get_connection, init_db
from app.users import set_admin from app.users import set_admin
@@ -68,6 +68,17 @@ def _set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> int:
return 0 return 0
def _set_tier(conn: sqlite3.Connection, account_id: int, tier: str, trial_until: str | None) -> int:
try:
set_tier(conn, account_id, tier, trial_until=trial_until)
except ValueError as exc:
print(f"eroare: {exc}", file=sys.stderr)
return 2
trial_msg = f", trial_until={trial_until}" if trial_until else ", fara trial"
print(f"Cont {account_id}: tier={tier}{trial_msg}")
return 0
def _set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool) -> int: def _set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool) -> int:
try: try:
set_admin(conn, account_id, is_admin=is_admin) set_admin(conn, account_id, is_admin=is_admin)
@@ -119,6 +130,29 @@ def main(argv: list[str] | None = None) -> int:
p_sadmin.add_argument("--account", type=int, required=True, help="account_id") p_sadmin.add_argument("--account", type=int, required=True, help="account_id")
p_sadmin.add_argument("--remove", action="store_true", help="sterge rolul admin (implicit: adauga)") p_sadmin.add_argument("--remove", action="store_true", help="sterge rolul admin (implicit: adauga)")
p_stier = sub.add_parser(
"set-tier",
help="seteaza planul unui cont (free/standard/pro/premium)",
description=(
"Aloca manual un plan de cont. Tier invalid -> eroare clara. "
"Contul de sistem id=1 e protejat."
),
)
p_stier.add_argument("--account", type=int, required=True, help="account_id")
p_stier.add_argument(
"--tier", required=True,
help="planul de alocat: free | standard | pro | premium"
)
_trial_grp = p_stier.add_mutually_exclusive_group()
_trial_grp.add_argument(
"--trial-days", type=int, metavar="N",
help="seteaza trial_until = acum + N zile"
)
_trial_grp.add_argument(
"--no-trial", action="store_true",
help="sterge trial-ul (trial_until=NULL)"
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
init_db() # asigura schema (accounts.active + index CUI) + cont default init_db() # asigura schema (accounts.active + index CUI) + cont default
@@ -134,6 +168,16 @@ def main(argv: list[str] | None = None) -> int:
return _set_active(conn, args.account, False) return _set_active(conn, args.account, False)
if args.cmd == "set-admin": if args.cmd == "set-admin":
return _set_admin(conn, args.account, is_admin=not args.remove) return _set_admin(conn, args.account, is_admin=not args.remove)
if args.cmd == "set-tier":
# Calculeaza trial_until din --trial-days sau None daca --no-trial
from datetime import datetime, timedelta, timezone
trial_until: str | None = None
if getattr(args, "trial_days", None):
trial_until = (
datetime.now(timezone.utc) + timedelta(days=args.trial_days)
).strftime("%Y-%m-%d %H:%M:%S")
# daca nici --trial-days nici --no-trial -> trial_until=None (fara trial)
return _set_tier(conn, args.account, args.tier, trial_until)
finally: finally:
conn.close() conn.close()
return 0 return 0