Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4766a7d525 | ||
|
|
020f3ef746 | ||
|
|
23baff9ef0 | ||
|
|
8685b3c466 | ||
|
|
b99b1f4ae5 |
@@ -1,6 +1,7 @@
|
|||||||
# Server Port & Configuration
|
# Server Port & Configuration
|
||||||
PORT=8080
|
PORT=8080
|
||||||
WEB_DIR=public
|
WEB_DIR=public
|
||||||
|
BOOKED_DATES_FILE=booked_dates.json
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
SMTP_HOST=mail.example.com
|
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
|
.env
|
||||||
*.exe
|
*.exe
|
||||||
|
booked_dates.json
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein:
|
|||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `PORT` | HTTP-Port des Go-Servers | `8080` |
|
| `PORT` | HTTP-Port des Go-Servers | `8080` |
|
||||||
| `WEB_DIR` | Verzeichnis der statischen Assets | `public` |
|
| `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_HOST` | Postausgangsserver (SMTP) | `mail.example.com` |
|
||||||
| `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` |
|
| `SMTP_PORT` | SMTP-Port (Standard meist 587 oder 465) | `587` |
|
||||||
| `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` |
|
| `SMTP_USER` | Benutzername / E-Mail für SMTP-Auth | `absender@example.com` |
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"net/smtp"
|
"net/smtp"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
CustomDescription string `json:"customDescription,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SurveyPayload struct {
|
type SurveyPayload struct {
|
||||||
@@ -45,6 +47,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.HandleFunc("/api/submit", handleSubmit)
|
http.HandleFunc("/api/submit", handleSubmit)
|
||||||
|
http.HandleFunc("/api/booked-dates", handleBookedDates)
|
||||||
|
|
||||||
// Serve static files from webDir directory
|
// Serve static files from webDir directory
|
||||||
fs := http.FileServer(http.Dir(webDir))
|
fs := http.FileServer(http.Dir(webDir))
|
||||||
@@ -80,16 +83,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)
|
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)
|
err = sendEmail(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error sending email: %v", err)
|
log.Printf("Warning: Email could not be sent: %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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -100,6 +100,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 {
|
func enableCORS(w http.ResponseWriter, r *http.Request) bool {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||||
@@ -131,28 +216,54 @@ func sendEmail(payload SurveyPayload) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boundary := fmt.Sprintf("BOUNDARY_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
var bodyBuilder bytes.Buffer
|
var bodyBuilder bytes.Buffer
|
||||||
bodyBuilder.WriteString(fmt.Sprintf("From: %s\r\n", fromEmail))
|
bodyBuilder.WriteString(fmt.Sprintf("From: %s\r\n", fromEmail))
|
||||||
bodyBuilder.WriteString(fmt.Sprintf("To: %s\r\n", toEmail))
|
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(fmt.Sprintf("Subject: Neue Date-Reservierung von %s! 💕\r\n", payload.Name))
|
||||||
bodyBuilder.WriteString("MIME-Version: 1.0\r\n")
|
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("Hallo!\r\n\r\n")
|
||||||
bodyBuilder.WriteString("Es gibt eine neue Date-Reservierung über deinen Date-Planer:\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("👤 Name: %s\r\n", payload.Name))
|
||||||
bodyBuilder.WriteString(fmt.Sprintf("📅 Datum: %s (%s)\r\n", payload.Date, payload.DateType))
|
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 {
|
if len(payload.Activities) > 0 {
|
||||||
bodyBuilder.WriteString("Details der Aktivitäten:\r\n")
|
bodyBuilder.WriteString("Details der Aktivitäten:\r\n")
|
||||||
for _, act := range payload.Activities {
|
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("\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)
|
auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost)
|
||||||
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
|
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
|
||||||
@@ -160,6 +271,59 @@ func sendEmail(payload SurveyPayload) error {
|
|||||||
return smtp.SendMail(addr, auth, fromEmail, []string{toEmail}, bodyBuilder.Bytes())
|
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) {
|
func loadEnv(filename string) {
|
||||||
file, err := os.Open(filename)
|
file, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -96,6 +96,43 @@
|
|||||||
animation: fadeIn 0.3s ease;
|
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 {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,18 @@
|
|||||||
</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">
|
||||||
|
|||||||
+148
-35
@@ -5,6 +5,8 @@
|
|||||||
const dateSummary = document.getElementById("dateSummary");
|
const dateSummary = document.getElementById("dateSummary");
|
||||||
const timeNotice = document.getElementById("timeNotice");
|
const timeNotice = document.getElementById("timeNotice");
|
||||||
const continueBtn = document.getElementById("continueBtn");
|
const continueBtn = document.getElementById("continueBtn");
|
||||||
|
const customActivitySection = document.getElementById("customActivitySection");
|
||||||
|
const customActivityInput = document.getElementById("customActivityInput");
|
||||||
|
|
||||||
const selectedDate = localStorage.getItem("selectedDate");
|
const selectedDate = localStorage.getItem("selectedDate");
|
||||||
const selectedDateType = localStorage.getItem("selectedDateType") || "long";
|
const selectedDateType = localStorage.getItem("selectedDateType") || "long";
|
||||||
@@ -26,15 +28,59 @@
|
|||||||
let availableActivities = [];
|
let availableActivities = [];
|
||||||
let selectedList = [];
|
let selectedList = [];
|
||||||
|
|
||||||
|
function isCustomActivity(act) {
|
||||||
|
return act && (act.name === "Etwas eigenes" || act.name === "Sonstiges" || act.type === "custom");
|
||||||
|
}
|
||||||
|
|
||||||
fetch("appsettings.json")
|
fetch("appsettings.json")
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((settings) => {
|
.then((settings) => {
|
||||||
const allActivities = settings.activities || [];
|
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") {
|
if (selectedDateType === "short") {
|
||||||
availableActivities = allActivities.filter((a) => a.type === "short");
|
otherActivities = otherActivities.filter((a) => a.type === "short");
|
||||||
} else {
|
|
||||||
availableActivities = allActivities;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "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();
|
renderActivities();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -43,20 +89,35 @@
|
|||||||
|
|
||||||
function renderActivities() {
|
function renderActivities() {
|
||||||
activityGrid.innerHTML = "";
|
activityGrid.innerHTML = "";
|
||||||
|
const hasCustomSelected = selectedList.some(isCustomActivity);
|
||||||
const hasShortSelected = selectedList.some((a) => a.type === "short");
|
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) => {
|
availableActivities.forEach((activity) => {
|
||||||
const card = document.createElement("button");
|
const card = document.createElement("button");
|
||||||
card.type = "button";
|
card.type = "button";
|
||||||
card.className = "activity-card";
|
card.className = "activity-card";
|
||||||
card.dataset.activity = activity.name;
|
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) {
|
if (isSelected) {
|
||||||
card.classList.add("selected");
|
card.classList.add("selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLongDisabled = hasShortSelected && activity.type === "long";
|
const isLongDisabled = !isCustom && hasShortSelected && activity.type === "long";
|
||||||
if (isLongDisabled) {
|
if (isLongDisabled) {
|
||||||
card.classList.add("is-disabled");
|
card.classList.add("is-disabled");
|
||||||
card.disabled = true;
|
card.disabled = true;
|
||||||
@@ -84,31 +145,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCardClick(activity) {
|
function handleCardClick(activity) {
|
||||||
const isAlreadySelected = selectedList.some((a) => a.name === activity.name);
|
const isCustom = isCustomActivity(activity);
|
||||||
|
const hasCustomSelected = selectedList.some(isCustomActivity);
|
||||||
|
|
||||||
if (activity.type === "long") {
|
if (isCustom) {
|
||||||
if (isAlreadySelected) {
|
if (hasCustomSelected) {
|
||||||
selectedList = [];
|
selectedList = [];
|
||||||
} else {
|
} else {
|
||||||
|
// Custom-Aktivität darf mit keiner anderen Aktivität kombiniert werden!
|
||||||
selectedList = [activity];
|
selectedList = [activity];
|
||||||
|
setTimeout(() => {
|
||||||
|
if (customActivityInput) {
|
||||||
|
customActivityInput.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Short activity
|
if (hasCustomSelected) {
|
||||||
if (isAlreadySelected) {
|
selectedList = [];
|
||||||
selectedList = selectedList.filter((a) => a.name !== activity.name);
|
}
|
||||||
} else if (selectedDateType === "short") {
|
|
||||||
// An kurzen Tagen ist nur maximal eine kurze Aktivität erlaubt
|
const isAlreadySelected = selectedList.some((a) => a.name === activity.name);
|
||||||
selectedList = [activity];
|
|
||||||
} else {
|
if (activity.type === "long") {
|
||||||
// An langen Tagen:
|
if (isAlreadySelected) {
|
||||||
// Falls vorher eine lange Aktivität gewählt war, ersetzen
|
selectedList = [];
|
||||||
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 {
|
} else {
|
||||||
selectedList.push(activity);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,30 +193,66 @@
|
|||||||
renderActivities();
|
renderActivities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (customActivityInput) {
|
||||||
|
customActivityInput.addEventListener("input", () => {
|
||||||
|
updateNoticeAndContinue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateNoticeAndContinue() {
|
function updateNoticeAndContinue() {
|
||||||
|
const hasCustomSelected = selectedList.some(isCustomActivity);
|
||||||
const shortCount = selectedList.filter((a) => a.type === "short").length;
|
const shortCount = selectedList.filter((a) => a.type === "short").length;
|
||||||
const hasLong = selectedList.some((a) => a.type === "long");
|
const hasLong = selectedList.some((a) => a.type === "long");
|
||||||
|
|
||||||
if (selectedDateType === "long" && shortCount === 1 && !hasLong) {
|
if (hasCustomSelected) {
|
||||||
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");
|
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.disabled = selectedList.length === 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
continueBtn.addEventListener("click", () => {
|
continueBtn.addEventListener("click", () => {
|
||||||
if (selectedList.length === 0) {
|
if (selectedList.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localStorage.setItem("selectedActivities", JSON.stringify(selectedList));
|
|
||||||
localStorage.setItem("selectedActivity", selectedList.map((a) => a.name).join(" + "));
|
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";
|
window.location.href = "name.html";
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
{ "name": "Squash spielen", "icon": "🏸", "type": "short" },
|
{ "name": "Squash spielen", "icon": "🏸", "type": "short" },
|
||||||
{ "name": "Essen im Namaste", "icon": "🍛", "type": "short" },
|
{ "name": "Essen im Namaste", "icon": "🍛", "type": "short" },
|
||||||
{ "name": "Museum für Gegenwartskunst", "icon": "🖼️", "type": "short" },
|
{ "name": "Museum für Gegenwartskunst", "icon": "🖼️", "type": "short" },
|
||||||
{ "name": "Playstation Co-Op", "icon": "🎮", "type": "short" }
|
{ "name": "Playstation Co-Op", "icon": "🎮", "type": "short" },
|
||||||
|
{ "name": "Etwas eigenes", "icon": "✏️", "type": "custom" }
|
||||||
],
|
],
|
||||||
"availableDates": [
|
"availableDates": [
|
||||||
{ "date": "2026-08-11", "type": "short" },
|
{ "date": "2026-08-11", "type": "short" },
|
||||||
|
|||||||
+16
-3
@@ -10,7 +10,12 @@
|
|||||||
const activityIcon = document.getElementById("activityIcon");
|
const activityIcon = document.getElementById("activityIcon");
|
||||||
const checkoutBtn = document.getElementById("checkoutBtn");
|
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 selectedDate = localStorage.getItem("selectedDate");
|
||||||
const selectedName = localStorage.getItem("selectedName");
|
const selectedName = localStorage.getItem("selectedName");
|
||||||
|
|
||||||
@@ -57,8 +62,12 @@
|
|||||||
checkoutBtn.textContent = "Wird übermittelt... 💌";
|
checkoutBtn.textContent = "Wird übermittelt... 💌";
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
activity: selectedActivity,
|
activity: cleanPrefix(selectedActivity),
|
||||||
activities: storedActivities,
|
activities: storedActivities.map((a) => ({
|
||||||
|
...a,
|
||||||
|
name: cleanPrefix(a.name),
|
||||||
|
customDescription: cleanPrefix(a.customDescription || a.name),
|
||||||
|
})),
|
||||||
date: selectedDate,
|
date: selectedDate,
|
||||||
dateType: localStorage.getItem("selectedDateType") || "long",
|
dateType: localStorage.getItem("selectedDateType") || "long",
|
||||||
name: selectedName,
|
name: selectedName,
|
||||||
@@ -79,6 +88,10 @@
|
|||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.success) {
|
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");
|
summarySection.classList.add("hidden");
|
||||||
if (backLink) {
|
if (backLink) {
|
||||||
backLink.classList.add("hidden");
|
backLink.classList.add("hidden");
|
||||||
|
|||||||
+28
-5
@@ -18,6 +18,7 @@
|
|||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
let availableDatesMap = new Map();
|
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;
|
||||||
@@ -36,15 +37,37 @@
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch("appsettings.json")
|
Promise.all([
|
||||||
.then((response) => response.json())
|
fetch("appsettings.json?t=" + Date.now()).then((res) => res.json()),
|
||||||
.then((settings) => {
|
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 || [];
|
const datesList = settings.availableDates || [];
|
||||||
datesList.forEach((item) => {
|
datesList.forEach((item) => {
|
||||||
|
let key = "";
|
||||||
|
let type = "long";
|
||||||
if (typeof item === "string") {
|
if (typeof item === "string") {
|
||||||
availableDatesMap.set(item, { type: "long" });
|
key = item.trim();
|
||||||
} else if (item && item.date) {
|
} 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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user