Fix mobile nav/TOC/overflow on article pages + harden membership form

Mobile fixes (article, guide, get-to-know, projects pages):
- Remove duplicate main.js include site-wide. footer.js emitted a
  <script src=main.js> inside the footer block while pages also load one
  before </body>, so DOMContentLoaded ran twice and every tap toggled the
  mobile menu / article TOC twice (net no-op). Menu and TOC only worked on
  the homepage. Now exactly one main.js per page, before </body>.
- Kill horizontal overflow: add overflow-x: clip to html/body (clips the
  off-canvas mobile nav panel without breaking position: sticky, unlike
  overflow-x: hidden).
- Reset align-items to stretch in the mobile .article-layout-container
  rule; the desktop flex-start made .article-container size to max-content
  width in a column flexbox and overflow the viewport.

Membership form hardening (Become a Member):
- Honeypot field + localStorage cooldown (30s gap, max 3/24h) + stronger
  email regex; trilingual cooldown alert message.
- Apps Script (setup doc): per-email auto-reply dedupe + daily send cap;
  applicant reply is a plain thank-you that never echoes submitted answers.
- Draft HTML welcome email (email-welcome-preview.html) for later use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
popovskikandClaude Opus 4.8 committed 2026-08-01 10:59:55 +02:00
1 parent 7bc249e8a4
commit efb3c6ea39
196 files changed
+83 -203

No files matched your search

+38 -2
View File
@@ -117,7 +117,7 @@
}
next.addEventListener('click', function () {
if (!validate()) { alertBox.hidden = false; return; }
if (!validate()) { alertBox.textContent = alertBox.getAttribute('data-required-msg') || alertBox.textContent; alertBox.hidden = false; return; }
if (state.idx < state.order.length - 1) { state.idx++; render(); form.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
});
back.addEventListener('click', function () {
@@ -172,10 +172,46 @@
try { fetch(GOOGLE_FORM.action, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }); } catch (e) { }
}
/* ---- soft anti-abuse: honeypot + localStorage cooldown/cap ----
A static site cannot rate-limit by IP (no backend). These are client-side
soft limits that stop casual flooding and bots; the Apps Script adds a
server-side per-email dedupe and daily cap as the real safety net. */
var hp = form.querySelector('.ms-hp input');
var LS_KEY = 'msos_member_submits';
var MIN_GAP_MS = 30 * 1000; // at least 30s between submissions
var WINDOW_MS = 24 * 60 * 60 * 1000; // rolling 24h window
var MAX_IN_WINDOW = 3; // max 3 submissions / 24h from this browser
function recentSubmits() {
var now = Date.now(), arr;
try { arr = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch (e) { arr = []; }
if (!Array.isArray(arr)) arr = [];
return arr.filter(function (t) { return typeof t === 'number' && (now - t) < WINDOW_MS; });
}
function coolingDown() {
var arr = recentSubmits(), now = Date.now();
if (arr.length >= MAX_IN_WINDOW) return true;
if (arr.length && (now - Math.max.apply(null, arr)) < MIN_GAP_MS) return true;
return false;
}
function recordSubmit() {
var arr = recentSubmits(); arr.push(Date.now());
try { localStorage.setItem(LS_KEY, JSON.stringify(arr)); } catch (e) { }
}
function showAlert(kind) {
var msg = alertBox.getAttribute(kind === 'cooldown' ? 'data-cooldown-msg' : 'data-required-msg');
if (msg) alertBox.textContent = msg;
alertBox.hidden = false;
}
submit.addEventListener('click', function () {
if (!validate()) { alertBox.hidden = false; return; }
// Bot trap: honeypot must stay empty. Silently succeed (no feedback for bots).
if (hp && hp.value) { form.hidden = true; success.hidden = false; return; }
if (!validate()) { showAlert('required'); return; }
if (coolingDown()) { showAlert('cooldown'); return; }
submit.disabled = true;
var d = collect();
sendToForm(d);
recordSubmit();
form.hidden = true;
var t = success.querySelector('.ms-success-title'), p = success.querySelector('.ms-success-text');
t.textContent = d.type === 'student' ? t.getAttribute('data-student-title') : t.getAttribute('data-supporter-title');