Compare commits

...
22 Commits
Author SHA1 Message Date
troogs 097e72d364 Add protected date management admin 2026-09-17 15:32:33 +02:00
troogs 253b65d976 feat: add task configuration for running Date Wizard 2026-09-17 15:14:55 +02:00
troogs 4766a7d525 Add custom activity support and clear booking data on submission 2026-08-17 12:55:47 +02:00
troogs 020f3ef746 fix: prevent browser caching of booked dates and ensure booked dates are immediately disabled 2026-08-12 18:16:47 +02:00
troogs 23baff9ef0 feat: persist booked dates in JSON file and exclude booked dates from selection 2026-08-12 18:10:53 +02:00
troogs 8685b3c466 docs: add prompt 8 for icalendar attachment feature 2026-08-12 18:07:33 +02:00
troogs b99b1f4ae5 feat: attach iCalendar (.ics) file to confirmation email 2026-08-12 18:02:53 +02:00
troogs d2cc049b25 fix: update wording for consistency in checkout summary 2026-08-12 17:35:17 +02:00
troogs 83eafe1aef fix: align icons to top and fix width for multi-activity summary items 2026-08-12 17:31:20 +02:00
troogs 6b159affda chore: add .gitignore for binary and local env files 2026-08-12 17:26:57 +02:00
troogs 89bd6f39f6 docs: add README.md with setup and deployment instructions 2026-08-12 17:21:28 +02:00
troogs 0ee82eb673 feat: add Go microservice, Dockerfile, docker-compose, and API submission endpoint 2026-08-12 17:20:03 +02:00
troogs ad02939b88 style: wording and activity list updates 2026-08-12 15:14:31 +02:00
troogs 964ac21acb fix: restrict activity selection to a single short activity on short dates 2026-08-12 15:14:18 +02:00
troogs 617d906d3e style: update fallback name in renderNames function 2026-08-12 14:16:55 +02:00
troogs ae330aeae8 style: randomize activities order in appsettings.json 2026-08-12 14:02:24 +02:00
troogs 192933c9b6 fix: disallow selecting dates in the past or today 2026-08-12 13:38:08 +02:00
troogs 54420cf25a feat: grey out long activities when a short activity is selected 2026-08-12 13:17:02 +02:00
troogs fc01c503b7 feat: restructure flow to date-first and add support for long/short days and activities 2026-08-12 13:13:54 +02:00
troogs 3d7da6c8da style: position continue button inline below content on desktop screens 2026-08-12 12:55:45 +02:00
troogs b230918903 fix: optimize mobile vertical layout and bottom clearance for continue bar 2026-08-12 12:52:45 +02:00
troogs 28f402674a fix: ensure bottom content is not obscured by fixed continue bar when scrolling 2026-08-12 12:50:36 +02:00
38 changed files with 2310 additions and 235 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.github
Dockerfile
docker-compose.yml
.env
*.exe
+14
View File
@@ -0,0 +1,14 @@
# Server Port & Configuration
PORT=8080
WEB_DIR=public
BOOKED_DATES_FILE=booked_dates.json
# SMTP Configuration
SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_USER=user@example.com
SMTP_PASS=secretpassword
# Email Addresses
TO_EMAIL=destination@example.com
FROM_EMAIL=sender@example.com
@@ -0,0 +1 @@
i want you to add a custom-activity (Sonstiges) with an appropriate icon. The custom activity always appears last in the list of activities. The custom activity should be available for selection in the booking process, and when selected, it should allow the user to enter a description of the activity. The entered description should be stored along with the booking information. The custom activity can not be paired with any other activity, and if selected, it should be the only activity in the booking. Custom activity don't need to be stored for later but included in the information mail and the created ics file
@@ -0,0 +1,10 @@
i want to perform a little logical restructure
there are long and short acitivies and there are long activitiees
there are days where we have more time and days where we have less time
on days where there is more time, we can do one long activity or two short activities
on days where there is less time, we can only do one short activity
so in the appsettings.json file i need to be able to define which days are long days and which days are short days.
and i need to be able to define which activities are long and which activities are short.
from now on, the date-selection comes first, then the activity selection. IF the user selected a long day AND a short activity, the user needs to be informed that on that day, there is still more time for another activity and have the option to select another short activity. If the user selects a short day, he will only have the option to select a short activity but doesnt need to be informed that there were options for long activities in the first place
@@ -0,0 +1,20 @@
I want to add backend functionality to send the survey responses as an email upon submission using Option 1 (a minimalist Go/Golang microservice).
Please complete the following tasks:
1. Go Microservice (`main.go`):
- Create a lightweight HTTP server using standard Go library packages (`net/http`, `net/smtp`, `encoding/json`).
- Create an endpoint (e.g., `POST /api/submit`) to accept JSON payload survey data from the front end.
- Configure SMTP credentials and server settings via environment variables (e.g., `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `TO_EMAIL`) via an .env file
- Format the incoming JSON survey data into a clean, readable text body and dispatch the email upon request.
- Handle CORS preflight (`OPTIONS`) and headers so the HTML/JS front end can POST to it smoothly.
2. Dockerization (`Dockerfile`):
- Provide a multi-stage `Dockerfile` (e.g., compile with `golang:alpine`, copy to `scratch` or `alpine`) to ensure the resulting container image is ultra-lightweight (<20MB).
3. Front-End Integration:
- Show how to update the existing JavaScript submission handler (using `fetch()`) to send the survey data as JSON to the new Go backend endpoint.
Please keep the code minimalist, robust, and well-commented.
when everything works, please create a simple docker-compose file that builds the project and runs the container. include traefik labels for reverse proxy routing.
@@ -0,0 +1,6 @@
I want to attach an iCalendar (.ics) file to the reservation confirmation email so that booked dates can be directly imported into a smartphone calendar.
Requirements:
- Format the outgoing email as a `multipart/mixed` MIME message.
- Dynamically generate an iCalendar (`.ics`) file containing the booking details (date, selected activity/activities, name).
- Set proper MIME headers (`Content-Type: text/calendar`, `Content-Disposition: attachment; filename="date-event.ics"`) so smartphones (iOS / Android) and calendar apps present a direct "Add to Calendar" import prompt.
@@ -0,0 +1 @@
I now want the application to maintain a list of booked dates. That can be a simple text file or json file. every time a booking is submitted, the date should be added to that file. When offering available dates for selection, the application should read that file and exclude any dates that have already been booked.
+3
View File
@@ -0,0 +1,3 @@
.env
*.exe
booked_dates.json
+32
View File
@@ -0,0 +1,32 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run Date Wizard",
"type": "shell",
"command": "go",
"args": [
"run",
"."
],
"isBackground": true,
"problemMatcher": [],
"group": "build",
"statusbar": {
"hide": false,
"label": "Run Date Wizard",
"icon": {
"id": "play"
},
"color": "#6dff72",
"detail": "Running Date Wizard",
"running": {
"icon": {
"id": "gear~spin"
},
"backgroundColor": "statusBarItem.warningBackground"
}
}
}
]
}
+28
View File
@@ -0,0 +1,28 @@
# Stage 1: Build the Go binary
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod ./
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server .
# Stage 2: Ultra-lightweight runtime container
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
# Copy binary and static front-end assets
COPY --from=builder /app/server /app/server
COPY --from=builder /app/public /app/public
COPY --from=builder /app/appsettings.json /app/appsettings.default.json
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
EXPOSE 8080
ENTRYPOINT ["/app/start.sh"]
+113
View File
@@ -0,0 +1,113 @@
# Date-Wizard 💕
Eine spielerische Web-App ("Choose Your Own Adventure") zum Planen gemeinsamer Dates mit mobilem Design, dynamischen Aktivitäten & Terminen sowie einem Go-Microservice zum E-Mail-Versand der Reservierung.
---
## 📁 Projektstruktur
```text
datetest/
├── public/ # Statische Webseiten & Assets
│ ├── appsettings.json # Konfiguration der Aktivitäten & Termine
│ ├── index.html # Startseite ("Willst du mit mir gehen?")
│ ├── datetime.html/.js # Datumsauswahl (Kalender)
│ ├── activity.html/.js # Aktivitätsauswahl
│ ├── name.html/.js # Namensauswahl ("Wer bist du?")
│ └── checkout.html/.js # Zusammenfassung & Bestätigung
├── main.go # Go-Microservice (HTTP-Server & SMTP E-Mail)
├── go.mod # Go-Moduldefinition
├── Dockerfile # Multi-Stage Dockerfile (Alpine Runtime)
├── docker-compose.yml # Docker-Compose mit Traefik Labels
├── .env.example # Vorlage für Umgebungsvariablen
└── .env # Lokale/Produktive Umgebungsvariablen (nicht ins Git committen)
```
---
## ⚙️ Konfiguration
### 1. E-Mail & Server `.env` anlegen
Kopiere `.env.example` zu `.env`:
```bash
cp .env.example .env
```
Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
| Variable | Beschreibung | Beispiel |
| :--- | :--- | :--- |
| `PORT` | HTTP-Port des Go-Servers | `8080` |
| `WEB_DIR` | Verzeichnis der statischen Assets | `public` |
| `BOOKED_DATES_FILE` | Speicherpfad für bereits gebuchte Termine | `booked_dates.json` |
| `SMTP_HOST` | Postausgangsserver (SMTP) | `mail.example.com` |
| `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` |
| `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` |
| `SMTP_PASS` | Passwort des SMTP-Kontos | `secretpassword` |
| `TO_EMAIL` | Ziel-Adresse für eingegangene Date-Anfragen | `ziel@example.com` |
| `FROM_EMAIL` | Absender-Adresse für die Benachrichtigung | `absender@example.com` |
*(Hinweis: Wenn `SMTP_HOST` oder `TO_EMAIL` leer bleiben, läuft der Server im Testmodus ohne E-Mail-Versand.)*
### 2. Aktivitäten & Termine anpassen (`appsettings.json`)
Passe in `appsettings.json` die zur Auswahl stehenden Aktivitäten, verfügbaren Tage und das Admin-Passwort an:
- **`adminPassword`**: Passwort für die Terminverwaltung unter `/admin`. Ändere den Standardwert `change-me`, bevor du die Anwendung veröffentlichst.
- **`activities`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji) und `type` (`short` oder `long`).
- **`availableDates`**: Liste von verfügbaren Tagen mit `date` (`YYYY-MM-DD`) und `type` (`short` oder `long`).
- **`names`**: Optional vordefinierte Namen zur Auswahl (ist die Liste leer, wird ein Standard-Fallback angeboten).
Die Terminliste lässt sich auch über die passwortgeschützte Verwaltung unter `http://localhost:8080/admin` bearbeiten. Das Passwort bleibt dabei auf dem Server und wird nicht an Besucher ausgeliefert.
Bei Docker Compose werden `appsettings.json` und `booked_dates.json` dauerhaft unter `/docker/data/date` gespeichert. Beim ersten Start legt der Container die Einstellungen dort aus der im Image enthaltenen Vorlage an. Ändere danach den Standardwert `change-me` in `/docker/data/date/appsettings.json` oder übernimm vor dem ersten Start eine vorbereitete Konfigurationsdatei.
---
## 🚀 Inbetriebnahme
### Option A: Mit Docker Compose (Empfohlen)
1. `.env` wie oben beschrieben anpassen.
2. Container erstellen und starten:
```bash
docker-compose up -d --build
```
3. Die Anwendung ist unter `http://localhost:8080` erreichbar.
#### Traefik / Reverse Proxy
`docker-compose.yml` enthält bereits Traefik-Labels. Passe bei Bedarf die Domain an:
```yaml
- "traefik.http.routers.date-wizard.rule=Host(`date.deine-domain.de`)"
```
---
### Option B: Direkt mit Go (Lokal)
#### Voraussetzungen
- Installed Go SDK (Version 1.22 oder neuer)
1. Abhängigkeiten prüfen & kompilieren:
```bash
go run main.go
```
2. Die Anwendung startet auf dem in `.env` definierten Port (z. B. `http://localhost:8080`).
---
## 📱 Ablauf der Anwendung
1. **Startseite (`index.html`)**: Spielerische Frage mit Ja/Nein-Interaktion.
2. **Datumsauswahl (`datetime.html`)**: Interaktiver Kalender mit Markierung von verfügbaren Tagen (`short`/`long`).
3. **Aktivitätsauswahl (`activity.html`)**: Auswahl passender Aktivitäten je nach verbleibender Zeit am gewählten Tag.
4. **Namensauswahl (`name.html`)**: Abfrage "Wer bist du?".
5. **Checkout & Bestätigung (`checkout.html`)**: Finale Übersicht und Absenden der Reservierung (überträgt Daten an `/api/submit` und löst den E-Mail-Versand aus).
-74
View File
@@ -1,74 +0,0 @@
/* ==========================================================================
Page: Aktivitäts-Auswahl (activity.html)
========================================================================== */
.page-activity .intro {
text-align: center;
margin-bottom: 1.5em;
}
.page-activity .intro p {
color: var(--color-text-light);
}
.activity-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (min-width: 600px) {
.activity-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.activity-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 140px;
padding: 16px 10px;
background: var(--color-white);
border: 2px solid transparent;
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
cursor: pointer;
touch-action: manipulation;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.activity-card:active {
transform: scale(0.97);
}
.activity-card .icon {
font-size: 2.6rem;
line-height: 1;
}
.activity-card .label {
font-family: var(--font-heading);
font-weight: 700;
font-size: 0.95rem;
text-align: center;
color: var(--color-text);
}
.activity-card.selected {
border-color: var(--color-rose);
background: linear-gradient(135deg, var(--color-rose-light) 0%, var(--color-white) 100%);
box-shadow: var(--shadow-strong);
}
.activity-card.selected::after {
content: "✓";
position: absolute;
top: 8px;
right: 10px;
color: var(--color-rose-dark);
font-weight: 700;
}
-52
View File
@@ -1,52 +0,0 @@
// Aktivitäts-Auswahl: lädt Aktivitäten aus appsettings.json, Kartenauswahl + Weiter-Button
(function () {
const activityGrid = document.getElementById("activityGrid");
const continueBtn = document.getElementById("continueBtn");
let selectedActivity = null;
fetch("appsettings.json")
.then((response) => response.json())
.then((settings) => renderActivities(settings.activities || []))
.catch(() => {
activityGrid.textContent = "Aktivitäten konnten nicht geladen werden.";
});
function renderActivities(activities) {
activities.forEach((activity) => {
const card = document.createElement("button");
card.type = "button";
card.className = "activity-card";
card.dataset.activity = activity.name;
const icon = document.createElement("span");
icon.className = "icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = activity.icon;
const label = document.createElement("span");
label.className = "label";
label.textContent = activity.name;
card.append(icon, label);
card.addEventListener("click", () => {
activityGrid.querySelectorAll(".activity-card").forEach((c) => c.classList.remove("selected"));
card.classList.add("selected");
selectedActivity = activity.name;
continueBtn.disabled = false;
});
activityGrid.appendChild(card);
});
}
continueBtn.addEventListener("click", () => {
if (!selectedActivity) {
return;
}
// Ablage für die spätere Anbindung an Datenbank/Storage
localStorage.setItem("selectedActivity", selectedActivity);
window.location.href = "datetime.html";
});
})();
+184 -16
View File
@@ -1,22 +1,190 @@
{
"activities": [
{ "name": "Essen bei Namaste", "icon": "🍛" },
{ "name": "Kinofilm", "icon": "🎬" },
{ "name": "Film zu Hause + Essen bestellen", "icon": "🛋️" },
{ "name": "Spaziergang im Wald", "icon": "🌲" },
{ "name": "Museum für Gegenwartskunst", "icon": "🖼️" },
{ "name": "Picknick auf der Wiese", "icon": "🧺" },
{ "name": "Massage zu Hause", "icon": "💆" }
{
"name": "Picknick auf der Wiese",
"icon": "🧺",
"type": "short"
},
{
"name": "Cocktailabend zu zweit",
"icon": "🍸",
"type": "long"
},
{
"name": "Massage zu Hause",
"icon": "💆",
"type": "short"
},
{
"name": "Ausgedehnte Wanderung",
"icon": "🥾",
"type": "long"
},
{
"name": "Kinofilm",
"icon": "🎬",
"type": "short"
},
{
"name": "Sushi selber machen",
"icon": "🍣",
"type": "short"
},
{
"name": "Untersetzer aus Ton basteln",
"icon": "🪨",
"type": "long"
},
{
"name": "Freizeitpark",
"icon": "🎢",
"type": "long"
},
{
"name": "Spaziergang im Wald",
"icon": "🌲",
"type": "short"
},
{
"name": "Kirschbier Tasting",
"icon": "🍒",
"type": "short"
},
{
"name": "Nudeln selber machen",
"icon": "🍜",
"type": "long"
},
{
"name": "Chillen im Freibad",
"icon": "🏊",
"type": "short"
},
{
"name": "Film zu Hause + Essen bestellen",
"icon": "🛋️",
"type": "long"
},
{
"name": "Squash spielen",
"icon": "🏸",
"type": "short"
},
{
"name": "Essen im Namaste",
"icon": "🍛",
"type": "short"
},
{
"name": "Museum für Gegenwartskunst",
"icon": "🖼️",
"type": "short"
},
{
"name": "Playstation Co-Op",
"icon": "🎮",
"type": "short"
},
{
"name": "Etwas eigenes",
"icon": "✏️",
"type": "custom"
}
],
"availableDates": [
"2026-08-15",
"2026-08-16",
"2026-08-23",
"2026-08-30",
"2026-09-05",
"2026-09-06"
{
"date": "2026-08-18",
"type": "short"
},
{
"date": "2026-08-20",
"type": "short"
},
{
"date": "2026-08-22",
"type": "long"
},
{
"date": "2026-08-23",
"type": "long"
},
{
"date": "2026-08-24",
"type": "short"
},
{
"date": "2026-08-26",
"type": "short"
},
{
"date": "2026-08-27",
"type": "short"
},
{
"date": "2026-08-28",
"type": "short"
},
{
"date": "2026-08-29",
"type": "long"
},
{
"date": "2026-08-31",
"type": "short"
},
{
"date": "2026-09-01",
"type": "short"
},
{
"date": "2026-09-07",
"type": "short"
},
{
"date": "2026-09-08",
"type": "short"
},
{
"date": "2026-09-14",
"type": "short"
},
{
"date": "2026-09-15",
"type": "short"
},
{
"date": "2026-09-17",
"type": "short"
},
{
"date": "2026-09-18",
"type": "short"
},
{
"date": "2026-09-25",
"type": "short"
},
{
"date": "2026-09-26",
"type": "long"
},
{
"date": "2026-09-27",
"type": "short"
},
{
"date": "2026-09-28",
"type": "short"
},
{
"date": "2026-09-29",
"type": "short"
},
{
"date": "2026-09-30",
"type": "short"
}
],
"names": [
]
"names": [],
"adminPassword": "change-me"
}
-53
View File
@@ -1,53 +0,0 @@
// Checkout & Bestätigungsseite
(function () {
const backLink = document.getElementById("backLink");
const summarySection = document.getElementById("summarySection");
const confirmationSection = document.getElementById("confirmationSection");
const summaryActivity = document.getElementById("summaryActivity");
const summaryDate = document.getElementById("summaryDate");
const summaryName = document.getElementById("summaryName");
const activityIcon = document.getElementById("activityIcon");
const checkoutBtn = document.getElementById("checkoutBtn");
const selectedActivity = localStorage.getItem("selectedActivity");
const selectedDate = localStorage.getItem("selectedDate");
const selectedName = localStorage.getItem("selectedName");
if (!selectedActivity || !selectedDate || !selectedName) {
window.location.href = "index.html";
return;
}
summaryActivity.textContent = selectedActivity;
summaryName.textContent = selectedName;
const dateObj = new Date(`${selectedDate}T00:00:00`);
const formattedDate = dateObj.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
summaryDate.textContent = formattedDate;
// Icon aus appsettings.json laden, falls verfügbar
fetch("appsettings.json")
.then((res) => res.json())
.then((settings) => {
const match = (settings.activities || []).find((a) => a.name === selectedActivity);
if (match && match.icon) {
activityIcon.textContent = match.icon;
}
})
.catch(() => {});
checkoutBtn.addEventListener("click", () => {
// Später: API / Mail-Versand / Kalendereintrag ausführen
summarySection.classList.add("hidden");
if (backLink) {
backLink.classList.add("hidden");
}
confirmationSection.classList.remove("hidden");
});
})();
+27
View File
@@ -0,0 +1,27 @@
services:
date-wizard:
build:
context: .
dockerfile: Dockerfile
container_name: date-wizard
restart: unless-stopped
networks:
- proxy
env_file:
- .env
environment:
SETTINGS_FILE: /data/appsettings.json
BOOKED_DATES_FILE: /data/booked_dates.json
volumes:
- /docker/data/date:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.date-wizard.rule=Host(`date.allen.beging.de`) || Host(`date.beging.de`)"
- "traefik.http.routers.date-wizard.entrypoints=websecure"
- "traefik.http.routers.date-wizard.tls=true"
- "traefik.http.routers.date-wizard.tls.certresolver=leresolver"
- "traefik.http.services.date-wizard.loadbalancer.server.port=8080"
networks:
proxy:
external: true
+3
View File
@@ -0,0 +1,3 @@
module date-wizard
go 1.22
+680
View File
@@ -0,0 +1,680 @@
package main
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/subtle"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"os"
"sort"
"strings"
"sync"
"time"
)
type Activity struct {
Name string `json:"name"`
Icon string `json:"icon"`
Type string `json:"type"`
CustomDescription string `json:"customDescription,omitempty"`
}
type SurveyPayload struct {
Activity string `json:"activity"`
Activities []Activity `json:"activities"`
Date string `json:"date"`
DateType string `json:"dateType"`
Name string `json:"name"`
}
type Response struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type AvailableDate struct {
Date string `json:"date"`
Type string `json:"type"`
}
type AppSettings struct {
Activities []Activity `json:"activities"`
AvailableDates []AvailableDate `json:"availableDates"`
Names []string `json:"names"`
AdminPassword string `json:"adminPassword"`
}
type PublicSettings struct {
Activities []Activity `json:"activities"`
AvailableDates []AvailableDate `json:"availableDates"`
Names []string `json:"names"`
}
type passwordRequest struct {
Password string `json:"password"`
}
var settingsMu sync.Mutex
var sessionsMu sync.Mutex
var adminSessions = make(map[string]time.Time)
func main() {
loadEnv(".env")
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
webDir := os.Getenv("WEB_DIR")
if webDir == "" {
webDir = "public"
}
http.HandleFunc("/api/submit", handleSubmit)
http.HandleFunc("/api/booked-dates", handleBookedDates)
http.HandleFunc("/api/settings", handlePublicSettings)
http.HandleFunc("/api/admin/login", handleAdminLogin)
http.HandleFunc("/api/admin/logout", handleAdminLogout)
http.HandleFunc("/api/admin/dates", handleAdminDates)
http.HandleFunc("/admin", handleAdminPage)
// Serve static files from webDir directory
fs := http.FileServer(http.Dir(webDir))
http.Handle("/", fs)
log.Printf("Server starting on port %s (serving static files from %s)...", port, webDir)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func handleAdminPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/admin" && r.URL.Path != "/admin/" {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, "public/admin.html")
}
func handlePublicSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
settings, err := readSettings()
if err != nil {
log.Printf("Error reading app settings: %v", err)
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, PublicSettings{
Activities: settings.Activities,
AvailableDates: availableDatesFromToday(settings.AvailableDates),
Names: settings.Names,
})
}
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var request passwordRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
settings, err := readSettings()
if err != nil {
log.Printf("Error reading app settings: %v", err)
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
return
}
if settings.AdminPassword == "" || subtle.ConstantTimeCompare([]byte(request.Password), []byte(settings.AdminPassword)) != 1 {
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Ungültiges Passwort."})
return
}
sessionID, err := createAdminSession()
if err != nil {
log.Printf("Error creating admin session: %v", err)
http.Error(w, "Session could not be created", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "date_wizard_admin",
Value: sessionID,
Path: "/",
MaxAge: int((8 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: r.TLS != nil,
})
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Angemeldet."})
}
func handleAdminLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if cookie, err := r.Cookie("date_wizard_admin"); err == nil {
sessionsMu.Lock()
delete(adminSessions, cookie.Value)
sessionsMu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: "date_wizard_admin", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil})
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Abgemeldet."})
}
func handleAdminDates(w http.ResponseWriter, r *http.Request) {
if !isAdminRequest(r) {
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
return
}
switch r.Method {
case http.MethodGet:
settings, err := readSettings()
if err != nil {
log.Printf("Error reading app settings: %v", err)
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, availableDatesFromToday(settings.AvailableDates))
case http.MethodPost:
var availableDate AvailableDate
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(availableDate) || !isTodayOrFuture(availableDate.Date) {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein gültiges Datum ab heute und einen Tagestyp."})
return
}
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
for _, date := range dates {
if date.Date == availableDate.Date {
return nil, fmt.Errorf("date already exists")
}
}
return append(dates, availableDate), nil
}); err != nil {
if err.Error() == "date already exists" {
writeJSON(w, http.StatusConflict, Response{Success: false, Message: "Dieses Datum ist bereits verfügbar."})
return
}
log.Printf("Error adding available date: %v", err)
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, Response{Success: true, Message: "Datum hinzugefügt."})
case http.MethodPut:
var availableDate AvailableDate
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(availableDate) || !isTodayOrFuture(availableDate.Date) {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein Datum ab heute und einen gültigen Tagestyp."})
return
}
updated := false
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
for index := range dates {
if dates[index].Date == availableDate.Date {
dates[index].Type = availableDate.Type
updated = true
break
}
}
if !updated {
return nil, fmt.Errorf("date does not exist")
}
return dates, nil
}); err != nil {
if err.Error() == "date does not exist" {
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Datum wurde nicht gefunden."})
return
}
log.Printf("Error updating available date: %v", err)
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Tagestyp aktualisiert."})
case http.MethodDelete:
var availableDate AvailableDate
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(AvailableDate{Date: availableDate.Date, Type: "short"}) || !isTodayOrFuture(availableDate.Date) {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein Datum ab heute."})
return
}
deleted := false
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
filtered := make([]AvailableDate, 0, len(dates))
for _, date := range dates {
if date.Date == availableDate.Date {
deleted = true
continue
}
filtered = append(filtered, date)
}
if !deleted {
return nil, fmt.Errorf("date does not exist")
}
return filtered, nil
}); err != nil {
if err.Error() == "date does not exist" {
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Datum wurde nicht gefunden."})
return
}
log.Printf("Error deleting available date: %v", err)
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Datum gelöscht."})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func getSettingsFilePath() string {
path := os.Getenv("SETTINGS_FILE")
if path == "" {
path = "appsettings.json"
}
return path
}
func readSettings() (AppSettings, error) {
settingsMu.Lock()
defer settingsMu.Unlock()
return readSettingsFile()
}
func readSettingsFile() (AppSettings, error) {
var settings AppSettings
data, err := os.ReadFile(getSettingsFilePath())
if err != nil {
return settings, err
}
if err := json.Unmarshal(data, &settings); err != nil {
return settings, err
}
return settings, nil
}
func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error)) error {
settingsMu.Lock()
defer settingsMu.Unlock()
settings, err := readSettingsFile()
if err != nil {
return err
}
dates, err := update(settings.AvailableDates)
if err != nil {
return err
}
sort.Slice(dates, func(i, j int) bool { return dates[i].Date < dates[j].Date })
settings.AvailableDates = dates
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
temporaryPath := getSettingsFilePath() + ".tmp"
if err := os.WriteFile(temporaryPath, data, 0600); err != nil {
return err
}
return os.Rename(temporaryPath, getSettingsFilePath())
}
func isValidAvailableDate(availableDate AvailableDate) bool {
if availableDate.Type != "short" && availableDate.Type != "long" {
return false
}
parsedDate, err := time.Parse("2006-01-02", availableDate.Date)
return err == nil && parsedDate.Format("2006-01-02") == availableDate.Date
}
func isTodayOrFuture(date string) bool {
return date >= time.Now().Format("2006-01-02")
}
func availableDatesFromToday(dates []AvailableDate) []AvailableDate {
upcomingDates := make([]AvailableDate, 0, len(dates))
for _, availableDate := range dates {
if isTodayOrFuture(availableDate.Date) {
upcomingDates = append(upcomingDates, availableDate)
}
}
return upcomingDates
}
func createAdminSession() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
sessionID := fmt.Sprintf("%x", bytes)
sessionsMu.Lock()
adminSessions[sessionID] = time.Now().Add(8 * time.Hour)
sessionsMu.Unlock()
return sessionID, nil
}
func isAdminRequest(r *http.Request) bool {
cookie, err := r.Cookie("date_wizard_admin")
if err != nil {
return false
}
sessionsMu.Lock()
defer sessionsMu.Unlock()
expiresAt, found := adminSessions[cookie.Value]
if !found || time.Now().After(expiresAt) {
delete(adminSessions, cookie.Value)
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value interface{}) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
json.NewEncoder(w).Encode(value)
}
func handleSubmit(w http.ResponseWriter, r *http.Request) {
if enableCORS(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var payload SurveyPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(Response{
Success: false,
Message: "Ungültige Anfragedaten.",
})
return
}
log.Printf("Received survey submission from %s for date %s (%s)", payload.Name, payload.Date, payload.Activity)
if err := recordBookedDate(payload.Date); err != nil {
log.Printf("Error recording booked date: %v", err)
}
err = sendEmail(payload)
if err != nil {
log.Printf("Warning: Email could not be sent: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(Response{
Success: true,
Message: "Reservierung erfolgreich übermittelt!",
})
}
func handleBookedDates(w http.ResponseWriter, r *http.Request) {
if enableCORS(w, r) {
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
dates, err := getBookedDates()
if err != nil {
log.Printf("Error reading booked dates: %v", err)
dates = []string{}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(dates)
}
func getBookedDatesFilePath() string {
path := os.Getenv("BOOKED_DATES_FILE")
if path == "" {
path = "booked_dates.json"
}
return path
}
func getBookedDates() ([]string, error) {
filePath := getBookedDatesFilePath()
file, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
defer file.Close()
var rawDates []string
if err := json.NewDecoder(file).Decode(&rawDates); err != nil {
return []string{}, nil
}
dates := make([]string, 0, len(rawDates))
for _, d := range rawDates {
trimmed := strings.TrimSpace(d)
if trimmed != "" {
dates = append(dates, trimmed)
}
}
return dates, nil
}
func recordBookedDate(dateStr string) error {
trimmedDate := strings.TrimSpace(dateStr)
if trimmedDate == "" {
return nil
}
dates, err := getBookedDates()
if err != nil {
dates = []string{}
}
for _, d := range dates {
if d == trimmedDate {
return nil
}
}
dates = append(dates, trimmedDate)
filePath := getBookedDatesFilePath()
data, err := json.MarshalIndent(dates, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
func enableCORS(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return true
}
return false
}
func sendEmail(payload SurveyPayload) error {
smtpHost := os.Getenv("SMTP_HOST")
smtpPort := os.Getenv("SMTP_PORT")
if smtpPort == "" {
smtpPort = "587"
}
smtpUser := os.Getenv("SMTP_USER")
smtpPass := os.Getenv("SMTP_PASS")
toEmail := os.Getenv("TO_EMAIL")
fromEmail := os.Getenv("FROM_EMAIL")
if fromEmail == "" {
fromEmail = smtpUser
}
if smtpHost == "" || toEmail == "" {
log.Println("HINWEIS: SMTP_HOST oder TO_EMAIL nicht konfiguriert. E-Mail-Versand übersprungen.")
return nil
}
boundary := fmt.Sprintf("BOUNDARY_%d", time.Now().UnixNano())
var bodyBuilder bytes.Buffer
bodyBuilder.WriteString(fmt.Sprintf("From: %s\r\n", fromEmail))
bodyBuilder.WriteString(fmt.Sprintf("To: %s\r\n", toEmail))
bodyBuilder.WriteString(fmt.Sprintf("Subject: Neue Date-Reservierung von %s! 💕\r\n", payload.Name))
bodyBuilder.WriteString("MIME-Version: 1.0\r\n")
bodyBuilder.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", boundary))
// Text-Teil der E-Mail
bodyBuilder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
bodyBuilder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
bodyBuilder.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
bodyBuilder.WriteString("Hallo!\r\n\r\n")
bodyBuilder.WriteString("Es gibt eine neue Date-Reservierung über deinen Date-Planer:\r\n\r\n")
bodyBuilder.WriteString(fmt.Sprintf("👤 Name: %s\r\n", payload.Name))
bodyBuilder.WriteString(fmt.Sprintf("📅 Datum: %s (%s)\r\n", payload.Date, payload.DateType))
bodyBuilder.WriteString(fmt.Sprintf("💌 Aktivität(en): %s\r\n\r\n", cleanActivityName(payload.Activity)))
if len(payload.Activities) > 0 {
bodyBuilder.WriteString("Details der Aktivitäten:\r\n")
for _, act := range payload.Activities {
actName := cleanActivityName(act.Name)
if act.CustomDescription != "" {
cleanDesc := cleanActivityName(act.CustomDescription)
if !strings.Contains(actName, cleanDesc) {
actName = cleanDesc
}
}
bodyBuilder.WriteString(fmt.Sprintf(" - %s %s (%s)\r\n", act.Icon, actName, act.Type))
}
bodyBuilder.WriteString("\r\n")
}
bodyBuilder.WriteString("Im Anhang findest du einen Kalendereintrag (.ics), den du direkt auf deinem Smartphone/Kalender importieren kannst. 💕\r\n\r\n")
// iCalendar-Anhang (.ics)
icsData := generateICS(payload)
bodyBuilder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
bodyBuilder.WriteString("Content-Type: text/calendar; charset=UTF-8; method=REQUEST; name=\"date-event.ics\"\r\n")
bodyBuilder.WriteString("Content-Disposition: attachment; filename=\"date-event.ics\"\r\n")
bodyBuilder.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
bodyBuilder.WriteString(icsData)
bodyBuilder.WriteString("\r\n")
// Abschluss-Boundary
bodyBuilder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost)
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
return smtp.SendMail(addr, auth, fromEmail, []string{toEmail}, bodyBuilder.Bytes())
}
func cleanActivityName(s string) string {
s = strings.TrimPrefix(s, "Etwas eigenes: ")
s = strings.TrimPrefix(s, "Etwas eigenes:")
s = strings.TrimPrefix(s, "Sonstiges: ")
s = strings.TrimPrefix(s, "Sonstiges:")
return strings.TrimSpace(s)
}
func generateICS(payload SurveyPayload) string {
parsedDate, err := time.Parse("2006-01-02", payload.Date)
dtStart := strings.ReplaceAll(payload.Date, "-", "")
dtEnd := dtStart
if err == nil {
dtStart = parsedDate.Format("20060102")
dtEnd = parsedDate.AddDate(0, 0, 1).Format("20060102")
}
dtStamp := time.Now().UTC().Format("20060102T150405Z")
uid := fmt.Sprintf("date-wizard-%s-%d@date-wizard", dtStart, time.Now().UnixNano())
activityText := cleanActivityName(payload.Activity)
for _, act := range payload.Activities {
if act.CustomDescription != "" {
cleanDesc := cleanActivityName(act.CustomDescription)
if !strings.Contains(activityText, cleanDesc) {
activityText = cleanDesc
}
}
}
summary := fmt.Sprintf("Date: %s", activityText)
description := fmt.Sprintf("Date mit %s\\nAktivität(en): %s", payload.Name, activityText)
var sb strings.Builder
sb.WriteString("BEGIN:VCALENDAR\r\n")
sb.WriteString("VERSION:2.0\r\n")
sb.WriteString("PRODID:-//Date-Wizard//DE\r\n")
sb.WriteString("CALSCALE:GREGORIAN\r\n")
sb.WriteString("METHOD:REQUEST\r\n")
sb.WriteString("BEGIN:VEVENT\r\n")
sb.WriteString(fmt.Sprintf("UID:%s\r\n", uid))
sb.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", dtStamp))
sb.WriteString(fmt.Sprintf("DTSTART;VALUE=DATE:%s\r\n", dtStart))
sb.WriteString(fmt.Sprintf("DTEND;VALUE=DATE:%s\r\n", dtEnd))
sb.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", summary))
sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", description))
sb.WriteString("STATUS:CONFIRMED\r\n")
sb.WriteString("END:VEVENT\r\n")
sb.WriteString("END:VCALENDAR\r\n")
return sb.String()
}
func loadEnv(filename string) {
file, err := os.Open(filename)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, `"'`)
if os.Getenv(key) == "" {
os.Setenv(key, val)
}
}
}
}
+149
View File
@@ -0,0 +1,149 @@
/* ==========================================================================
Page: Aktivitäts-Auswahl (activity.html)
========================================================================== */
.page-activity .intro {
text-align: center;
margin-bottom: 1.2em;
}
.page-activity .intro p {
color: var(--color-text-light);
}
.activity-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (min-width: 600px) {
.activity-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
}
.activity-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 115px;
padding: 12px 8px;
background: var(--color-white);
border: 2px solid transparent;
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
cursor: pointer;
touch-action: manipulation;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.activity-card:active {
transform: scale(0.97);
}
.activity-card .icon {
font-size: 2.2rem;
line-height: 1;
}
.activity-card .label {
font-family: var(--font-heading);
font-weight: 700;
font-size: 0.9rem;
text-align: center;
color: var(--color-text);
}
.activity-card.selected {
border-color: var(--color-rose);
background: linear-gradient(135deg, var(--color-rose-light) 0%, var(--color-white) 100%);
box-shadow: var(--shadow-strong);
}
.activity-card.selected::after {
content: "✓";
position: absolute;
top: 8px;
right: 10px;
color: var(--color-rose-dark);
font-weight: 700;
}
.activity-card.is-disabled,
.activity-card:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(0.4);
box-shadow: none;
background: #f7e8ec;
}
.time-notice {
background: var(--color-white);
border: 2px solid var(--color-peach);
border-radius: var(--radius-md);
padding: 12px 16px;
margin-bottom: 16px;
font-size: 0.95rem;
color: var(--color-rose-dark);
text-align: center;
box-shadow: var(--shadow-soft);
animation: fadeIn 0.3s ease;
}
.custom-activity-section {
margin-top: 16px;
background: var(--color-white);
border: 2px solid var(--color-rose);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-soft);
animation: fadeIn 0.3s ease;
}
.custom-activity-label {
display: block;
font-family: var(--font-heading);
font-weight: 700;
font-size: 0.95rem;
color: var(--color-rose-dark);
margin-bottom: 8px;
}
.custom-activity-input {
width: 100%;
padding: 12px 14px;
font-family: var(--font-body);
font-size: 1rem;
color: var(--color-text);
background: var(--color-bg);
border: 2px solid var(--color-peach);
border-radius: var(--radius-md);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.custom-activity-input:focus {
border-color: var(--color-rose);
box-shadow: 0 0 0 3px rgba(224, 87, 126, 0.2);
}
.hidden {
display: none !important;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+19 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Wähle eure Aktivität Date-Planer</title>
<title>Wähle unsere Aktivität Date-Planer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&family=Quicksand:wght@600;700&display=swap" rel="stylesheet" />
@@ -12,13 +12,29 @@
</head>
<body class="page-activity has-continue-bar">
<main class="container">
<a class="back-link" href="index.html"> Zurück</a>
<a class="back-link" href="datetime.html"> Zurück</a>
<div class="intro">
<h1>Worauf hast du Lust? 🥰</h1>
<p>Wähle eure gemeinsame Aktivität für das Date aus.</p>
<p id="dateSummary">Wähle unsere gemeinsame Aktivität für das Date aus.</p>
</div>
<div id="timeNotice" class="time-notice hidden">
💡 An diesem Tag haben wir reichlich Zeit! Wenn du möchtest, kannst du noch eine zweite kurze Aktivität auswählen.
</div>
<div class="activity-grid" id="activityGrid"></div>
<div id="customActivitySection" class="custom-activity-section hidden">
<label for="customActivityInput" class="custom-activity-label">Was möchtest du unternehmen? ✏️</label>
<input
type="text"
id="customActivityInput"
class="custom-activity-input"
placeholder="Beschreibung der Aktivität eingeben..."
maxlength="150"
autocomplete="off"
/>
</div>
</main>
<div class="continue-bar">
+258
View File
@@ -0,0 +1,258 @@
// Aktivitäts-Auswahl: lädt Aktivitäten aus appsettings.json, Kartenauswahl + Weiter-Button
(function () {
const activityGrid = document.getElementById("activityGrid");
const dateSummary = document.getElementById("dateSummary");
const timeNotice = document.getElementById("timeNotice");
const continueBtn = document.getElementById("continueBtn");
const customActivitySection = document.getElementById("customActivitySection");
const customActivityInput = document.getElementById("customActivityInput");
const selectedDate = localStorage.getItem("selectedDate");
const selectedDateType = localStorage.getItem("selectedDateType") || "long";
if (!selectedDate) {
window.location.href = "datetime.html";
return;
}
const dateObj = new Date(`${selectedDate}T00:00:00`);
const formattedDate = dateObj.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
dateSummary.textContent = `Für unser Date am ${formattedDate}.`;
let availableActivities = [];
let selectedList = [];
function isCustomActivity(act) {
return act && (act.name === "Etwas eigenes" || act.name === "Sonstiges" || act.type === "custom");
}
fetch("/api/settings", { cache: "no-store" })
.then((response) => response.json())
.then((settings) => {
const allActivities = settings.activities || [];
const customAct = allActivities.find((a) => a.name === "Etwas eigenes" || a.name === "Sonstiges") || {
name: "Etwas eigenes",
icon: "✏️",
type: "custom",
};
let otherActivities = allActivities.filter((a) => a.name !== "Etwas eigenes" && a.name !== "Sonstiges");
if (selectedDateType === "short") {
otherActivities = otherActivities.filter((a) => a.type === "short");
}
// "Etwas eigenes" erscheint IMMER an letzter Stelle
availableActivities = [...otherActivities, customAct];
// Vorherige Auswahl wiederherstellen, falls vorhanden
try {
const stored = JSON.parse(localStorage.getItem("selectedActivities") || "[]");
if (stored && stored.length > 0) {
const isStoredCustom = stored.some(
(a) => a.type === "custom" || (a.name && (a.name.startsWith("Etwas eigenes") || a.name.startsWith("Sonstiges")))
);
if (isStoredCustom) {
const storedCustom = stored.find(
(a) => a.type === "custom" || (a.name && (a.name.startsWith("Etwas eigenes") || a.name.startsWith("Sonstiges")))
);
selectedList = [customAct];
if (storedCustom) {
const desc =
storedCustom.customDescription ||
(storedCustom.name.includes(":")
? storedCustom.name.split(":").slice(1).join(":").trim()
: "");
if (customActivityInput) {
customActivityInput.value = desc;
}
}
} else {
selectedList = stored.filter((s) =>
availableActivities.some((a) => a.name === s.name)
);
}
}
} catch (e) {}
renderActivities();
})
.catch(() => {
activityGrid.textContent = "Aktivitäten konnten nicht geladen werden.";
});
function renderActivities() {
activityGrid.innerHTML = "";
const hasCustomSelected = selectedList.some(isCustomActivity);
const hasShortSelected = selectedList.some((a) => a.type === "short");
if (hasCustomSelected) {
if (customActivitySection) {
customActivitySection.classList.remove("hidden");
}
} else {
if (customActivitySection) {
customActivitySection.classList.add("hidden");
}
}
availableActivities.forEach((activity) => {
const card = document.createElement("button");
card.type = "button";
card.className = "activity-card";
card.dataset.activity = activity.name;
const isCustom = isCustomActivity(activity);
const isSelected = isCustom
? hasCustomSelected
: selectedList.some((a) => a.name === activity.name);
if (isSelected) {
card.classList.add("selected");
}
const isLongDisabled = !isCustom && hasShortSelected && activity.type === "long";
if (isLongDisabled) {
card.classList.add("is-disabled");
card.disabled = true;
}
const icon = document.createElement("span");
icon.className = "icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = activity.icon;
const label = document.createElement("span");
label.className = "label";
label.textContent = activity.name;
card.append(icon, label);
card.addEventListener("click", () => {
handleCardClick(activity);
});
activityGrid.appendChild(card);
});
updateNoticeAndContinue();
}
function handleCardClick(activity) {
const isCustom = isCustomActivity(activity);
const hasCustomSelected = selectedList.some(isCustomActivity);
if (isCustom) {
if (hasCustomSelected) {
selectedList = [];
} else {
// Custom-Aktivität darf mit keiner anderen Aktivität kombiniert werden!
selectedList = [activity];
setTimeout(() => {
if (customActivityInput) {
customActivityInput.focus();
}
}, 50);
}
} else {
if (hasCustomSelected) {
selectedList = [];
}
const isAlreadySelected = selectedList.some((a) => a.name === activity.name);
if (activity.type === "long") {
if (isAlreadySelected) {
selectedList = [];
} else {
selectedList = [activity];
}
} else {
if (isAlreadySelected) {
selectedList = selectedList.filter((a) => a.name !== activity.name);
} else if (selectedDateType === "short") {
selectedList = [activity];
} else {
if (selectedList.some((a) => a.type === "long")) {
selectedList = [activity];
} else if (selectedList.length >= 2) {
selectedList = [selectedList[0], activity];
} else {
selectedList.push(activity);
}
}
}
}
renderActivities();
}
if (customActivityInput) {
customActivityInput.addEventListener("input", () => {
updateNoticeAndContinue();
});
}
function updateNoticeAndContinue() {
const hasCustomSelected = selectedList.some(isCustomActivity);
const shortCount = selectedList.filter((a) => a.type === "short").length;
const hasLong = selectedList.some((a) => a.type === "long");
if (hasCustomSelected) {
timeNotice.classList.add("hidden");
const desc = customActivityInput ? customActivityInput.value.trim() : "";
continueBtn.disabled = desc.length === 0;
} else {
if (selectedDateType === "long" && shortCount === 1 && !hasLong) {
timeNotice.textContent =
"💡 An diesem Tag haben wir reichlich Zeit! Wenn du möchtest, kannst du noch eine zweite kurze Aktivität auswählen.";
timeNotice.classList.remove("hidden");
} else if (selectedDateType === "long" && shortCount === 2) {
timeNotice.textContent = "🎉 Perfekt! Du hast 2 kurze Aktivitäten für unseren Tag ausgewählt.";
timeNotice.classList.remove("hidden");
} else {
timeNotice.classList.add("hidden");
}
continueBtn.disabled = selectedList.length === 0;
}
}
continueBtn.addEventListener("click", () => {
if (selectedList.length === 0) {
return;
}
const hasCustomSelected = selectedList.some(isCustomActivity);
if (hasCustomSelected) {
const customDesc = customActivityInput ? customActivityInput.value.trim() : "";
if (!customDesc) {
return;
}
const customAct = selectedList.find(isCustomActivity);
const savedCustomAct = {
name: customDesc,
icon: customAct.icon || "✏️",
type: "custom",
customDescription: customDesc,
};
localStorage.setItem("selectedActivities", JSON.stringify([savedCustomAct]));
localStorage.setItem("selectedActivity", customDesc);
} else {
localStorage.setItem("selectedActivities", JSON.stringify(selectedList));
localStorage.setItem("selectedActivity", selectedList.map((a) => a.name).join(" + "));
}
window.location.href = "name.html";
});
})();
+260
View File
@@ -0,0 +1,260 @@
.page-admin {
background: #f8f4ec;
font-size: 1rem;
}
.admin-shell {
width: min(100% - 32px, 720px);
margin: 0 auto;
padding: 40px 0 56px;
}
.login-view {
width: min(100%, 400px);
margin: 12vh auto 0;
padding: 32px;
background: var(--color-white);
border: 1px solid #eaded1;
border-radius: 8px;
box-shadow: 0 12px 30px rgba(74, 44, 61, 0.12);
}
.eyebrow {
margin-bottom: 6px;
color: #8c6d52;
font-family: var(--font-heading);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.login-copy,
.section-heading p {
color: var(--color-text-light);
}
.login-form,
.date-form {
display: grid;
gap: 12px;
margin-top: 24px;
}
.login-form label,
.date-form > label,
.type-selector legend {
color: var(--color-text);
font-family: var(--font-heading);
font-size: 0.92rem;
font-weight: 700;
}
.login-form input,
.date-form > input {
width: 100%;
min-height: 46px;
padding: 10px 12px;
border: 1px solid #cfbba9;
border-radius: 6px;
background: #fffefa;
color: var(--color-text);
font: inherit;
}
.login-form input:focus,
.date-form > input:focus,
.date-type-select:focus {
outline: 3px solid rgba(224, 87, 126, 0.28);
outline-offset: 1px;
border-color: var(--color-rose);
}
.login-form .btn,
.date-form .btn {
min-height: 48px;
border-radius: 6px;
font-size: 1rem;
}
.form-message {
min-height: 1.5em;
margin: 12px 0 0;
color: var(--color-rose-dark);
font-weight: 700;
}
.form-message.is-success {
color: #287540;
}
.admin-view {
display: grid;
gap: 32px;
}
.admin-header,
.dates-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.admin-header h1 {
margin-bottom: 0;
}
.text-action {
padding: 8px 0;
border: 0;
background: transparent;
color: var(--color-rose-dark);
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.admin-section {
padding-top: 24px;
border-top: 1px solid #dfcdbd;
}
.section-heading h2 {
margin-bottom: 4px;
font-size: 1.25rem;
}
.section-heading p {
margin-bottom: 0;
}
.type-selector {
display: flex;
gap: 8px;
margin: 4px 0 2px;
padding: 0;
border: 0;
}
.type-selector legend {
margin-bottom: 8px;
}
.type-selector label {
flex: 1;
}
.type-selector input {
position: absolute;
opacity: 0;
}
.type-selector span {
display: block;
padding: 10px 12px;
border: 1px solid #cfbba9;
border-radius: 6px;
background: #fffefa;
color: var(--color-text-light);
font-family: var(--font-heading);
font-weight: 700;
text-align: center;
cursor: pointer;
}
.type-selector input:checked + span {
border-color: var(--color-rose-dark);
background: var(--color-rose-dark);
color: var(--color-white);
}
.type-selector input:focus-visible + span {
outline: 3px solid rgba(224, 87, 126, 0.28);
outline-offset: 1px;
}
.dates-list {
display: grid;
gap: 10px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.date-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 118px auto;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #e5d8ce;
}
.date-label {
font-family: var(--font-heading);
font-weight: 700;
}
.date-type-select {
min-height: 38px;
padding: 6px 28px 6px 8px;
border: 1px solid #cfbba9;
border-radius: 5px;
background: #fffefa;
color: var(--color-text);
font: inherit;
}
.delete-date-btn {
min-height: 38px;
padding: 6px 10px;
border: 1px solid #c84a62;
border-radius: 5px;
background: transparent;
color: #a83049;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.delete-date-btn:hover,
.delete-date-btn:focus-visible {
background: #fff0f2;
}
.empty-dates {
margin: 18px 0 0;
color: var(--color-text-light);
}
.hidden {
display: none !important;
}
@media (max-width: 480px) {
.admin-shell {
width: min(100% - 24px, 720px);
padding-top: 24px;
}
.login-view {
margin-top: 8vh;
padding: 24px;
}
.date-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.date-type-select {
grid-column: 1;
}
.delete-date-btn {
grid-column: 2;
grid-row: 1 / span 2;
}
}
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Terminverwaltung - Date-Planer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&family=Quicksand:wght@600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="admin.css" />
</head>
<body class="page-admin">
<main class="admin-shell">
<section id="loginView" class="login-view" aria-labelledby="loginTitle">
<p class="eyebrow">Date-Planer</p>
<h1 id="loginTitle">Terminverwaltung</h1>
<p class="login-copy">Melde dich an, um verfügbare Tage zu verwalten.</p>
<form id="loginForm" class="login-form">
<label for="passwordInput">Passwort</label>
<input id="passwordInput" name="password" type="password" autocomplete="current-password" required />
<button class="btn btn-primary" type="submit">Anmelden</button>
</form>
<p id="loginMessage" class="form-message" role="status" aria-live="polite"></p>
</section>
<section id="adminView" class="admin-view hidden" aria-labelledby="adminTitle">
<header class="admin-header">
<div>
<p class="eyebrow">Date-Planer</p>
<h1 id="adminTitle">Verfügbare Tage</h1>
</div>
<button id="logoutBtn" class="text-action" type="button">Abmelden</button>
</header>
<section class="admin-section add-date-section" aria-labelledby="addDateTitle">
<div class="section-heading">
<h2 id="addDateTitle">Neuen Tag hinzufügen</h2>
<p>Datum im Kalender wählen und Zeitumfang festlegen.</p>
</div>
<form id="addDateForm" class="date-form">
<label for="dateInput">Datum</label>
<input id="dateInput" name="date" type="date" required />
<fieldset class="type-selector">
<legend>Tagestyp</legend>
<label>
<input type="radio" name="dateType" value="short" checked />
<span>Kurz</span>
</label>
<label>
<input type="radio" name="dateType" value="long" />
<span>Lang</span>
</label>
</fieldset>
<button class="btn btn-primary" type="submit">Datum hinzufügen</button>
</form>
<p id="addDateMessage" class="form-message" role="status" aria-live="polite"></p>
</section>
<section class="admin-section dates-section" aria-labelledby="datesTitle">
<div class="section-heading dates-heading">
<div>
<h2 id="datesTitle">Eingetragene Tage</h2>
<p id="dateCount">Lade Termine...</p>
</div>
</div>
<ul id="datesList" class="dates-list" aria-live="polite"></ul>
<p id="emptyDates" class="empty-dates hidden">Noch keine Termine eingetragen.</p>
</section>
</section>
</main>
<script src="admin.js" defer></script>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
(function () {
const loginView = document.getElementById("loginView");
const adminView = document.getElementById("adminView");
const loginForm = document.getElementById("loginForm");
const passwordInput = document.getElementById("passwordInput");
const loginMessage = document.getElementById("loginMessage");
const addDateForm = document.getElementById("addDateForm");
const dateInput = document.getElementById("dateInput");
const addDateMessage = document.getElementById("addDateMessage");
const datesList = document.getElementById("datesList");
const dateCount = document.getElementById("dateCount");
const emptyDates = document.getElementById("emptyDates");
const logoutBtn = document.getElementById("logoutBtn");
let dates = [];
function setMessage(element, message, success) {
element.textContent = message;
element.classList.toggle("is-success", Boolean(success));
}
function todayKey() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatDate(date) {
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
async function request(path, options) {
const response = await fetch(path, {
...options,
headers: {
"Content-Type": "application/json",
...(options && options.headers),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.message || "Die Anfrage ist fehlgeschlagen.");
error.status = response.status;
throw error;
}
return data;
}
function showLogin(message) {
adminView.classList.add("hidden");
loginView.classList.remove("hidden");
if (message) {
setMessage(loginMessage, message, false);
}
passwordInput.focus();
}
function renderDates() {
datesList.innerHTML = "";
dateCount.textContent = `${dates.length} ${dates.length === 1 ? "Termin" : "Termine"}`;
emptyDates.classList.toggle("hidden", dates.length !== 0);
dates.forEach((date) => {
const row = document.createElement("li");
row.className = "date-row";
const label = document.createElement("span");
label.className = "date-label";
label.textContent = formatDate(date.date);
const typeSelect = document.createElement("select");
typeSelect.className = "date-type-select";
typeSelect.setAttribute("aria-label", `Tagestyp für ${formatDate(date.date)}`);
[["short", "Kurz"], ["long", "Lang"]].forEach(([value, text]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
option.selected = date.type === value;
typeSelect.appendChild(option);
});
typeSelect.addEventListener("change", async () => {
typeSelect.disabled = true;
try {
await request("/api/admin/dates", {
method: "PUT",
body: JSON.stringify({ date: date.date, type: typeSelect.value }),
});
date.type = typeSelect.value;
setMessage(addDateMessage, "Tagestyp aktualisiert.", true);
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
typeSelect.value = date.type;
setMessage(addDateMessage, error.message, false);
} finally {
typeSelect.disabled = false;
}
});
const deleteButton = document.createElement("button");
deleteButton.className = "delete-date-btn";
deleteButton.type = "button";
deleteButton.textContent = "Löschen";
deleteButton.addEventListener("click", async () => {
if (!window.confirm(`${formatDate(date.date)} wirklich löschen?`)) {
return;
}
deleteButton.disabled = true;
try {
await request("/api/admin/dates", {
method: "DELETE",
body: JSON.stringify({ date: date.date }),
});
dates = dates.filter((item) => item.date !== date.date);
renderDates();
setMessage(addDateMessage, "Datum gelöscht.", true);
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
setMessage(addDateMessage, error.message, false);
deleteButton.disabled = false;
}
});
row.append(label, typeSelect, deleteButton);
datesList.appendChild(row);
});
}
async function loadDates() {
try {
dates = await request("/api/admin/dates", { method: "GET" });
dates.sort((first, second) => first.date.localeCompare(second.date));
loginView.classList.add("hidden");
adminView.classList.remove("hidden");
renderDates();
} catch (error) {
if (error.status === 401) {
showLogin();
return;
}
showLogin("Die Termine konnten nicht geladen werden.");
}
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
setMessage(loginMessage, "", false);
try {
await request("/api/admin/login", {
method: "POST",
body: JSON.stringify({ password: passwordInput.value }),
});
passwordInput.value = "";
await loadDates();
} catch (error) {
setMessage(loginMessage, error.message, false);
}
});
addDateForm.addEventListener("submit", async (event) => {
event.preventDefault();
const type = addDateForm.elements.dateType.value;
setMessage(addDateMessage, "", false);
try {
await request("/api/admin/dates", {
method: "POST",
body: JSON.stringify({ date: dateInput.value, type }),
});
dates.push({ date: dateInput.value, type });
dates.sort((first, second) => first.date.localeCompare(second.date));
renderDates();
setMessage(addDateMessage, "Datum hinzugefügt.", true);
addDateForm.reset();
dateInput.min = todayKey();
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
setMessage(addDateMessage, error.message, false);
}
});
logoutBtn.addEventListener("click", async () => {
try {
await request("/api/admin/logout", { method: "POST" });
} finally {
showLogin();
}
});
dateInput.min = todayKey();
loadDates();
})();
View File
+1 -1
View File
@@ -22,6 +22,6 @@
});
yesBtn.addEventListener("click", () => {
window.location.href = "activity.html";
window.location.href = "datetime.html";
});
})();
+5 -2
View File
@@ -29,7 +29,7 @@
.summary-item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-rose-light);
@@ -42,7 +42,10 @@
.summary-icon {
font-size: 1.8rem;
line-height: 1;
line-height: 1.2;
flex-shrink: 0;
width: 44px;
text-align: center;
}
.summary-details {
+1 -1
View File
@@ -21,7 +21,7 @@
<div id="summarySection">
<div class="intro">
<h1>Fast geschafft! ✨</h1>
<p>Überprüfe noch einmal eure Angaben für das perfekt geplante Date.</p>
<p>Überprüfe noch einmal deine Angaben für unser perfekt geplantes Date.</p>
</div>
<div class="summary-card">
+113
View File
@@ -0,0 +1,113 @@
// Checkout & Bestätigungsseite
(function () {
const backLink = document.getElementById("backLink");
const summarySection = document.getElementById("summarySection");
const confirmationSection = document.getElementById("confirmationSection");
const summaryActivity = document.getElementById("summaryActivity");
const summaryDate = document.getElementById("summaryDate");
const summaryName = document.getElementById("summaryName");
const activityIcon = document.getElementById("activityIcon");
const checkoutBtn = document.getElementById("checkoutBtn");
function cleanPrefix(str) {
if (!str) return "";
return str.replace(/^(Etwas eigenes|Sonstiges):\s*/i, "");
}
const selectedActivity = cleanPrefix(localStorage.getItem("selectedActivity"));
const selectedDate = localStorage.getItem("selectedDate");
const selectedName = localStorage.getItem("selectedName");
if (!selectedActivity || !selectedDate || !selectedName) {
window.location.href = "index.html";
return;
}
summaryActivity.textContent = selectedActivity;
summaryName.textContent = selectedName;
const dateObj = new Date(`${selectedDate}T00:00:00`);
const formattedDate = dateObj.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
summaryDate.textContent = formattedDate;
// Icons aus selectedActivities oder appsettings.json laden
let storedActivities = [];
try {
storedActivities = JSON.parse(localStorage.getItem("selectedActivities") || "[]");
} catch (e) {}
if (storedActivities.length > 0) {
activityIcon.textContent = storedActivities.map((a) => a.icon).join(" ");
} else {
fetch("/api/settings", { cache: "no-store" })
.then((res) => res.json())
.then((settings) => {
const match = (settings.activities || []).find((a) => a.name === selectedActivity);
if (match && match.icon) {
activityIcon.textContent = match.icon;
}
})
.catch(() => {});
}
checkoutBtn.addEventListener("click", () => {
checkoutBtn.disabled = true;
const originalText = checkoutBtn.textContent;
checkoutBtn.textContent = "Wird übermittelt... 💌";
const payload = {
activity: cleanPrefix(selectedActivity),
activities: storedActivities.map((a) => ({
...a,
name: cleanPrefix(a.name),
customDescription: cleanPrefix(a.customDescription || a.name),
})),
date: selectedDate,
dateType: localStorage.getItem("selectedDateType") || "long",
name: selectedName,
};
fetch("/api/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
.then((res) => {
if (!res.ok) {
throw new Error("Fehler beim Übermitteln.");
}
return res.json();
})
.then((data) => {
if (data.success) {
// Alle lokal gespeicherten Daten (inkl. bookedDates und ausgewählter Werte) leeren,
// damit bei der nächsten Buchung alles frisch geladen / neu ausgewählt wird.
localStorage.clear();
summarySection.classList.add("hidden");
if (backLink) {
backLink.classList.add("hidden");
}
confirmationSection.classList.remove("hidden");
} else {
alert(data.message || "Es gab ein Problem bei der Übermittlung.");
checkoutBtn.disabled = false;
checkoutBtn.textContent = originalText;
}
})
.catch((err) => {
console.error("Submission error:", err);
alert("Übermittlung fehlgeschlagen. Bitte versuche es erneut.");
checkoutBtn.disabled = false;
checkoutBtn.textContent = originalText;
});
});
})();
+2 -2
View File
@@ -16,10 +16,10 @@
</div>
<main class="container">
<a class="back-link" href="activity.html"> Zurück</a>
<a class="back-link" href="index.html"> Zurück</a>
<div class="intro">
<h1>Wann soll es losgehen? 💌</h1>
<p id="activitySummary">Wähle das Datum für euer Date.</p>
<p id="activitySummary">Wähl das passende Datum für unser Date aus.</p>
</div>
<section class="calendar-card" aria-label="Kalender">
+45 -19
View File
@@ -7,7 +7,6 @@
"Juli", "August", "September", "Oktober", "November", "Dezember",
];
const activitySummary = document.getElementById("activitySummary");
const monthLabel = document.getElementById("monthLabel");
const weekdayRow = document.getElementById("weekdayRow");
const dayGrid = document.getElementById("dayGrid");
@@ -15,21 +14,15 @@
const nextMonthBtn = document.getElementById("nextMonthBtn");
const continueBtn = document.getElementById("continueBtn");
// Ohne gewählte Aktivität ergibt diese Seite keinen Sinn.
const selectedActivity = localStorage.getItem("selectedActivity");
if (!selectedActivity) {
window.location.href = "activity.html";
return;
}
activitySummary.textContent = `Für unser Date: „${selectedActivity}`;
const today = new Date();
today.setHours(0, 0, 0, 0);
let availableDates = new Set();
let availableDatesMap = new Map();
let bookedDatesSet = new Set();
let viewYear = today.getFullYear();
let viewMonth = today.getMonth();
let selectedDate = null;
let selectedDateType = "long";
WEEKDAYS.forEach((day) => {
const span = document.createElement("span");
@@ -44,14 +37,43 @@
return `${year}-${month}-${day}`;
}
fetch("appsettings.json")
.then((response) => response.json())
.then((settings) => {
availableDates = new Set(settings.availableDates || []);
Promise.all([
fetch("/api/settings?t=" + Date.now(), { cache: "no-store" }).then((res) => res.json()),
fetch("/api/booked-dates?t=" + Date.now(), { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.catch(() => []),
])
.then(([settings, bookedDates]) => {
let localBooked = [];
try {
localBooked = JSON.parse(localStorage.getItem("bookedDates") || "[]");
} catch (e) {}
const upcoming = Array.from(availableDates)
const allBooked = [...(bookedDates || []), ...localBooked]
.map((d) => (typeof d === "string" ? d.trim() : ""))
.filter(Boolean);
bookedDatesSet = new Set(allBooked);
const datesList = settings.availableDates || [];
datesList.forEach((item) => {
let key = "";
let type = "long";
if (typeof item === "string") {
key = item.trim();
} else if (item && item.date) {
key = item.date.trim();
type = item.type || "long";
}
if (key && !bookedDatesSet.has(key)) {
availableDatesMap.set(key, { type });
}
});
const upcoming = Array.from(availableDatesMap.keys())
.map((dateKey) => new Date(`${dateKey}T00:00:00`))
.filter((date) => date >= today)
.filter((date) => date > today)
.sort((a, b) => a - b);
if (upcoming.length > 0) {
@@ -82,12 +104,15 @@
for (let day = 1; day <= daysInMonth; day++) {
const cellDate = new Date(viewYear, viewMonth, day);
const key = toDateKey(cellDate);
const button = document.createElement("button");
button.type = "button";
button.className = "day-cell";
button.textContent = String(day);
const isAvailable = availableDates.has(toDateKey(cellDate));
const dateInfo = availableDatesMap.get(key);
const isPastOrToday = cellDate <= today;
const isAvailable = Boolean(dateInfo) && !isPastOrToday;
const isToday = cellDate.getTime() === today.getTime();
const isSelected = selectedDate && cellDate.getTime() === selectedDate.getTime();
@@ -104,6 +129,7 @@
button.addEventListener("click", () => {
selectedDate = cellDate;
selectedDateType = dateInfo ? dateInfo.type : "long";
renderCalendar();
updateContinueState();
});
@@ -141,9 +167,9 @@
if (!selectedDate) {
return;
}
// Ablage für die spätere Anbindung an Datenbank/Storage
localStorage.setItem("selectedDate", toDateKey(selectedDate));
window.location.href = "name.html";
localStorage.setItem("selectedDateType", selectedDateType);
window.location.href = "activity.html";
});
})();
+1 -1
View File
@@ -16,7 +16,7 @@
</div>
<main class="content">
<h1 class="question">Hättest du Lust,<br />mit mir auszugehen? 💕</h1>
<h1 class="question">Hallo, ich bin dein Ehemann.<br>Hättest du Lust, mit mir auszugehen? 💕</h1>
<p class="subtitle">Eine kleine Frage mit hoffentlich großer Antwort …</p>
<div class="button-group">
View File
View File
+2 -2
View File
@@ -28,7 +28,7 @@
let selectedName = null;
fetch("appsettings.json")
fetch("/api/settings", { cache: "no-store" })
.then((response) => response.json())
.then((settings) => renderNames(settings.names || []))
.catch(() => {
@@ -36,7 +36,7 @@
});
function renderNames(names) {
const options = names.length > 0 ? names : ["Mein name ist Amor 😽"];
const options = names.length > 0 ? names : ["Du weißt, wer ich bin"];
options.forEach((name) => {
const card = document.createElement("button");
+34 -8
View File
@@ -28,14 +28,16 @@
box-sizing: border-box;
}
html,
body {
height: 100%;
html {
min-height: 100%;
}
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
font-family: var(--font-body);
font-size: clamp(1rem, 3.6vw, 1.15rem);
line-height: 1.5;
@@ -65,7 +67,8 @@ p {
width: 100%;
max-width: 480px;
margin: 0 auto;
padding: 24px 20px 40px;
padding: 20px 20px 32px;
flex: 1;
}
/* Buttons -------------------------------------------------------------- */
@@ -161,8 +164,8 @@ p {
/* Fixed bottom bar for a primary continue action ------------------------- */
body.has-continue-bar {
padding-bottom: 96px;
body.has-continue-bar main.container {
padding-bottom: calc(110px + env(safe-area-inset-bottom));
}
.continue-bar {
@@ -170,8 +173,10 @@ body.has-continue-bar {
left: 0;
right: 0;
bottom: 0;
padding: 16px 20px calc(16px + env(safe-area-inset-bottom));
background: linear-gradient(0deg, var(--color-bg) 60%, rgba(255, 245, 247, 0));
z-index: 100;
padding: 12px 20px calc(12px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(255, 245, 247, 0) 0%, var(--color-bg) 50%);
pointer-events: none;
}
.continue-bar .container {
@@ -179,6 +184,27 @@ body.has-continue-bar {
max-width: 480px;
}
.continue-bar .btn {
pointer-events: auto;
}
@media (min-width: 768px) {
.container {
flex: none;
}
body.has-continue-bar main.container {
padding-bottom: 12px;
}
.continue-bar {
position: static;
padding: 0 20px 40px;
background: none;
pointer-events: auto;
}
}
/* Back link --------------------------------------------------------------- */
.back-link {
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
settings_file="${SETTINGS_FILE:-/app/appsettings.json}"
if [ ! -f "$settings_file" ]; then
mkdir -p "$(dirname "$settings_file")"
cp /app/appsettings.default.json "$settings_file"
fi
exec /app/server