diff --git a/app/routers/router_2026.py b/app/routers/router_2026.py index 8e761a5..7c4722f 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") 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/feedback.js b/app/static/2026/js/feedback.js new file mode 100644 index 0000000..00b63d6 --- /dev/null +++ b/app/static/2026/js/feedback.js @@ -0,0 +1,148 @@ +(function() { + "use strict"; + + var form = document.getElementById("feedbackForm"); + if (!form) return; + + var apiUrl = "https://api.pycontg.pytogo.org/api/feedback/"; + var submitBtn = document.getElementById("submitBtn"); + var resetBtn = document.getElementById("resetBtn"); + var statusEl = document.getElementById("formStatus"); + var statusMsg = statusEl.querySelector(".feedback-status-msg"); + var feedbackIsOpen = form.dataset.feedbackOpen === "true"; + + var i18n = { + fr: { + required: "Veuillez remplir les champs requis.", + days_required: "Veuillez sélectionner au moins un jour.", + not_open: "Le formulaire n'est pas encore ouvert.", + sending: "Envoi…", + success: "Merci ! Votre retour a été envoyé.", + error: "Une erreur est survenue. Veuillez réessayer plus tard.", + network: "Erreur réseau. Veuillez vérifier votre connexion." + }, + en: { + required: "Please fill in the required fields.", + days_required: "Please select at least one day.", + not_open: "The feedback form is not yet open.", + sending: "Sending…", + success: "Thank you! Your feedback has been sent.", + error: "An error occurred. Please try again later.", + network: "Network error. Please check your connection." + } + }; + + function getLang() { + var docLang = (document.documentElement.lang || "fr").toLowerCase(); + return docLang.startsWith("en") ? "en" : (docLang.startsWith("fr") ? "fr" : "fr"); + } + + function showStatus(success, text) { + statusEl.className = "feedback-status " + (success ? "success" : "error"); + statusMsg.textContent = text; + statusEl.style.display = "flex"; + } + + function hideStatus() { + statusEl.style.display = "none"; + statusMsg.textContent = ""; + } + + function setDaysError(show) { + var daysError = document.querySelector(".feedback-checkbox-error"); + if (!daysError) return; + daysError.style.display = show ? "block" : "none"; + } + + function daysSelected(fd) { + return fd.getAll("days").length > 0; + } + + var dayCheckboxes = document.querySelectorAll('input[type="checkbox"][name="days"]'); + dayCheckboxes.forEach(function(cb) { + cb.addEventListener("change", function() { + if (daysSelected(new FormData(form))) setDaysError(false); + }); + }); + + if (resetBtn) { + resetBtn.addEventListener("click", function() { + form.reset(); + setDaysError(false); + hideStatus(); + }); + } + + form.addEventListener("submit", async function(e) { + e.preventDefault(); + hideStatus(); + + var lang = getLang(); + var t = i18n[lang] || i18n.en; + + if (!feedbackIsOpen) { + showStatus(false, t.not_open); + return; + } + + var fd = new FormData(form); + + var payload = { + sex: fd.get("sex") || null, + age: fd.get("age") || null, + profession: (fd.get("profession") || "").trim() || null, + country: (fd.get("country") || "").trim() || null, + python_level: fd.get("python_level") || null, + days: fd.getAll("days"), + heard: fd.get("heard") || null, + rating: fd.get("rating") ? Number(fd.get("rating")) : null, + overall: (fd.get("overall") || "").trim() || null, + favorite: (fd.get("favorite") || "").trim() || null, + improvements: (fd.get("improvements") || "").trim() || null, + comments: (fd.get("comments") || "").trim() || null + }; + + if (!payload.days || payload.days.length === 0) { + setDaysError(true); + showStatus(false, t.days_required || t.required); + return; + } else { + setDaysError(false); + } + + if (!payload.sex && !payload.age && !payload.profession && !payload.heard && !payload.overall && !payload.favorite) { + showStatus(false, t.required); + return; + } + + submitBtn.disabled = true; + var originalText = submitBtn.textContent; + submitBtn.textContent = t.sending; + + try { + var res = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + showStatus(true, t.success); + form.reset(); + setDaysError(false); + } else { + var msg = t.error; + try { + var data = await res.json(); + if (data && data.message) msg = data.message + " - " + msg; + } catch (err) {} + showStatus(false, msg); + } + } catch (err) { + showStatus(false, t.network); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = originalText; + } + }); +})(); diff --git a/app/templates/2026/2026_feedback.html b/app/templates/2026/2026_feedback.html new file mode 100644 index 0000000..fed688c --- /dev/null +++ b/app/templates/2026/2026_feedback.html @@ -0,0 +1,183 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ +
+
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/2026/base.html b/app/templates/2026/base.html index 1e1d11e..a48b196 100644 --- a/app/templates/2026/base.html +++ b/app/templates/2026/base.html @@ -42,7 +42,7 @@ Home @@ -155,6 +157,9 @@ data-i18n-fr="Visiter Togo">Visit Togo Contact + Feedback
@@ -245,6 +250,7 @@
Navigation
Sponsors Team Job Board + Feedback Contact