Compare commits
22
Commits
c2e157c095
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097e72d364 | ||
|
|
253b65d976 | ||
|
|
4766a7d525 | ||
|
|
020f3ef746 | ||
|
|
23baff9ef0 | ||
|
|
8685b3c466 | ||
|
|
b99b1f4ae5 | ||
|
|
d2cc049b25 | ||
|
|
83eafe1aef | ||
|
|
6b159affda | ||
|
|
89bd6f39f6 | ||
|
|
0ee82eb673 | ||
|
|
ad02939b88 | ||
|
|
964ac21acb | ||
|
|
617d906d3e | ||
|
|
ae330aeae8 | ||
|
|
192933c9b6 | ||
|
|
54420cf25a | ||
|
|
fc01c503b7 | ||
|
|
3d7da6c8da | ||
|
|
b230918903 | ||
|
|
28f402674a |
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.env
|
||||||
|
*.exe
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
*.exe
|
||||||
|
booked_dates.json
|
||||||
Vendored
+32
@@ -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
@@ -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"]
|
||||||
@@ -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).
|
||||||
@@ -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
@@ -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
@@ -1,22 +1,190 @@
|
|||||||
{
|
{
|
||||||
"activities": [
|
"activities": [
|
||||||
{ "name": "Essen bei Namaste", "icon": "🍛" },
|
{
|
||||||
{ "name": "Kinofilm", "icon": "🎬" },
|
"name": "Picknick auf der Wiese",
|
||||||
{ "name": "Film zu Hause + Essen bestellen", "icon": "🛋️" },
|
"icon": "🧺",
|
||||||
{ "name": "Spaziergang im Wald", "icon": "🌲" },
|
"type": "short"
|
||||||
{ "name": "Museum für Gegenwartskunst", "icon": "🖼️" },
|
},
|
||||||
{ "name": "Picknick auf der Wiese", "icon": "🧺" },
|
{
|
||||||
{ "name": "Massage zu Hause", "icon": "💆" }
|
"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": [
|
"availableDates": [
|
||||||
"2026-08-15",
|
{
|
||||||
"2026-08-16",
|
"date": "2026-08-18",
|
||||||
"2026-08-23",
|
"type": "short"
|
||||||
"2026-08-30",
|
},
|
||||||
"2026-09-05",
|
{
|
||||||
"2026-09-06"
|
"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
@@ -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");
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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 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>
|
</head>
|
||||||
<body class="page-activity has-continue-bar">
|
<body class="page-activity has-continue-bar">
|
||||||
<main class="container">
|
<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">
|
<div class="intro">
|
||||||
<h1>Worauf hast du Lust? 🥰</h1>
|
<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>
|
||||||
|
|
||||||
<div class="activity-grid" id="activityGrid"></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>
|
</main>
|
||||||
|
|
||||||
<div class="continue-bar">
|
<div class="continue-bar">
|
||||||
@@ -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";
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
@@ -22,6 +22,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
yesBtn.addEventListener("click", () => {
|
yesBtn.addEventListener("click", () => {
|
||||||
window.location.href = "activity.html";
|
window.location.href = "datetime.html";
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
.summary-item {
|
.summary-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid var(--color-rose-light);
|
border-bottom: 1px solid var(--color-rose-light);
|
||||||
@@ -42,7 +42,10 @@
|
|||||||
|
|
||||||
.summary-icon {
|
.summary-icon {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
line-height: 1;
|
line-height: 1.2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 44px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-details {
|
.summary-details {
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<div id="summarySection">
|
<div id="summarySection">
|
||||||
<div class="intro">
|
<div class="intro">
|
||||||
<h1>Fast geschafft! ✨</h1>
|
<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>
|
||||||
|
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="container">
|
<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">
|
<div class="intro">
|
||||||
<h1>Wann soll es losgehen? 💌</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<section class="calendar-card" aria-label="Kalender">
|
<section class="calendar-card" aria-label="Kalender">
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
];
|
];
|
||||||
|
|
||||||
const activitySummary = document.getElementById("activitySummary");
|
|
||||||
const monthLabel = document.getElementById("monthLabel");
|
const monthLabel = document.getElementById("monthLabel");
|
||||||
const weekdayRow = document.getElementById("weekdayRow");
|
const weekdayRow = document.getElementById("weekdayRow");
|
||||||
const dayGrid = document.getElementById("dayGrid");
|
const dayGrid = document.getElementById("dayGrid");
|
||||||
@@ -15,21 +14,15 @@
|
|||||||
const nextMonthBtn = document.getElementById("nextMonthBtn");
|
const nextMonthBtn = document.getElementById("nextMonthBtn");
|
||||||
const continueBtn = document.getElementById("continueBtn");
|
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();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
let availableDates = new Set();
|
let availableDatesMap = new Map();
|
||||||
|
let bookedDatesSet = new Set();
|
||||||
let viewYear = today.getFullYear();
|
let viewYear = today.getFullYear();
|
||||||
let viewMonth = today.getMonth();
|
let viewMonth = today.getMonth();
|
||||||
let selectedDate = null;
|
let selectedDate = null;
|
||||||
|
let selectedDateType = "long";
|
||||||
|
|
||||||
WEEKDAYS.forEach((day) => {
|
WEEKDAYS.forEach((day) => {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
@@ -44,14 +37,43 @@
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch("appsettings.json")
|
Promise.all([
|
||||||
.then((response) => response.json())
|
fetch("/api/settings?t=" + Date.now(), { cache: "no-store" }).then((res) => res.json()),
|
||||||
.then((settings) => {
|
fetch("/api/booked-dates?t=" + Date.now(), { cache: "no-store" })
|
||||||
availableDates = new Set(settings.availableDates || []);
|
.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`))
|
.map((dateKey) => new Date(`${dateKey}T00:00:00`))
|
||||||
.filter((date) => date >= today)
|
.filter((date) => date > today)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
if (upcoming.length > 0) {
|
if (upcoming.length > 0) {
|
||||||
@@ -82,12 +104,15 @@
|
|||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
const cellDate = new Date(viewYear, viewMonth, day);
|
const cellDate = new Date(viewYear, viewMonth, day);
|
||||||
|
const key = toDateKey(cellDate);
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "day-cell";
|
button.className = "day-cell";
|
||||||
button.textContent = String(day);
|
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 isToday = cellDate.getTime() === today.getTime();
|
||||||
const isSelected = selectedDate && cellDate.getTime() === selectedDate.getTime();
|
const isSelected = selectedDate && cellDate.getTime() === selectedDate.getTime();
|
||||||
|
|
||||||
@@ -104,6 +129,7 @@
|
|||||||
|
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
selectedDate = cellDate;
|
selectedDate = cellDate;
|
||||||
|
selectedDateType = dateInfo ? dateInfo.type : "long";
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
updateContinueState();
|
updateContinueState();
|
||||||
});
|
});
|
||||||
@@ -141,9 +167,9 @@
|
|||||||
if (!selectedDate) {
|
if (!selectedDate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ablage für die spätere Anbindung an Datenbank/Storage
|
|
||||||
localStorage.setItem("selectedDate", toDateKey(selectedDate));
|
localStorage.setItem("selectedDate", toDateKey(selectedDate));
|
||||||
window.location.href = "name.html";
|
localStorage.setItem("selectedDateType", selectedDateType);
|
||||||
|
window.location.href = "activity.html";
|
||||||
});
|
});
|
||||||
|
|
||||||
})();
|
})();
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="content">
|
<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>
|
<p class="subtitle">Eine kleine Frage mit hoffentlich großer Antwort …</p>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
let selectedName = null;
|
let selectedName = null;
|
||||||
|
|
||||||
fetch("appsettings.json")
|
fetch("/api/settings", { cache: "no-store" })
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((settings) => renderNames(settings.names || []))
|
.then((settings) => renderNames(settings.names || []))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function renderNames(names) {
|
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) => {
|
options.forEach((name) => {
|
||||||
const card = document.createElement("button");
|
const card = document.createElement("button");
|
||||||
@@ -28,14 +28,16 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html {
|
||||||
body {
|
min-height: 100%;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: clamp(1rem, 3.6vw, 1.15rem);
|
font-size: clamp(1rem, 3.6vw, 1.15rem);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -65,7 +67,8 @@ p {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px 20px 40px;
|
padding: 20px 20px 32px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons -------------------------------------------------------------- */
|
/* Buttons -------------------------------------------------------------- */
|
||||||
@@ -161,8 +164,8 @@ p {
|
|||||||
|
|
||||||
/* Fixed bottom bar for a primary continue action ------------------------- */
|
/* Fixed bottom bar for a primary continue action ------------------------- */
|
||||||
|
|
||||||
body.has-continue-bar {
|
body.has-continue-bar main.container {
|
||||||
padding-bottom: 96px;
|
padding-bottom: calc(110px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.continue-bar {
|
.continue-bar {
|
||||||
@@ -170,8 +173,10 @@ body.has-continue-bar {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
padding: 16px 20px calc(16px + env(safe-area-inset-bottom));
|
z-index: 100;
|
||||||
background: linear-gradient(0deg, var(--color-bg) 60%, rgba(255, 245, 247, 0));
|
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 {
|
.continue-bar .container {
|
||||||
@@ -179,6 +184,27 @@ body.has-continue-bar {
|
|||||||
max-width: 480px;
|
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 --------------------------------------------------------------- */
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
Reference in New Issue
Block a user