Compare commits
7
Commits
d2cc049b25
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097e72d364 | ||
|
|
253b65d976 | ||
|
|
4766a7d525 | ||
|
|
020f3ef746 | ||
|
|
23baff9ef0 | ||
|
|
8685b3c466 | ||
|
|
b99b1f4ae5 |
@@ -1,6 +1,7 @@
|
||||
# Server Port & Configuration
|
||||
PORT=8080
|
||||
WEB_DIR=public
|
||||
BOOKED_DATES_FILE=booked_dates.json
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=mail.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,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.
|
||||
@@ -1,2 +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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-1
@@ -18,7 +18,11 @@ 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/server"]
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
|
||||
@@ -41,6 +41,7 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
|
||||
| :--- | :--- | :--- |
|
||||
| `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` |
|
||||
@@ -50,14 +51,19 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
|
||||
|
||||
*(Hinweis: Wenn `SMTP_HOST` oder `TO_EMAIL` leer bleiben, läuft der Server im Testmodus ohne E-Mail-Versand.)*
|
||||
|
||||
### 2. Aktivitäten & Termine anpassen (`public/appsettings.json`)
|
||||
### 2. Aktivitäten & Termine anpassen (`appsettings.json`)
|
||||
|
||||
Passe in `public/appsettings.json` die zur Auswahl stehenden Aktivitäten sowie die verfügbaren Tage an:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"activities": [
|
||||
{
|
||||
"name": "Picknick auf der Wiese",
|
||||
"icon": "🧺",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Cocktailabend zu zweit",
|
||||
"icon": "🍸",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Massage zu Hause",
|
||||
"icon": "💆",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Ausgedehnte Wanderung",
|
||||
"icon": "🥾",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Kinofilm",
|
||||
"icon": "🎬",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Sushi selber machen",
|
||||
"icon": "🍣",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Untersetzer aus Ton basteln",
|
||||
"icon": "🪨",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Freizeitpark",
|
||||
"icon": "🎢",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Spaziergang im Wald",
|
||||
"icon": "🌲",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Kirschbier Tasting",
|
||||
"icon": "🍒",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Nudeln selber machen",
|
||||
"icon": "🍜",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Chillen im Freibad",
|
||||
"icon": "🏊",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Film zu Hause + Essen bestellen",
|
||||
"icon": "🛋️",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Squash spielen",
|
||||
"icon": "🏸",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Essen im Namaste",
|
||||
"icon": "🍛",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Museum für Gegenwartskunst",
|
||||
"icon": "🖼️",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Playstation Co-Op",
|
||||
"icon": "🎮",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "Etwas eigenes",
|
||||
"icon": "✏️",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"availableDates": [
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-22",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-28",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-29",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-01",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-08",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-18",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-25",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-26",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-27",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-29",
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-30",
|
||||
"type": "short"
|
||||
}
|
||||
],
|
||||
"names": [],
|
||||
"adminPassword": "change-me"
|
||||
}
|
||||
+13
-6
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
date-wizard:
|
||||
build:
|
||||
@@ -7,14 +5,23 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: date-wizard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
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.example.com`)"
|
||||
- "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=letsencrypt"
|
||||
- "traefik.http.routers.date-wizard.tls.certresolver=leresolver"
|
||||
- "traefik.http.services.date-wizard.loadbalancer.server.port=8080"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -3,19 +3,25 @@ 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 {
|
||||
@@ -31,6 +37,32 @@ type Response struct {
|
||||
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")
|
||||
|
||||
@@ -45,6 +77,12 @@ func main() {
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -56,6 +94,301 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -80,16 +413,13 @@ func handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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("Error sending email: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(Response{
|
||||
Success: false,
|
||||
Message: "E-Mail konnte nicht gesendet werden.",
|
||||
})
|
||||
return
|
||||
log.Printf("Warning: Email could not be sent: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -100,6 +430,91 @@ func handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -131,28 +546,54 @@ func sendEmail(payload SurveyPayload) error {
|
||||
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("Content-Type: text/plain; charset=UTF-8\r\n\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", payload.Activity))
|
||||
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 {
|
||||
bodyBuilder.WriteString(fmt.Sprintf(" - %s %s (%s)\r\n", act.Icon, act.Name, act.Type))
|
||||
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("Viel Spaß beim gemeinsamen Date! 💕\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)
|
||||
@@ -160,6 +601,59 @@ func sendEmail(payload SurveyPayload) error {
|
||||
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 {
|
||||
|
||||
@@ -96,6 +96,43 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
</div>
|
||||
|
||||
<div class="activity-grid" id="activityGrid"></div>
|
||||
|
||||
<div id="customActivitySection" class="custom-activity-section hidden">
|
||||
<label for="customActivityInput" class="custom-activity-label">Was möchtest du unternehmen? ✏️</label>
|
||||
<input
|
||||
type="text"
|
||||
id="customActivityInput"
|
||||
class="custom-activity-input"
|
||||
placeholder="Beschreibung der Aktivität eingeben..."
|
||||
maxlength="150"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="continue-bar">
|
||||
|
||||
+124
-11
@@ -5,6 +5,8 @@
|
||||
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";
|
||||
@@ -26,15 +28,59 @@
|
||||
let availableActivities = [];
|
||||
let selectedList = [];
|
||||
|
||||
fetch("appsettings.json")
|
||||
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") {
|
||||
availableActivities = allActivities.filter((a) => a.type === "short");
|
||||
} else {
|
||||
availableActivities = allActivities;
|
||||
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(() => {
|
||||
@@ -43,20 +89,35 @@
|
||||
|
||||
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 isSelected = selectedList.some((a) => a.name === 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 = hasShortSelected && activity.type === "long";
|
||||
const isLongDisabled = !isCustom && hasShortSelected && activity.type === "long";
|
||||
if (isLongDisabled) {
|
||||
card.classList.add("is-disabled");
|
||||
card.disabled = true;
|
||||
@@ -84,6 +145,26 @@
|
||||
}
|
||||
|
||||
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") {
|
||||
@@ -93,33 +174,41 @@
|
||||
selectedList = [activity];
|
||||
}
|
||||
} else {
|
||||
// Short activity
|
||||
if (isAlreadySelected) {
|
||||
selectedList = selectedList.filter((a) => a.name !== activity.name);
|
||||
} else if (selectedDateType === "short") {
|
||||
// An kurzen Tagen ist nur maximal eine kurze Aktivität erlaubt
|
||||
selectedList = [activity];
|
||||
} else {
|
||||
// An langen Tagen:
|
||||
// Falls vorher eine lange Aktivität gewählt war, ersetzen
|
||||
if (selectedList.some((a) => a.type === "long")) {
|
||||
selectedList = [activity];
|
||||
} else if (selectedList.length >= 2) {
|
||||
// Maximal 2 kurze Aktivitäten, die zweite ersetzen
|
||||
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.";
|
||||
@@ -133,13 +222,37 @@
|
||||
|
||||
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();
|
||||
})();
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"activities": [
|
||||
{ "name": "Picknick auf der Wiese", "icon": "🧺", "type": "short" },
|
||||
{ "name": "Cocktailabend zu zweit", "icon": "🍸", "type": "long" },
|
||||
{ "name": "Massage zu Hause", "icon": "💆", "type": "short" },
|
||||
{ "name": "Ausgedehnte Wanderung", "icon": "🥾", "type": "long" },
|
||||
{ "name": "Kinofilm", "icon": "🎬", "type": "short" },
|
||||
{ "name": "Sushi selber machen", "icon": "🍣", "type": "short" },
|
||||
{ "name": "Untersetzer aus Ton basteln", "icon": "🪨", "type": "long" },
|
||||
{ "name": "Freizeitpark", "icon": "🎢", "type": "long" },
|
||||
{ "name": "Spaziergang im Wald", "icon": "🌲", "type": "short" },
|
||||
{ "name": "Kirschbier Tasting", "icon": "🍒", "type": "short" },
|
||||
{ "name": "Nudeln selber machen", "icon": "🍜", "type": "long" },
|
||||
{ "name": "Chillen im Freibad", "icon": "🏊", "type": "short" },
|
||||
{ "name": "Film zu Hause + Essen bestellen", "icon": "🛋️", "type": "long" },
|
||||
{ "name": "Squash spielen", "icon": "🏸", "type": "short" },
|
||||
{ "name": "Essen im Namaste", "icon": "🍛", "type": "short" },
|
||||
{ "name": "Museum für Gegenwartskunst", "icon": "🖼️", "type": "short" },
|
||||
{ "name": "Playstation Co-Op", "icon": "🎮", "type": "short" }
|
||||
],
|
||||
"availableDates": [
|
||||
{ "date": "2026-08-11", "type": "short" },
|
||||
{ "date": "2026-08-15", "type": "short" },
|
||||
{ "date": "2026-08-18", "type": "short" },
|
||||
{ "date": "2026-08-20", "type": "short" },
|
||||
{ "date": "2026-08-20", "type": "short" },
|
||||
{ "date": "2026-08-22", "type": "long" },
|
||||
{ "date": "2026-08-23", "type": "long" },
|
||||
{ "date": "2026-08-24", "type": "short" },
|
||||
{ "date": "2026-08-26", "type": "short" },
|
||||
{ "date": "2026-08-27", "type": "short" },
|
||||
{ "date": "2026-08-28", "type": "short" },
|
||||
{ "date": "2026-08-29", "type": "long" },
|
||||
{ "date": "2026-08-31", "type": "short" },
|
||||
{ "date": "2026-09-01", "type": "short" },
|
||||
{ "date": "2026-09-07", "type": "short" },
|
||||
{ "date": "2026-09-08", "type": "short" },
|
||||
{ "date": "2026-09-14", "type": "short" },
|
||||
{ "date": "2026-09-15", "type": "short" },
|
||||
{ "date": "2026-09-17", "type": "short" },
|
||||
{ "date": "2026-09-18", "type": "short" },
|
||||
{ "date": "2026-09-25", "type": "short" },
|
||||
{ "date": "2026-09-26", "type": "long" },
|
||||
{ "date": "2026-09-27", "type": "short" },
|
||||
{ "date": "2026-09-28", "type": "short" },
|
||||
{ "date": "2026-09-29", "type": "short" },
|
||||
{ "date": "2026-09-30", "type": "short" }
|
||||
|
||||
|
||||
|
||||
],
|
||||
"names": []
|
||||
}
|
||||
+17
-4
@@ -10,7 +10,12 @@
|
||||
const activityIcon = document.getElementById("activityIcon");
|
||||
const checkoutBtn = document.getElementById("checkoutBtn");
|
||||
|
||||
const selectedActivity = localStorage.getItem("selectedActivity");
|
||||
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");
|
||||
|
||||
@@ -40,7 +45,7 @@
|
||||
if (storedActivities.length > 0) {
|
||||
activityIcon.textContent = storedActivities.map((a) => a.icon).join(" ");
|
||||
} else {
|
||||
fetch("appsettings.json")
|
||||
fetch("/api/settings", { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((settings) => {
|
||||
const match = (settings.activities || []).find((a) => a.name === selectedActivity);
|
||||
@@ -57,8 +62,12 @@
|
||||
checkoutBtn.textContent = "Wird übermittelt... 💌";
|
||||
|
||||
const payload = {
|
||||
activity: selectedActivity,
|
||||
activities: storedActivities,
|
||||
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,
|
||||
@@ -79,6 +88,10 @@
|
||||
})
|
||||
.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");
|
||||
|
||||
+28
-5
@@ -18,6 +18,7 @@
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let availableDatesMap = new Map();
|
||||
let bookedDatesSet = new Set();
|
||||
let viewYear = today.getFullYear();
|
||||
let viewMonth = today.getMonth();
|
||||
let selectedDate = null;
|
||||
@@ -36,15 +37,37 @@
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
fetch("appsettings.json")
|
||||
.then((response) => response.json())
|
||||
.then((settings) => {
|
||||
Promise.all([
|
||||
fetch("/api/settings?t=" + Date.now(), { cache: "no-store" }).then((res) => res.json()),
|
||||
fetch("/api/booked-dates?t=" + Date.now(), { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.catch(() => []),
|
||||
])
|
||||
.then(([settings, bookedDates]) => {
|
||||
let localBooked = [];
|
||||
try {
|
||||
localBooked = JSON.parse(localStorage.getItem("bookedDates") || "[]");
|
||||
} catch (e) {}
|
||||
|
||||
const 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") {
|
||||
availableDatesMap.set(item, { type: "long" });
|
||||
key = item.trim();
|
||||
} else if (item && item.date) {
|
||||
availableDatesMap.set(item.date, { type: item.type || "long" });
|
||||
key = item.date.trim();
|
||||
type = item.type || "long";
|
||||
}
|
||||
|
||||
if (key && !bookedDatesSet.has(key)) {
|
||||
availableDatesMap.set(key, { type });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
|
||||
let selectedName = null;
|
||||
|
||||
fetch("appsettings.json")
|
||||
fetch("/api/settings", { cache: "no-store" })
|
||||
.then((response) => response.json())
|
||||
.then((settings) => renderNames(settings.names || []))
|
||||
.catch(() => {
|
||||
|
||||
Reference in New Issue
Block a user