Connexion Admin
+Connectez-vous pour gérer les inscriptions
+diff --git a/.env.example b/.env.example index 296bb79..95d5bb9 100644 --- a/.env.example +++ b/.env.example @@ -18,4 +18,5 @@ EMAIL_PASSWORD = "" PYTHON_TOGO_API_BASE_URL="" PYTHON_TOGO_API_KEY="" PYTHON_TOGO_API_TIMEOUT_SECONDS= -PYTHON_TOGO_EVENT_CODE="" \ No newline at end of file +PYTHON_TOGO_EVENT_CODE="" +ADMIN_API_KEY="" \ No newline at end of file diff --git a/app/main.py b/app/main.py index 882b730..c34a4fc 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ typing._ClassVar = typing.ClassVar from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware from app.routers.router_2025 import router_2025 from app.routers.router_2026 import router as router_2026 from fastapi import FastAPI, Request, HTTPException, status @@ -45,6 +46,81 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def _build_api_proxy_url(path: str) -> str: + base = settings.python_togo_api_base_url.rstrip("/") + if base.endswith("/api/v2"): + return f"{base}/{path}" + return f"{base}/api/v2/{path}" + + +@app.api_route("/api/v2/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) +async def _proxy_api_v2(request: Request, path: str): + backend_url = _build_api_proxy_url(path) + query_string = request.url.query + target = f"{backend_url}?{query_string}" if query_string else backend_url + + headers = dict(request.headers) + headers.pop("host", None) + headers["X-API-Key"] = settings.python_togo_api_key + body = await request.body() + + async with httpx.AsyncClient( + timeout=settings.python_togo_api_timeout_seconds + ) as client: + response = await client.request( + method=request.method, + url=target, + headers=headers, + content=body if body else None, + follow_redirects=False, + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + +@app.api_route("/api/feedback", methods=["POST"]) +@app.api_route("/api/feedback/", methods=["POST"]) +async def _proxy_feedback(request: Request): + base = settings.python_togo_api_base_url.rstrip("/").removesuffix("/api/v2") + backend_url = f"{base}/api/feedback/" + query_string = request.url.query + target = f"{backend_url}?{query_string}" if query_string else backend_url + + headers = dict(request.headers) + headers.pop("host", None) + headers["X-API-Key"] = settings.python_togo_api_key + body = await request.body() + + async with httpx.AsyncClient( + timeout=settings.python_togo_api_timeout_seconds + ) as client: + response = await client.post( + target, + headers=headers, + content=body if body else None, + follow_redirects=False, + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + BASE_DIR = Path(__file__).resolve().parent template = Jinja2Templates(directory=str(BASE_DIR / "templates")) app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") @@ -203,4 +279,4 @@ async def send_contact_message(payload: ContactFormPayload): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="127.0.0.1", port=8800) + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/app/routers/router_2026.py b/app/routers/router_2026.py index 8e761a5..d70cfa4 100644 --- a/app/routers/router_2026.py +++ b/app/routers/router_2026.py @@ -33,6 +33,12 @@ grant_results_date = date(2026, 8, 15) +feedback_open_at = datetime(2026, 8, 28, 12, 0, tzinfo=timezone.utc) + + +def _feedback_is_open() -> bool: + return datetime.now(timezone.utc) >= feedback_open_at + PartnerType = Literal[ "partnership", @@ -1668,9 +1674,19 @@ def volunteers(request: Request): @router.get("/feedback") -def feedback(request: Request): - # TODO - create a feedback page with a form to submit feedback - pass +async def feedback(request: Request): + feedback_is_open = _feedback_is_open() + return await _render_page_with_event( + request=request, + name="2026_feedback.html", + active_page="feedback", + page_css="feedback.css", + page_title="PyCon Togo 2026 - Feedback", + extra_context={ + "feedback_is_open": feedback_is_open, + "feedback_open_at": feedback_open_at, + }, + ) @router.get("/shop") @@ -1847,3 +1863,56 @@ def _road_to_pycon(request: Request): @router.get("/streamyard") def _streamyard(request: Request): return RedirectResponse(url="https://streamyard.com/phrvxehbva", status_code=302) + + +@router.get("/api/v2/registrations") +async def api_v2_registrations(request: Request): + event_id = request.query_params.get("event_id") + url = _build_api_url("/registrations") + if event_id: + url = f"{url}?event_id={event_id}" + + headers = { + "Accept": "application/json", + } + + authorization = request.headers.get("authorization") + if authorization: + headers["Authorization"] = authorization + + try: + async with httpx.AsyncClient(timeout=settings.python_togo_api_timeout_seconds) as client: + response = await client.get(url, headers=headers) + except httpx.RequestError: + raise HTTPException(status_code=500, detail="Error retrieving registrations") + + if response.status_code == 401: + raise HTTPException(status_code=401, detail="Not authenticated") + if response.status_code == 403: + raise HTTPException(status_code=403, detail="Admin access required") + if response.status_code >= 400: + raise HTTPException(status_code=500, detail="Error retrieving registrations") + + return JSONResponse(status_code=response.status_code, content=response.json()) + + +@router.get("/admin/login") +async def admin_login(request: Request): + return await _render_page_with_event( + request=request, + name="2026_admin_login.html", + active_page="support", + page_css="admin-registrations.css", + page_title="PyCon Togo 2026 - Admin - Connexion", + ) + + +@router.get("/admin/registrations") +async def admin_registrations(request: Request): + return await _render_page_with_event( + request=request, + name="2026_admin_registrations.html", + active_page="support", + page_css="admin-registrations.css", + page_title="PyCon Togo 2026 - Admin - Inscriptions", + ) diff --git a/app/schemas/settings.py b/app/schemas/settings.py index 4165403..90b588d 100644 --- a/app/schemas/settings.py +++ b/app/schemas/settings.py @@ -46,6 +46,11 @@ class Settings(BaseModel): title="Python Togo Event Code", description="Event code used in sponsorship inquiry endpoint path", ) + admin_api_key: str = Field( + "", + title="Admin API Key", + description="Secret key used to authenticate admin requests to the Python Togo API", + ) redis_url: str = Field( "redis://localhost:6379/0", title="Redis URL", diff --git a/app/static/2026/css/pages/admin-login.css b/app/static/2026/css/pages/admin-login.css new file mode 100644 index 0000000..a6511cb --- /dev/null +++ b/app/static/2026/css/pages/admin-login.css @@ -0,0 +1,160 @@ +/* ═══════════════════════════════════════════════ + PyCon Togo 2026 - Admin Login + ═══════════════════════════════════════════════ */ + +.admin-login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: linear-gradient(135deg, var(--green-pale) 0%, var(--cream) 50%, var(--gold-pale) 100%); +} + +.admin-login-card { + width: 100%; + max-width: 420px; + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + padding: 40px 36px; + border: 1px solid var(--border); +} + +.admin-login-header { + text-align: center; + margin-bottom: 28px; +} + +.admin-login-logo { + width: 56px; + height: 56px; + border-radius: var(--radius); + background: var(--green); + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + color: var(--white); + font-weight: 800; + font-size: 1.25rem; + letter-spacing: -0.02em; +} + +.admin-login-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); + margin: 0 0 6px; + letter-spacing: -0.01em; +} + +.admin-login-subtitle { + font-size: 0.95rem; + color: var(--text-muted); + margin: 0; + line-height: 1.5; +} + +.admin-login-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.admin-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-field label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-mid); + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.admin-field input { + width: 100%; + padding: 12px 14px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + background: var(--off-white); + color: var(--text); + font-size: 1rem; + font-family: var(--font-body); + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; + outline: none; +} + +.admin-field input:hover { + border-color: var(--border-mid); + background: var(--white); +} + +.admin-field input:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px var(--green-dim); + background: var(--white); +} + +.admin-login-actions { + margin-top: 6px; +} + +.admin-login-submit { + width: 100%; + padding: 13px 16px; + border-radius: var(--radius-sm); + background: var(--green); + color: var(--white); + font-weight: 600; + font-size: 1rem; + letter-spacing: 0.01em; + border: none; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease; + box-shadow: var(--shadow-sm); +} + +.admin-login-submit:hover { + background: var(--green-mid); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.admin-login-submit:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +.admin-login-submit:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.admin-login-error { + margin-top: 4px; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--red-pale); + color: var(--red); + font-size: 0.9rem; + line-height: 1.4; + border: 1px solid rgba(206, 17, 38, 0.15); +} + +@media (max-width: 480px) { + .admin-login-card { + padding: 28px 20px; + border-radius: var(--radius); + } + + .admin-login-title { + font-size: 1.25rem; + } +} diff --git a/app/static/2026/css/pages/admin-registrations.css b/app/static/2026/css/pages/admin-registrations.css new file mode 100644 index 0000000..9641df4 --- /dev/null +++ b/app/static/2026/css/pages/admin-registrations.css @@ -0,0 +1,192 @@ +/* Admin registrations page specific styling */ +.admin-header { + margin-bottom: 28px; +} + +.admin-title { + font-size: clamp(1.6rem, 2.4vw, 2.2rem); + font-weight: 700; + margin: 0 0 8px; +} + +.admin-subtitle { + color: #555; + margin: 0; + font-size: 1rem; +} + +.admin-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.admin-filter { + display: flex; + align-items: center; + gap: 10px; +} + +.admin-filter label { + font-weight: 600; + font-size: 0.95rem; + color: #333; +} + +.admin-filter select { + padding: 8px 12px; + border-radius: 10px; + border: 1px solid #d6dbe6; + background: #fff; + font-size: 0.95rem; + min-width: 220px; +} + +.admin-meta { + display: flex; + align-items: center; +} + +.admin-count { + font-weight: 600; + color: #333; + background: #eef2ff; + padding: 8px 14px; + border-radius: 999px; + font-size: 0.95rem; +} + +.admin-table-wrapper { + overflow-x: auto; + background: #fff; + border-radius: 18px; + box-shadow: 0 10px 30px rgba(14, 22, 40, 0.08); + padding: 4px; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; +} + +.admin-table thead { + background: #f7f9ff; +} + +.admin-table th { + text-align: left; + padding: 14px 16px; + font-weight: 700; + color: #334155; + white-space: nowrap; + border-bottom: 1px solid #e5e9f2; +} + +.admin-table td { + padding: 14px 16px; + border-bottom: 1px solid #f1f5f9; + color: #1f2937; + vertical-align: middle; +} + +.admin-table tbody tr:last-child td { + border-bottom: none; +} + +.admin-table tbody tr:hover { + background: #f8fafc; +} + +.admin-cell-primary { + font-weight: 600; + color: #0f172a; +} + +.admin-cell-secondary { + font-size: 0.85rem; + color: #6b7280; + margin-top: 2px; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +.badge-status { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket-student { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket-standard { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket-premium { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket-dinner { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-payment { + background: #fff7ed; + color: #7c2d12; +} + +.admin-loading, +.admin-error, +.admin-empty { + text-align: center; + padding: 28px 16px; + color: #6b7280; +} + +.admin-error { + color: #b91c1c; + background: #fef2f2; +} + +@media (max-width: 768px) { + .admin-toolbar { + flex-direction: column; + align-items: stretch; + } + + .admin-filter { + flex-direction: column; + align-items: stretch; + } + + .admin-filter select { + width: 100%; + } + + .admin-meta { + justify-content: flex-start; + } +} diff --git a/app/static/2026/css/pages/feedback.css b/app/static/2026/css/pages/feedback.css new file mode 100644 index 0000000..292b487 --- /dev/null +++ b/app/static/2026/css/pages/feedback.css @@ -0,0 +1,145 @@ +.feedback-card { + max-width: 820px; + margin: 0 auto; + background: #fff; + border-radius: 16px; + padding: 32px; + box-shadow: 0 4px 24px rgba(0,0,0,0.06); +} +.feedback-header { margin-bottom: 24px; } +.feedback-title { + color: #056d1e; + font-size: 1.75rem; + font-weight: 700; + margin: 0 0 8px; +} +.feedback-subtitle { + color: #444; + font-size: 1rem; + margin: 0; + line-height: 1.5; +} +.feedback-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.feedback-full { grid-column: 1 / -1; } +.feedback-field { display: flex; flex-direction: column; gap: 6px; } +.feedback-label { + font-weight: 600; + color: #2D5016; + font-size: 0.9rem; + margin-bottom: 4px; +} +.feedback-form input[type="text"], +.feedback-form input[type="number"], +.feedback-form select, +.feedback-form textarea { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid rgba(0,0,0,0.08); + background: #ffffff; + color: #111; + font-size: 0.95rem; + font-family: inherit; +} +.feedback-form textarea { min-height: 100px; resize: vertical; } +.feedback-radio-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.feedback-radio-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(5,109,30,0.04); + border: 1px solid rgba(0,0,0,0.05); + color: #056d1e; + font-size: 0.9rem; + cursor: pointer; +} +.feedback-radio-item input { margin-right: 4px; } +.feedback-checkbox-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.feedback-checkbox-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(5,109,30,0.04); + border: 1px solid rgba(0,0,0,0.05); + color: #056d1e; + font-size: 0.9rem; + cursor: pointer; +} +.feedback-checkbox-item input { margin-right: 4px; } +.feedback-actions { + display: flex; + gap: 12px; + align-items: center; + margin-top: 20px; + grid-column: 1 / -1; +} +.feedback-status { + grid-column: 1 / -1; + margin-top: 16px; + padding: 12px 16px; + border-radius: 10px; + display: none; + align-items: center; + gap: 10px; + font-weight: 600; +} +.feedback-status.success { + background: rgba(5,109,30,0.06); + border: 2px solid rgba(255,212,59,0.18); + color: #2D5016; +} +.feedback-status.error { + background: rgba(255,0,0,0.04); + border: 2px solid rgba(255,0,0,0.12); + color: #800; +} +.feedback-status-msg { flex: 1; } +.feedback-coming-soon-text { + color: #444; + font-size: 1.1rem; + line-height: 1.6; + margin: 0; + text-align: center; +} +.feedback-coming-soon-date { + color: #2D5016; + font-weight: 600; + font-size: 0.95rem; + margin: 16px 0 0; + text-align: center; +} +.feedback-form-wrapper.feedback-form-locked { + position: relative; + pointer-events: none; +} +.feedback-form-wrapper.feedback-form-locked .feedback-form * { + pointer-events: none; +} +.feedback-form-wrapper.feedback-form-locked .feedback-form .feedback-status { + pointer-events: auto; +} +.feedback-closed-banner { + margin-bottom: 20px; + padding: 16px 20px; + border-radius: 10px; + border: 2px solid rgba(255, 0, 0, 0.12); + background: rgba(255, 0, 0, 0.04); + color: #800; +} +.feedback-closed-banner strong { + font-weight: 700; + display: block; + margin-bottom: 4px; +} +@media (max-width: 720px) { + .feedback-card { padding: 20px; } + .feedback-grid { grid-template-columns: 1fr; } +} diff --git a/app/static/2026/js/admin-auth.js b/app/static/2026/js/admin-auth.js new file mode 100644 index 0000000..5630f75 --- /dev/null +++ b/app/static/2026/js/admin-auth.js @@ -0,0 +1,203 @@ +/* ═══════════════════════════════════════════════ + PyCon Togo 2026 - Admin Auth + ═══════════════════════════════════════════════ */ + +const AdminAuth = (() => { + const ACCESS_TOKEN_KEY = "admin_access_token"; + const REFRESH_TOKEN_KEY = "admin_refresh_token"; + const REFRESH_MARGIN_MS = 60 * 1000; + + function getAccessToken() { + try { + return localStorage.getItem(ACCESS_TOKEN_KEY) || ""; + } catch { + return ""; + } + } + + function getRefreshToken() { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY) || ""; + } catch { + return ""; + } + } + + function isAuthenticated() { + return Boolean(getAccessToken()); + } + + function clearTokens() { + try { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch { + // ignore storage errors + } + } + + function setTokens({ access_token, refresh_token }) { + try { + localStorage.setItem(ACCESS_TOKEN_KEY, access_token || ""); + localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token || ""); + } catch { + // ignore storage errors + } + } + + async function refreshAccessToken() { + const refresh_token = getRefreshToken(); + if (!refresh_token) { + clearTokens(); + return null; + } + + try { + const res = await fetch("/api/v2/auth/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${refresh_token}`, + }, + body: JSON.stringify({ refresh_token }), + }); + + if (!res.ok) { + clearTokens(); + return null; + } + + const data = await res.json(); + if (data?.access_token) { + setTokens({ + access_token: data.access_token, + refresh_token: data.refresh_token || refresh_token, + }); + return data.access_token; + } + + clearTokens(); + return null; + } catch { + clearTokens(); + return null; + } + } + + async function getValidAccessToken() { + const token = getAccessToken(); + if (!token) return null; + + const decoded = parseJwt(token); + if (!decoded || !decoded.exp) return await refreshAccessToken(); + + const expiresInMs = decoded.exp * 1000 - Date.now(); + if (expiresInMs <= REFRESH_MARGIN_MS) { + return await refreshAccessToken(); + } + + return token; + } + + function parseJwt(token) { + if (!token || typeof token !== "string") return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const json = decodeURIComponent( + atob(payload) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + return JSON.parse(json); + } catch { + return null; + } + } + + async function login({ email, password }) { + const res = await fetch("/api/v2/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + if (!res.ok) { + const message = data?.detail || data?.message || "Login failed"; + throw new Error(message); + } + + setTokens({ + access_token: data.access_token || "", + refresh_token: data.refresh_token || "", + }); + + return data; + } + + async function logout() { + const refresh_token = getRefreshToken(); + const access_token = getAccessToken(); + clearTokens(); + + if (refresh_token && access_token) { + try { + await fetch("/api/v2/auth/logout", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${access_token}`, + }, + body: JSON.stringify({ refresh_token }), + }); + } catch { + // ignore logout errors + } + } + } + + async function authedFetch(url, options = {}) { + const accessToken = await getValidAccessToken(); + if (!accessToken) { + clearTokens(); + window.location.href = "/admin/login"; + return Promise.reject(new Error("Unauthorized")); + } + + const headers = new Headers(options.headers || {}); + headers.set("Authorization", `Bearer ${accessToken}`); + if (!headers.has("Accept")) { + headers.set("Accept", "application/json"); + } + + const res = await fetch(url, { ...options, headers }); + + if (res.status === 401) { + const refreshed = await refreshAccessToken(); + if (!refreshed) { + clearTokens(); + window.location.href = "/admin/login"; + return Promise.reject(new Error("Unauthorized")); + } + + headers.set("Authorization", `Bearer ${refreshed}`); + return fetch(url, { ...options, headers }); + } + + return res; + } + + return { + getAccessToken, + getRefreshToken, + isAuthenticated, + clearTokens, + setTokens, + login, + logout, + authedFetch, + }; +})(); diff --git a/app/static/2026/js/admin-login.js b/app/static/2026/js/admin-login.js new file mode 100644 index 0000000..d937ce1 --- /dev/null +++ b/app/static/2026/js/admin-login.js @@ -0,0 +1,50 @@ +/* ═══════════════════════════════════════════════ + PyCon Togo 2026 - Admin Login Page + ═══════════════════════════════════════════════ */ + +(function () { + const form = document.getElementById("adminLoginForm"); + const emailInput = document.getElementById("adminEmail"); + const passwordInput = document.getElementById("adminPassword"); + const errorBox = document.getElementById("adminLoginError"); + + if (!form) return; + + function showError(message) { + if (!errorBox) return; + errorBox.textContent = message; + errorBox.style.display = "block"; + } + + function clearError() { + if (!errorBox) return; + errorBox.textContent = ""; + errorBox.style.display = "none"; + } + + async function onSubmit(e) { + e.preventDefault(); + clearError(); + + const email = (emailInput?.value || "").trim(); + const password = passwordInput?.value || ""; + + if (!email || !password) { + showError("Email and password are required."); + return; + } + + const submitBtn = form.querySelector(".admin-login-submit"); + if (submitBtn) submitBtn.disabled = true; + + try { + await AdminAuth.login({ email, password }); + window.location.href = "/admin/registrations"; + } catch (err) { + showError(err.message || "Login failed."); + if (submitBtn) submitBtn.disabled = false; + } + } + + form.addEventListener("submit", onSubmit); +})(); diff --git a/app/static/2026/js/admin-registrations.js b/app/static/2026/js/admin-registrations.js new file mode 100644 index 0000000..13e314c --- /dev/null +++ b/app/static/2026/js/admin-registrations.js @@ -0,0 +1,208 @@ +/* ═══════════════════════════════════════════════ + PyCon Togo 2026 - Admin Registrations + ═══════════════════════════════════════════════ */ + +(function () { + const API_URL = "/api/v2/registrations"; + let allRegistrations = []; + let currentFilter = ""; + + function t(key, fallback) { + if (typeof translations !== "undefined" && translations[currentLang || "en"]) { + const keys = key.split("."); + let value = translations[currentLang || "en"]; + for (const k of keys) { + value = value && value[k]; + if (!value) return fallback; + } + return value; + } + return fallback; + } + + async function loadRegistrations() { + const loadingRow = document.getElementById("registrationsLoading"); + const errorRow = document.getElementById("registrationsError"); + const emptyRow = document.getElementById("registrationsEmpty"); + const errorText = document.getElementById("registrationsErrorText"); + + if (loadingRow) loadingRow.style.display = ""; + if (errorRow) errorRow.style.display = "none"; + if (emptyRow) emptyRow.style.display = "none"; + + try { + const res = await AdminAuth.authedFetch(API_URL, { + headers: { "Accept": "application/json" }, + }); + + if (res.status === 403) { + throw new Error(t("errors.adminRequired", "Admin access required")); + } + if (res.status === 500) { + throw new Error(t("errors.retrieving", "Error retrieving registrations")); + } + if (!res.ok) { + throw new Error(t("errors.unexpected", "Unexpected error")); + } + + allRegistrations = await res.json(); + renderRegistrations(allRegistrations); + } catch (err) { + console.error("Failed to load registrations:", err); + if (loadingRow) loadingRow.style.display = "none"; + if (errorRow) { + errorRow.style.display = ""; + errorText.textContent = err.message || t("errors.loadFailed", "Failed to load registrations."); + } + } + } + + function formatDate(iso) { + if (!iso) return ""; + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString(currentLang === "fr" ? "fr-FR" : "en-US", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } + } + + function renderRegistrations(list) { + const tbody = document.getElementById("registrationsBody"); + const loadingRow = document.getElementById("registrationsLoading"); + const errorRow = document.getElementById("registrationsError"); + const emptyRow = document.getElementById("registrationsEmpty"); + const countEl = document.getElementById("registrationsCount"); + + if (!tbody) return; + + if (loadingRow) loadingRow.style.display = "none"; + if (errorRow) errorRow.style.display = "none"; + + const filtered = currentFilter + ? list.filter((r) => (r.ticket_type || "") === currentFilter) + : list; + + if (countEl) { + const total = filtered.length; + const label = + total === 1 + ? t("admin.registrationSingular", "1 registration") + : t("admin.registrationPlural", "{count} registrations"); + countEl.textContent = label.replace("{count}", String(total)); + } + + tbody.innerHTML = ""; + + if (filtered.length === 0) { + const tr = document.createElement("tr"); + tr.innerHTML = + `
Connectez-vous pour gérer les inscriptions
+Liste des participants inscrits
+| Participant | +Accès | +Type de billet | +Quantité | +Statut paiement | +Réf. paiement | +Créé le | +
|---|---|---|---|---|---|---|
| Chargement des inscriptions... | +||||||
+ Nous apprécions votre opinion. Dites-nous ce que vous avez aimé et comment nous pourrions nous améliorer. Votre réponse restera anonyme. +
+