Added presenter mode for the event

This commit is contained in:
dschmidt committed 2026-06-09 18:23:04 +02:00
1 parent d9408d474e
commit 21a79f44a8
3 files changed
+1261

No files matched your search

+5
View File
@@ -1209,6 +1209,11 @@ def display():
return render_template('display.html', questions=QUESTIONS)
@app.route('/presenter')
def presenter():
return render_template('presenter.html', questions=QUESTIONS)
# Per-question bubble chart — linked from the bottom strip on the display page
@app.route('/q/<int:q_id>')
def question_view(q_id):
+426
View File
@@ -0,0 +1,426 @@
services:
# ------------------------------------------------------
# Traefik Reverse Proxy
# ------------------------------------------------------
traefik:
image: traefik:v2.11
container_name: traefik
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=inventor@txproducts.de"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
# ------------------------------------------------------
# Tika (Office text extraction)
# ------------------------------------------------------
tika:
image: apache/tika:latest
container_name: tika
restart: unless-stopped
# ------------------------------------------------------
# Gotenberg (Office → PDF conversion)
# ------------------------------------------------------
gotenberg:
image: gotenberg/gotenberg:latest
container_name: gotenberg
restart: unless-stopped
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
- "--libreoffice-start-timeout=900s"
- "--chromium-start-timeout=900s"
- "--libreoffice-auto-start=true"
# ------------------------------------------------------
# Paperless-NGX
# ------------------------------------------------------
paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
container_name: paperless
restart: unless-stopped
depends_on:
- redis
- paperless_db
- tika
- gotenberg
environment:
PAPERLESS_URL: https://paperless.txproducts.de
PAPERLESS_REDIS: redis://redis:6379
PAPERLESS_DBHOST: paperless_db
PAPERLESS_DBNAME: paperless
PAPERLESS_DBUSER: paperless
PAPERLESS_DBPASS: AVeryStrong123Password
PAPERLESS_CONSUMER_RECURSIVE: "true"
PAPERLESS_CONSUMER_DELETE_DUPLICATES: "true"
PAPERLESS_EMAIL_HOST: "mail.cs537.de"
PAPERLESS_EMAIL_PORT: 465
PAPERLESS_EMAIL_HOST_USER: "system@txproducts.de"
PAPERLESS_EMAIL_HOST_PASSWORD: "OubveTtORoOmPRItNITeRbIDECUppEcT,=,42"
PAPERLESS_EMAIL_FROM: "system@txproducts.de"
PAPERLESS_EMAIL_USE_TLS: "true"
PAPERLESS_EMAIL_USE_SSL: "false"
# --- Office document support ---
PAPERLESS_TIKA_ENABLED: "true"
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
volumes:
- ./paperless/data:/usr/src/paperless/data
- ./paperless/media:/usr/src/paperless/media
- ./paperless/export:/usr/src/paperless/export
- ./paperless/consume:/usr/src/paperless/consume
labels:
- "traefik.enable=true"
- "traefik.http.routers.paperless.rule=Host(`paperless.txproducts.de`)"
- "traefik.http.routers.paperless.entrypoints=websecure"
- "traefik.http.routers.paperless.tls.certresolver=letsencrypt"
paperless_db:
image: postgres:14
restart: unless-stopped
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: AVeryStrong123Password
volumes:
- ./paperless/postgres:/var/lib/postgresql/data
redis:
image: redis:7
restart: unless-stopped
# ------------------------------------------------------
# Paperless AI
# ------------------------------------------------------
paperless_ai:
image: clusterzx/paperless-ai:latest
container_name: paperless_ai
restart: unless-stopped
environment:
PUID: 1000
PGID: 1000
# Port config
PAPERLESS_AI_PORT: 3000
# RAG engine (enabled by default)
RAG_SERVICE_URL: http://paperless_ai:8000
RAG_SERVICE_ENABLED: "true"
# Connect to Paperless
PAPERLESS_URL: https://paperless.txproducts.de
# ==== OpenRouter (Custom Provider) ====
AI_PROVIDER: custom
CUSTOM_AI_BASE_URL: https://openrouter.ai/api/v1
CUSTOM_AI_API_KEY: "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"
CUSTOM_AI_MODEL: "stepfun/step-3.5-flash:free"
# OpenRouter required headers!
HTTP_REFERER: "https://paperai.txproducts.de"
X_TITLE: "Paperless-AI"
volumes:
- ./paperless-ai/data:/app/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.paperlessai.rule=Host(`paperai.txproducts.de`)"
- "traefik.http.routers.paperlessai.entrypoints=websecure"
- "traefik.http.routers.paperlessai.tls.certresolver=letsencrypt"
# ------------------------------------------------------
# InvenTree + DB
# ------------------------------------------------------
inventree:
image: inventree/inventree:stable
container_name: inventree
restart: unless-stopped
depends_on:
- inventree_db
environment:
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: AVeryStrong123Password
INVENTREE_DB_HOST: inventree_db
INVENTREE_ADMIN_USER: admin
INVENTREE_ADMIN_PASSWORD: AVeryStrong123Password
INVENTREE_ADMIN_EMAIL: dschmidt@txproducts.de
INVENTREE_PLUGINS_ENABLED: 1
INVENTREE_PLUGIN_FILE: /home/inventree/data/plugins.txt
# Required for reverse-proxy setups
INVENTREE_SITE_URL: https://inventree.txproducts.de
CSRF_TRUSTED_ORIGINS: https://inventree.txproducts.de
ALLOWED_HOSTS: inventree.txproducts.de,localhost,127.0.0.1
volumes:
- ./inventree/data:/home/inventree/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.inventree.rule=Host(`inventree.txproducts.de`)"
- "traefik.http.routers.inventree.entrypoints=websecure"
- "traefik.http.routers.inventree.tls.certresolver=letsencrypt"
# Static files service
- "traefik.http.services.inventree.loadbalancer.server.port=8000"
inventree_db:
image: postgres:14
restart: unless-stopped
environment:
POSTGRES_DB: inventree
POSTGRES_USER: inventree
POSTGRES_PASSWORD: AVeryStrong123Password
volumes:
- ./inventree/postgres:/var/lib/postgresql/data
inventree_worker:
image: inventree/inventree:stable
container_name: inventree_worker
command: invoke worker
restart: unless-stopped
depends_on:
- inventree
environment:
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: AVeryStrong123Password
INVENTREE_DB_HOST: inventree_db
INVENTREE_SITE_URL: https://inventree.txproducts.de
CSRF_TRUSTED_ORIGINS: https://inventree.txproducts.de
volumes:
- ./inventree/data:/home/inventree/data
# ------------------------------------------------------
# Nextcloud + DB
# ------------------------------------------------------
nextcloud-db:
image: mariadb:11
container_name: nextcloud-db
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
environment:
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: A_Very_Strong_DB_Password
MYSQL_ROOT_PASSWORD: A_Very_Strong_Root_Password
volumes:
- ./nextcloud/db:/var/lib/mysql
nextcloud:
image: nextcloud:latest
container_name: nextcloud
restart: unless-stopped
depends_on:
- nextcloud-db
- redis
environment:
# DB connection
MYSQL_HOST: nextcloud-db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: A_Very_Strong_DB_Password
REDIS_HOST: redis
# URL & proxy config
#NEXTCLOUD_ADMIN_USER: admin
#NEXTCLOUD_ADMIN_PASSWORD: 97v2*r7APuS^RV3d$YPU
NEXTCLOUD_TRUSTED_DOMAINS: cloud.txproducts.de
OVERWRITEHOST: cloud.txproducts.de
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: https://cloud.txproducts.de
TRUSTED_PROXIES: 172.16.0.0/12
volumes:
- ./nextcloud/html:/var/www/html
- ./nextcloud/data:/var/www/html/data
- ./paperless:/paperless
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextcloud.rule=Host(`cloud.txproducts.de`)"
- "traefik.http.routers.nextcloud.entrypoints=websecure"
- "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
# Optional: increase timeouts for large uploads
- "traefik.http.services.nextcloud.loadbalancer.server.port=80"
- "traefik.http.routers.nextcloud-http.entrypoints=web"
- "traefik.http.routers.nextcloud-http.rule=Host(`cloud.txproducts.de`)"
- "traefik.http.routers.nextcloud-http.middlewares=nextcloud-redirect"
- "traefik.http.middlewares.nextcloud-redirect.redirectscheme.scheme=https"
nextcloud-cron:
image: nextcloud:latest
container_name: nextcloud-cron
restart: unless-stopped
volumes:
- ./nextcloud/html:/var/www/html
- ./nextcloud/data:/var/www/html/data
entrypoint: /cron.sh
depends_on:
- nextcloud-db
- redis
talk-hpb:
image: ghcr.io/nextcloud-releases/aio-talk:latest
container_name: nextcloud-talk-hpb
restart: unless-stopped
init: true
labels:
- "traefik.enable=true"
# 1. HTTP Router (Redirect HTTP to HTTPS)
- "traefik.http.routers.talk-http.entrypoints=web"
- "traefik.http.routers.talk-http.rule=Host(`signal.cloud.txproducts.de`)"
- "traefik.http.routers.talk-http.middlewares=nextcloud-redirect"
# 2. HTTPS Router (Secure WebSocket Access)
- "traefik.http.routers.talk-https.entrypoints=websecure"
- "traefik.http.routers.talk-https.rule=Host(`signal.cloud.txproducts.de`)"
- "traefik.http.routers.talk-https.tls=true"
- "traefik.http.routers.talk-https.tls.certresolver=letsencrypt"
# The Talk service is available internally on port 8081
- "traefik.http.services.talk-hpb.loadbalancer.server.port=8081"
environment:
- NC_DOMAIN=cloud.txproducts.de
- TALK_HOST=signal.cloud.txproducts.de
- TALK_PORT=3478
- TURN_SECRET=1ph34w9hrs0gka5xi2qfbn2ti48390
- SIGNALING_SECRET=avzeyam5ak1e4a4xseak2m43kih3vp
- INTERNAL_SECRET=e5hr4dgxnjbsl71kr1stkubqyrz3zh
ports:
# Expose the TURN ports (required for external clients)
- "3478:3478/tcp"
- "3478:3478/udp"
# Expose the internal signaling port for the reverse proxy
- "8081:8081/tcp"
depends_on:
- nextcloud
nextcloud-whiteboard:
image: ghcr.io/nextcloud-releases/whiteboard:stable
container_name: nextcloud-whiteboard
restart: unless-stopped
environment:
NEXTCLOUD_URL: https://cloud.txproducts.de
JWT_SECRET_KEY: "d7a6599a3864220838281e1796fb06cb90441519a39621ce077a87f80b9c96a6"
labels:
- "traefik.enable=true"
- "traefik.http.routers.whiteboard.rule=Host(`cloud.txproducts.de`) && PathPrefix(`/whiteboard`)"
- "traefik.http.routers.whiteboard.entrypoints=websecure"
- "traefik.http.routers.whiteboard.tls.certresolver=letsencrypt"
- "traefik.http.services.whiteboard.loadbalancer.server.port=3002"
- "traefik.http.middlewares.strip-whiteboard.stripprefix.prefixes=/whiteboard"
- "traefik.http.routers.whiteboard.middlewares=strip-whiteboard"
depends_on:
- nextcloud
# ------------------------------------------------------
# Gitea
# ------------------------------------------------------
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
depends_on:
- gitea_db
environment:
USER_UID: 1000
USER_GID: 1000
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: gitea_db:5432
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: AVeryStrongGiteaPassword
GITEA__server__DOMAIN: git.txproducts.de
GITEA__server__ROOT_URL: https://git.txproducts.de
GITEA__server__HTTP_PORT: 3000
GITEA__server__SSH_DOMAIN: git.txproducts.de
GITEA__server__SSH_PORT: 2222
GITEA__mailer__ENABLED: true
GITEA__mailer__PROTOCOL: smtps
GITEA__mailer__SMTP_ADDR: mail.cs537.de
GITEA__mailer__SMTP_PORT: 465
GITEA__mailer__USER: system@txproducts.de
GITEA__mailer__PASSWD: OubveTtORoOmPRItNITeRbIDECUppEcT,=,42
GITEA__mailer__FROM: system@txproducts.de
GITEA__service__REGISTER_EMAIL_CONFIRM: true
GITEA__service__DISABLE_REGISTRATION: false
GITEA__service__ALLOW_ONLY_INTERNAL_REGISTRATION: true
GITEA__service__ENABLE_NOTIFY_MAIL: true
volumes:
- ./gitea/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "222:22" # SSH access for git push/pull
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`git.txproducts.de`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
- "traefik.http.routers.gitea-http.entrypoints=web"
- "traefik.http.routers.gitea-http.rule=Host(`git.txproducts.de`)"
- "traefik.http.routers.gitea-http.middlewares=gitea-redirect"
- "traefik.http.middlewares.gitea-redirect.redirectscheme.scheme=https"
gitea_db:
image: postgres:14
container_name: gitea_db
restart: unless-stopped
environment:
POSTGRES_DB: gitea
POSTGRES_USER: gitea
POSTGRES_PASSWORD: AVeryStrongGiteaPassword
volumes:
- ./gitea/postgres:/var/lib/postgresql/data
# ------------------------------------------------------
# HiActs Conference Poll
# ------------------------------------------------------
hiacts-poll:
build:
context: ./poll
dockerfile_inline: |
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5001
CMD ["python", "app.py"]
container_name: hiacts-poll
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.hiacts-poll.rule=Host(`poll.txproducts.de`)"
- "traefik.http.routers.hiacts-poll.entrypoints=websecure"
- "traefik.http.routers.hiacts-poll.tls.certresolver=letsencrypt"
- "traefik.http.services.hiacts-poll.loadbalancer.server.port=5001"
networks:
default:
driver: bridge
enable_ipv6: true
ipam:
config:
- subnet: "172.20.0.0/16"
- subnet: "fd01::/80"
+830
View File
@@ -0,0 +1,830 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HiActs Live Results - TXproducts</title>
<!-- D3.js library — used to draw the bubble chart -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
/* ── RESET: removes default browser spacing on all elements ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ── BODY: full screen, side-by-side layout on desktop ── */
body {
background: #f0f4f8;
color: #2d3748;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
font-size: 14px;
}
/* ── MOBILE / TABLET RESPONSIVENESS ── */
@media (max-width: 768px) {
body { overflow: auto; height: auto; }
.sidebar { display: none !important; }
.right-col { height: auto !important; overflow: visible !important; }
#phaseResults { flex-direction: column; overflow: visible; height: auto; }
/* Hide the mixed bubble chart and horizontal bottom strip on mobile */
#chart-container { display: none !important; }
#bottomStrip { display: none !important; }
.analysis-btn { display: none !important; }
/* Always show analysis panel on mobile */
#analysisPanel { display: flex !important; overflow: visible; padding: 14px 14px 40px; }
.analysis-grid { grid-template-columns: 1fr !important; }
/* Top bar adjustments */
.top-bar { grid-template-columns: auto 1fr !important; }
.top-bar-right { padding-right: 12px; gap: 8px; }
.top-bar-event { padding: 10px 14px; }
.top-bar-event-title { font-size: 12px; }
.top-bar-event-meta { font-size: 8px; }
.top-bar-center h1 { font-size: 15px; }
.top-bar-center .meta { font-size: 12px; }
.counter-number { font-size: 22px !important; }
.counter-label { font-size: 9px; }
}
/* ── SIDEBAR: left panel with event branding (desktop only) ── */
.sidebar {
width: 300px; flex-shrink: 0;
background: white; border-right: 1px solid #dde5ee;
display: flex; flex-direction: column; height: 100vh;
}
/* Dark blue gradient header inside sidebar */
.event-header {
flex-shrink: 0;
background: linear-gradient(160deg, #0f2744 0%, #1a4a8a 60%, #1d6fa8 100%);
padding: 28px 22px 24px;
color: white;
text-align: center;
}
.event-badge {
font-size: 10px; font-weight: 700;
letter-spacing: 1.4px; text-transform: uppercase;
opacity: 0.6; margin-bottom: 14px;
}
.event-title {
font-size: 28px; font-weight: 900;
line-height: 1.12; margin-bottom: 6px;
letter-spacing: -0.3px;
}
.event-title span { color: #5db8f5; }
.event-subtitle {
font-size: 11.5px; font-weight: 500;
opacity: 0.7; margin-bottom: 16px; line-height: 1.4;
}
.event-meta {
display: flex; align-items: center; justify-content: center; gap: 6px;
font-size: 10.5px; opacity: 0.55; letter-spacing: 0.3px;
}
.event-meta-dot { width: 3px; height: 3px; background: white; border-radius: 50%; opacity: 0.5; }
/* Narrative text block inside sidebar */
.message-area { flex-shrink: 0; padding: 20px 22px 0; }
.sidebar-mid { flex: 1; display: flex; align-items: center; padding: 0 22px 20px; }
.message-narrative {
font-size: 13px; color: #334155; font-weight: 400;
line-height: 1.7; flex: 1;
}
.message-narrative p { margin-bottom: 12px; }
.message-narrative p:last-child { margin-bottom: 0; }
.message-narrative strong { color: #1a365d; font-weight: 600; }
/* Navigation links inside sidebar */
.sidebar-nav { padding: 18px 0 0; display: flex; flex-direction: column; gap: 6px; }
.snav-btn {
display: block; text-decoration: none;
font-size: 12px; font-weight: 600;
color: #4a5568; background: #f7fafc;
border: 1px solid #dde5ee; border-radius: 8px;
padding: 7px 12px; text-align: center;
transition: background 0.12s, color 0.12s;
}
.snav-btn:hover { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
.snav-btn.active { background: #eff6ff; color: #2563eb; border-color: #2563eb; font-weight: 700; }
/* ── BODY: full screen, no sidebar ── */
.right-col { flex: 1; display: flex; flex-direction: column; overflow: hidden; height: 100vh; }
/* ── TOP BAR: gradient left column, tagline centre, controls right ── */
.top-bar {
flex-shrink: 0; display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
background: white; border-bottom: 1px solid #dde5ee; gap: 0;
}
/* Gradient left column — same style as poll.html */
.top-bar-event {
background: linear-gradient(160deg, #0f2744 0%, #1a4a8a 60%, #1d6fa8 100%);
color: white; padding: 14px 20px; text-align: center;
display: flex; flex-direction: column; justify-content: center; gap: 4px;
}
.top-bar-event-title { font-size: 15px; font-weight: 900; line-height: 1.15; letter-spacing: -0.3px; }
.top-bar-event-title span { color: #5db8f5; }
.top-bar-event-meta { font-size: 9px; color: #f5c842; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; line-height: 1.5; }
.top-bar-center { text-align: center; padding: 12px 20px; display: flex; flex-direction: column; justify-content: center; }
.top-bar-center h1 { font-size: 20px; font-weight: 700; color: #1a365d; }
.top-bar-center .meta { font-size: 15px; color: #8a9baf; margin-top: 4px; }
.top-bar-right { display: flex; align-items: center; justify-content: flex-end; gap: 14px; padding-right: 20px; }
/* Green pulsing "Live" badge */
.live-badge {
display: flex; align-items: center; gap: 6px;
background: #f0fdf4; border: 1px solid #86efac;
border-radius: 20px; padding: 4px 12px;
}
.live-dot { width: 7px; height: 7px; background: #16a34a; border-radius: 50%; animation: blink 1.4s infinite; }
.live-text { font-size: 11px; font-weight: 700; color: #16a34a; letter-spacing: 0.8px; text-transform: uppercase; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* Large response counter (top right) */
.counter-block { text-align: center; }
.counter-number {
font-size: 32px; font-weight: 900; line-height: 1;
background: linear-gradient(135deg, #2563eb, #059669);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.counter-label { font-size: 10px; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; }
/* Toggle button: switches between bubble chart and per-question analysis */
.analysis-btn {
background: linear-gradient(135deg, #2563eb, #7c3aed);
color: white; border: none; border-radius: 8px;
padding: 10px 22px; font-size: 15px; font-weight: 900;
cursor: pointer; font-family: inherit; display: none;
white-space: nowrap; letter-spacing: 0.8px; text-transform: uppercase;
}
.analysis-btn.visible { display: block; }
/* ── PHASE 1: Question overview (NOT used in production — hidden automatically) ── */
#phaseQuestions {
flex: 1; overflow-y: auto;
padding: 18px 24px 80px;
display: flex; flex-direction: column; gap: 14px;
}
.qs-header { font-size: 12px; font-weight: 700; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; }
.qs-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.q-card {
background: white; border-radius: 14px; border: 1.5px solid #dde5ee;
padding: 14px 16px; display: flex; flex-direction: column; gap: 10px;
}
.q-card-header { display: flex; align-items: flex-start; gap: 10px; }
.q-badge {
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800; color: white; flex-shrink: 0; margin-top: 1px;
}
.q-card-text { font-size: 13px; font-weight: 700; color: #1a365d; line-height: 1.4; }
.q-options { display: flex; flex-direction: column; gap: 4px; }
.q-option { font-size: 11.5px; color: #4a5568; padding: 4px 10px; background: #f7fafc; border-radius: 6px; border-left: 3px solid #e2e8f0; line-height: 1.3; }
.q-examples { display: flex; flex-wrap: wrap; gap: 6px; }
.q-example-chip { font-size: 11px; color: #2563eb; background: #eff6ff; border-radius: 20px; padding: 3px 10px; font-style: italic; }
.submit-row { display: flex; justify-content: center; padding-top: 4px; }
.big-submit-btn {
background: linear-gradient(135deg, #2563eb, #059669);
color: white; border: none; border-radius: 14px;
padding: 15px 40px; font-size: 16px; font-weight: 700;
cursor: pointer; font-family: inherit;
transition: opacity 0.15s, transform 0.08s;
}
.big-submit-btn:active { transform: scale(0.98); }
.big-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── PHASE 2: Live results view (always shown in production) ── */
#phaseResults { display: none; flex: 1; flex-direction: column; overflow: hidden; }
#phaseResults.active { display: flex; }
/* Bubble chart area */
#chart-container { flex: 1; position: relative; overflow: hidden; background: white; }
#chart { width: 100%; height: 100%; }
/* Shown when there are no responses yet */
.empty-state {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%,-50%); text-align: center; pointer-events: none;
}
.empty-state h2 { font-size: 16px; font-weight: 700; color: #c5d2de; margin-bottom: 6px; }
.empty-state p { font-size: 14px; color: #d6e0ea; }
/* QR code overlay — top-right corner of the chart, 20% of chart width */
.presenter-qr {
position: absolute;
top: 16px;
right: 16px;
width: 20%;
aspect-ratio: 1 / 1;
border-radius: 12px;
background: white;
padding: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
z-index: 10;
pointer-events: none;
}
/* ── BOTTOM STRIP: coloured bar showing leading answer per question ── */
#bottomStrip {
flex-shrink: 0; background: #f0f4f8; border-top: 1px solid #dde5ee;
padding: 8px 16px; display: flex; gap: 8px; align-items: stretch;
}
.bs-item {
display: flex; align-items: center; gap: 8px;
border-radius: 10px; padding: 8px 12px;
flex: 1; min-width: 0;
text-decoration: none; cursor: pointer;
transition: filter 0.15s, transform 0.1s;
}
.bs-item:hover { filter: brightness(1.12); transform: translateY(-1px); }
.bs-q { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 800; color: white; flex-shrink: 0; background: rgba(255,255,255,0.25); }
.bs-body { display: flex; flex-direction: column; min-width: 0; }
.bs-label { font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; margin-bottom: 2px; color: rgba(255,255,255,0.7); }
.bs-text { font-size: 11.5px; font-weight: 700; color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── ANALYSIS PANEL: per-question ranked results (shown when "Show Analysis" is clicked) ── */
#analysisPanel {
display: none; flex: 1; flex-direction: column;
overflow-y: auto; padding: 18px 24px 80px;
gap: 14px; background: #f0f4f8;
}
#analysisPanel.active { display: flex; }
.analysis-header { font-size: 12px; font-weight: 700; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
.analysis-subheader { font-size: 11px; color: #a0aec0; margin-bottom: 14px; }
/* 2-column grid of question result cards (1 column on mobile) */
.analysis-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
/* Individual question result card */
.a-card {
background: white; border-radius: 14px; border: 1.5px solid #dde5ee;
padding: 16px; display: flex; flex-direction: column; gap: 12px;
}
.a-card-header { display: flex; align-items: flex-start; gap: 10px; }
.a-question { font-size: 13px; font-weight: 700; color: #1a365d; line-height: 1.4; flex: 1; }
.a-divider { height: 1px; background: #e8eef4; }
.a-answers { display: flex; flex-direction: column; gap: 8px; }
/* Each ranked answer row (rank label + answer text + bar + count) */
.a-row { display: flex; align-items: center; gap: 8px; }
.a-rank { font-size: 11px; font-weight: 800; color: #8a9baf; width: 16px; flex-shrink: 0; text-align: right; }
.a-rank-1 { color: #d97706; }
.a-rank-2 { color: #64748b; }
.a-rank-3 { color: #92400e; }
.a-text { font-size: 12.5px; color: #1a365d; flex: 1; line-height: 1.3; }
.a-bar-wrap { width: 80px; flex-shrink: 0; height: 7px; background: #e8eef4; border-radius: 4px; overflow: hidden; }
.a-bar { height: 100%; border-radius: 4px; transition: width 0.6s ease; }
.a-val { font-size: 11px; font-weight: 700; color: #4a5568; flex-shrink: 0; width: 20px; text-align: right; }
.bubble-group { cursor: default; }
/* ── EMAIL CAPTURE MODAL ── */
#emailModal {
display: none; position: fixed; inset: 0;
background: rgba(10, 20, 50, 0.6);
z-index: 999; align-items: center; justify-content: center;
backdrop-filter: blur(3px);
}
#emailModal.open { display: flex; }
.email-modal-box {
background: white; border-radius: 20px;
padding: 28px 32px; max-width: 460px; width: 90%;
max-height: 90vh; overflow-y: auto;
box-shadow: 0 32px 80px rgba(0,0,0,0.35);
animation: modalIn 0.25s ease;
}
@keyframes modalIn {
from { transform: translateY(18px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.em-eyebrow { font-size: 11px; font-weight: 700; color: #2563eb; letter-spacing: 1.2px; text-transform: uppercase; margin-bottom: 8px; }
.em-title { font-size: 18px; font-weight: 900; color: #1a365d; line-height: 1.25; margin-bottom: 8px; }
.em-body { font-size: 13px; color: #4a5568; line-height: 1.65; margin-bottom: 12px; }
.em-input {
width: 100%; padding: 13px 16px; border: 1.5px solid #dde5ee;
border-radius: 10px; font-size: 15px; font-family: inherit;
margin-bottom: 12px; outline: none; color: #1a365d;
transition: border-color 0.15s;
}
.em-input:focus { border-color: #2563eb; }
.em-input.error { border-color: #dc2626; }
.em-submit {
width: 100%; padding: 14px; font-size: 15px; font-weight: 700;
color: white; border: none; border-radius: 10px; cursor: pointer;
font-family: inherit; margin-bottom: 14px;
background: linear-gradient(135deg, #2563eb, #7c3aed);
transition: opacity 0.15s;
}
.em-submit:hover { opacity: 0.9; }
.em-confirm { display: none; text-align: center; color: #059669; font-weight: 700; font-size: 14px; margin-bottom: 12px; }
.em-skip { display: block; text-align: center; font-size: 13px; color: #8a9baf; text-decoration: none; }
.em-skip:hover { color: #4a5568; }
.em-gdpr { font-size: 11.5px; color: #718096; line-height: 1.65; margin-bottom: 14px; padding: 10px 13px; background: #f7fafc; border-radius: 8px; border: 1px solid #e2e8f0; }
.em-consent { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; font-size: 12.5px; color: #4a5568; line-height: 1.5; cursor: pointer; }
.em-consent input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; width: 15px; height: 15px; cursor: pointer; accent-color: #2563eb; }
.em-submit:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
</head>
<!--
Hi-Acts 3rd Network Meeting
Innovation Challenge Arena: Live Polling Display — Presenter View
Developed by TXproducts for DESY Hamburg, June 2026 -->
<body>
<!-- ═══════════════════════════════════════════
MAIN CONTENT AREA (full width, no sidebar)
═══════════════════════════════════════════ -->
<div class="right-col">
<!-- Top bar: gradient left column, tagline centre, controls right -->
<div class="top-bar">
<div class="top-bar-event">
<div class="top-bar-event-meta">Hi-Acts · 3rd Network Meeting</div>
<div class="top-bar-event-title">Innovation <span>Challenge</span> Arena</div>
<div class="top-bar-event-meta">Data and Software Standardization</div>
</div>
<div class="top-bar-center">
<h1>Every perspective matters.</h1>
<div class="meta">Help us gain insight across the beamline community.</div>
</div>
<div class="top-bar-right">
<!-- Button toggles between bubble chart and per-question ranked results -->
<button class="analysis-btn" id="analysisBtnEl" onclick="toggleAnalysis()">Show Analysis</button>
<div class="live-badge">
<span class="live-dot"></span>
<span class="live-text">Live</span>
</div>
<div class="counter-block">
<!-- Total number of responses received — updates automatically every 2.5 seconds -->
<div class="counter-number" id="totalCount">0</div>
<div class="counter-label">responses</div>
</div>
</div>
</div>
<!-- ── PHASE 1: Question overview ──
This section is hidden automatically on page load (see script at bottom).
It was used for demo purposes only — not shown to conference attendees. -->
<div id="phaseQuestions">
<div class="qs-header">11 questions being asked to today's participants</div>
<div class="qs-grid">
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#2563eb">Q1</div>
<div class="q-card-text">What best describes your role at the beamline?</div>
</div>
<div class="q-options">
<div class="q-option">Beamline Scientist</div>
<div class="q-option">Instrument Scientist</div>
<div class="q-option">Industry User</div>
<div class="q-option">Control System / IT</div>
<div class="q-option">Other</div>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#059669">Q2</div>
<div class="q-card-text">In one word, describe your experiment logbook today.</div>
</div>
<div class="q-examples">
<span class="q-example-chip">chaotic</span>
<span class="q-example-chip">fragmented</span>
<span class="q-example-chip">manual</span>
<span class="q-example-chip">scattered</span>
<span class="q-example-chip">analog</span>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#dc2626">Q3</div>
<div class="q-card-text">What is the biggest obstacle when moving data between beamlines or facilities?</div>
</div>
<div class="q-options">
<div class="q-option">Different control systems (TANGO, EPICS, SPOCK)</div>
<div class="q-option">Inconsistent logbook formats</div>
<div class="q-option">Missing or incomplete metadata</div>
<div class="q-option">No common file format standard</div>
<div class="q-option">Manual re-scripting for every beamline</div>
<div class="q-option">Other</div>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#d97706">Q4</div>
<div class="q-card-text">How do you feel about standardising beamline workflows?</div>
</div>
<div class="q-options">
<div class="q-option">Necessary and overdue</div>
<div class="q-option">Desirable but very difficult</div>
<div class="q-option">Too rigid for our experimental needs</div>
<div class="q-option">Not our problem to solve</div>
<div class="q-option">Other</div>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#7c3aed">Q5</div>
<div class="q-card-text">When an industry user brings a sample, how well documented is the experiment?</div>
</div>
<div class="q-options">
<div class="q-option">Fully documented, reproducible</div>
<div class="q-option">Mostly documented, some gaps</div>
<div class="q-option">Partial, depends on the scientist</div>
<div class="q-option">Barely documented</div>
<div class="q-option">Other</div>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#ea580c">Q6</div>
<div class="q-card-text">If every beamline used the same data language, what would become possible?</div>
</div>
<div class="q-examples">
<span class="q-example-chip">Automated cross-facility comparison</span>
<span class="q-example-chip">Direct data delivery without post-processing</span>
<span class="q-example-chip">Reproducible experiments across facilities</span>
</div>
</div>
</div>
<div class="submit-row">
<button class="big-submit-btn" id="submitBtn" onclick="submitAll()">
Load 10 Participant Responses &amp; See Results &rarr;
</button>
</div>
</div>
<!-- ── PHASE 2: Live results (always shown to attendees) ── -->
<div id="phaseResults">
<!-- Bubble chart — bubbles represent answer frequencies, coloured by question -->
<div id="chart-container">
<svg id="chart"></svg>
<!-- Shown while waiting for the first responses to come in -->
<div class="empty-state" id="emptyState">
<h2>Waiting for responses&hellip;</h2>
<p>Results will appear here as people answer</p>
</div>
<!-- QR code overlay — top-right corner, 20% of chart width -->
<img src="/static/attend_qr.png" alt="Scan to participate" class="presenter-qr">
</div>
<!-- Bottom strip — desktop: horizontal coloured tabs. Mobile: hidden, replaced by mobileQuestionList -->
<div id="bottomStrip"></div>
<!-- Analysis panel — shows ranked results per question (toggled by the button in the top bar) -->
<div id="analysisPanel">
<!-- Back to results link — visible on mobile only -->
<a href="/q/1" id="backToResults" style="display:none; align-items:center; gap:6px; font-size:13px; font-weight:700; color:#2563eb; text-decoration:none; margin-bottom:4px;">&#8592; Back to results</a>
<div class="analysis-header">Top 5 ranked results per question</div>
<!-- Cards are built dynamically by JavaScript when the panel is opened -->
<div class="analysis-grid" id="analysisGrid"></div>
</div>
</div>
</div>
<script>
/* ── QUESTIONS LIST — loaded from the server, never hardcoded ── */
const QUESTIONS = {{ questions | tojson }};
/* ── COLOURS: one colour per question, used in bubbles, badges, and strips ── */
const COLORS = ['#2563eb','#059669','#dc2626','#d97706','#7c3aed','#ea580c','#0891b2','#be185d','#65a30d','#0e7490','#b45309'];
/* ── BOTTOM STRIP: builds the coloured tabs at the bottom showing the leading answer per question ── */
function buildBottomStrip(bubbles) {
const strip = document.getElementById('bottomStrip');
if (!strip) return;
strip.innerHTML = '';
QUESTIONS.forEach((q, qi) => {
const color = COLORS[qi % COLORS.length];
const best = bubbles.filter(b => b.q_id === q.id).sort((a, b) => b.value - a.value)[0];
const text = best ? best.text : '—';
strip.innerHTML += `
<a href="/q/${q.id}" class="bs-item" style="background:${color}">
<div class="bs-q">Q${q.id}</div>
<div class="bs-body">
<span class="bs-label">${q.label}</span>
<span class="bs-text" title="${text}">${text}</span>
</div>
</a>`;
});
}
/* ── ANALYSIS TOGGLE ──
Switches the view between the bubble chart and the per-question ranked results panel.
The button text updates to show what clicking it will do next. ── */
let analysisVisible = false;
function toggleAnalysis() {
const btn = document.getElementById('analysisBtnEl');
analysisVisible = !analysisVisible;
if (analysisVisible) {
fetch('/api/results').then(r => r.json()).then(data => {
buildAnalysis(data.bubbles);
document.getElementById('chart-container').style.display = 'none';
document.getElementById('bottomStrip').style.display = 'none';
document.getElementById('analysisPanel').classList.add('active');
btn.textContent = 'Back to Chart';
showEmailModal();
});
} else {
document.getElementById('analysisPanel').classList.remove('active');
document.getElementById('chart-container').style.display = '';
document.getElementById('bottomStrip').style.display = '';
btn.textContent = 'Show Analysis';
}
}
/* ── BUILD ANALYSIS CARDS ──
Creates one card per question showing top 4 answers ranked by number of responses,
with a proportional bar and response count. ── */
function buildAnalysis(bubbles) {
const grid = document.getElementById('analysisGrid');
grid.innerHTML = '';
const rankLabels = ['1st', '2nd', '3rd', '4th', '5th'];
const rankColors = ['#d97706', '#64748b', '#92400e', '#94a3b8', '#cbd5e1'];
QUESTIONS.forEach((q, qi) => {
const color = COLORS[qi % COLORS.length];
const qBubs = bubbles.filter(b => b.q_id === q.id).sort((a, b) => b.value - a.value);
const top = qBubs.slice(0, 5);
const maxVal = top[0]?.value || 1;
const answersHTML = top.length ? top.map((b, i) => `
<div class="a-row">
<span class="a-rank" style="color:${rankColors[i]}">${rankLabels[i]}</span>
<span class="a-text">${b.text}</span>
<div class="a-bar-wrap">
<div class="a-bar" style="width:${(b.value / maxVal * 100).toFixed(0)}%;background:${color}"></div>
</div>
<span class="a-val">${b.value}</span>
</div>`).join('')
: '<div style="font-size:12px;color:#8a9baf;padding:4px 0">No responses yet</div>';
grid.innerHTML += `
<div class="a-card">
<div class="a-card-header">
<div class="q-badge" style="background:${color}">Q${q.id}</div>
<div class="a-question">${q.text}</div>
</div>
<div class="a-divider"></div>
<div class="a-answers">${answersHTML}</div>
</div>`;
});
}
/* ── BUBBLE CHART ──
Built using D3.js. Bubbles are sized by response count and coloured by question.
The simulation keeps bubbles separated and centred on screen. ── */
const svg = d3.select('#chart');
let width = 0, height = 0, simulation = null, currentNodes = [];
/* Recalculates chart dimensions when the window is resized */
function resize() {
const el = document.getElementById('chart-container');
width = el.clientWidth;
height = el.clientHeight;
svg.attr('width', width).attr('height', height);
if (simulation) simulation.force('center', d3.forceCenter(width/2, height/2)).alpha(0.3).restart();
}
window.addEventListener('resize', resize);
/* Calculates bubble radius based on response count relative to the maximum */
function rScale(v, max) {
return 24 + Math.sqrt(v / max) * (Math.min(width, height) * 0.145 - 24);
}
/* Wraps long answer text inside a bubble across multiple lines */
function wrapBubbleText(textEl, d) {
const r = d.r;
const fontSize = Math.max(9, Math.min(r * 0.30, 14));
const maxWidth = r * 1.65;
const charW = fontSize * 0.54;
const maxChars = Math.max(5, Math.floor(maxWidth / charW));
const words = d.text.split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (test.length > maxChars && line) { lines.push(line); line = word; }
else { line = test; }
}
if (line) lines.push(line);
const hasCount = r > 34 && d.value > 1;
const lineH = fontSize * 1.22;
const countSize = Math.max(8, Math.min(r * 0.20, 11));
const totalH = (lines.length - 1) * lineH + (hasCount ? lineH * 1.05 : 0);
const startY = -(totalH / 2) + fontSize * 0.35;
textEl.selectAll('*').remove();
textEl.attr('text-anchor','middle').attr('font-size',fontSize).attr('font-weight','700').attr('fill',d.color);
lines.forEach((ln, i) => {
textEl.append('tspan').attr('x',0).attr('y', startY + i * lineH).text(ln);
});
if (hasCount) {
textEl.append('tspan')
.attr('x',0).attr('y', startY + lines.length * lineH)
.attr('fill-opacity',0.55).attr('font-weight','400').attr('font-size',countSize)
.text(d.value === 1 ? '1 response' : `${d.value} responses`);
}
}
/* Updates the bubble chart when new data arrives from the server */
function updateChart(bubbles) {
document.getElementById('emptyState').style.display = bubbles.length ? 'none' : 'block';
if (!bubbles.length) return;
const maxVal = d3.max(bubbles, d => d.value) || 1;
const oldMap = new Map(currentNodes.map(d => [d.text+'_'+d.q_id, d]));
currentNodes = bubbles.map(d => {
const key = d.text+'_'+d.q_id;
const exist = oldMap.get(key);
const r = rScale(d.value, maxVal);
const color = COLORS[(d.q_id-1) % COLORS.length];
if (exist) { exist.value=d.value; exist.r=r; exist.color=color; return exist; }
return { ...d, r, color,
x: width/2 + (Math.random()-.5)*100,
y: height/2 + (Math.random()-.5)*100 };
});
if (simulation) simulation.stop();
simulation = d3.forceSimulation(currentNodes)
.force('center', d3.forceCenter(width/2, height/2).strength(0.04))
.force('charge', d3.forceManyBody().strength(6))
.force('collision', d3.forceCollide().radius(d => d.r + 4).strength(0.85))
.alphaDecay(0.008)
.on('tick', ticked);
render();
}
/* Draws or updates the SVG bubble elements */
function render() {
const groups = svg.selectAll('.bubble-group').data(currentNodes, d => d.text+'_'+d.q_id);
groups.exit().transition().duration(300).style('opacity', 0).remove();
const enter = groups.enter().append('g').attr('class','bubble-group').style('opacity', 0);
enter.append('circle').attr('class','bubble-circle');
enter.append('text').attr('class','bubble-text');
enter.transition()
.delay((d, i) => i * 110)
.duration(800)
.ease(d3.easeCubicOut)
.style('opacity', 1);
const all = enter.merge(groups);
all.select('.bubble-circle')
.attr('r', d => d.r).attr('fill', d => d.color)
.attr('fill-opacity', 0.10).attr('stroke', d => d.color).attr('stroke-width', 2.5);
all.select('.bubble-text').each(function(d) { wrapBubbleText(d3.select(this), d); });
}
/* Keeps bubbles within the chart boundary on each simulation tick */
function ticked() {
currentNodes.forEach(d => {
d.x = Math.max(d.r+4, Math.min(width - d.r-4, d.x));
d.y = Math.max(d.r+4, Math.min(height - d.r-4, d.y));
});
svg.selectAll('.bubble-group').attr('transform', d => `translate(${d.x},${d.y})`);
}
/* ── FETCH RESULTS ──
Calls the server every 2.5 seconds to get the latest responses.
Updates the counter, bubble chart, bottom strip, and mobile list automatically. ── */
function fetchResults() {
fetch('/api/results').then(r=>r.json()).then(data => {
document.getElementById('totalCount').textContent = data.total_responses;
updateChart(data.bubbles);
buildBottomStrip(data.bubbles);
if (analysisVisible) buildAnalysis(data.bubbles);
}).catch(console.error);
}
/* ── PAGE STARTUP ──
Hides the demo phase, shows the live results, and starts polling the server. ── */
document.getElementById('phaseQuestions').style.display = 'none';
document.getElementById('phaseResults').classList.add('active');
document.getElementById('analysisBtnEl').classList.add('visible');
/* On mobile, show analysis immediately and show the back button */
if (window.innerWidth <= 768) {
analysisVisible = true;
document.getElementById('analysisPanel').classList.add('active');
document.getElementById('backToResults').style.display = 'flex';
}
resize();
fetchResults();
setInterval(fetchResults, 2500); /* refresh every 2.5 seconds */
/* showEmailModal is defined in the script block below, after the modal HTML */
/* On mobile the analysis panel is auto-shown — show modal after a short delay */
if (window.innerWidth <= 768) {
setTimeout(() => { if (typeof showEmailModal === 'function') showEmailModal(); }, 800);
}
</script>
<!-- ── EMAIL CAPTURE MODAL ──
Must be in the HTML before the script that references its elements. -->
<div id="emailModal">
<div class="email-modal-box">
<div class="em-eyebrow">Innovation Challenge Arena</div>
<h2 class="em-title">You're viewing the top 5 ranked responses.</h2>
<p class="em-body">
For a full in-depth analysis of all results, including how responses differ across roles and facilities, leave your email below. We will send the complete report to you after the event.
</p>
<div class="em-gdpr">
<strong>Privacy Notice:</strong> Your email address will be collected by TXproducts UG solely to send you this analysis report. It will not be shared with or sold to any third party. It will be permanently deleted within 30 days of the conference.
</div>
<input class="em-input" id="emailInput" type="email" placeholder="your@email.com" autocomplete="email">
<label class="em-consent">
<input type="checkbox" id="gdprCheckbox" onchange="toggleSubmit()">
I have read the data notice above and consent to TXproducts UG storing my email address for the purpose of receiving this report.
</label>
<div class="em-confirm" id="emailConfirm">Thank you. We will be in touch after the event.</div>
<button class="em-submit" id="emailSubmitBtn" onclick="submitEmail()" disabled>Send me the full report</button>
<a href="#" class="em-skip" onclick="closeEmailModal(); return false;">No thanks, just show the results</a>
</div>
</div>
<script>
/* ── EMAIL MODAL FUNCTIONS ──
Placed after the modal HTML so getElementById calls find the elements. */
function showEmailModal() {
if (sessionStorage.getItem('emailModalShown')) return;
setTimeout(() => {
const modal = document.getElementById('emailModal');
if (modal) modal.classList.add('open');
}, 400);
}
function toggleSubmit() {
const checked = document.getElementById('gdprCheckbox').checked;
document.getElementById('emailSubmitBtn').disabled = !checked;
}
function closeEmailModal() {
sessionStorage.setItem('emailModalShown', '1');
const modal = document.getElementById('emailModal');
if (modal) modal.classList.remove('open');
}
function submitEmail() {
const input = document.getElementById('emailInput');
const confirm = document.getElementById('emailConfirm');
const btn = document.getElementById('emailSubmitBtn');
const email = input.value.trim();
if (!email || !email.includes('@') || !email.includes('.')) {
input.classList.add('error');
return;
}
input.classList.remove('error');
btn.disabled = true;
fetch('/api/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
}).then(() => {
input.style.display = 'none';
btn.style.display = 'none';
confirm.style.display = 'block';
setTimeout(closeEmailModal, 2500);
}).catch(() => { btn.disabled = false; });
}
document.getElementById('emailInput').addEventListener('keydown', e => {
if (e.key === 'Enter') submitEmail();
});
</script>
</body>
</html>