Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions app/routers/router_2026.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
145 changes: 145 additions & 0 deletions app/static/2026/css/pages/feedback.css
Original file line number Diff line number Diff line change
@@ -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; }
}
148 changes: 148 additions & 0 deletions app/static/2026/js/feedback.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
})();
Loading