Add custom activity support and clear booking data on submission

This commit is contained in:
2026-08-17 12:55:47 +02:00
parent 020f3ef746
commit 4766a7d525
7 changed files with 248 additions and 53 deletions
@@ -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
+33 -7
View File
@@ -14,9 +14,10 @@ import (
) )
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 {
@@ -233,12 +234,19 @@ func sendEmail(payload SurveyPayload) error {
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")
} }
@@ -263,6 +271,14 @@ 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 { func generateICS(payload SurveyPayload) string {
parsedDate, err := time.Parse("2006-01-02", payload.Date) parsedDate, err := time.Parse("2006-01-02", payload.Date)
dtStart := strings.ReplaceAll(payload.Date, "-", "") dtStart := strings.ReplaceAll(payload.Date, "-", "")
@@ -275,8 +291,18 @@ func generateICS(payload SurveyPayload) string {
dtStamp := time.Now().UTC().Format("20060102T150405Z") dtStamp := time.Now().UTC().Format("20060102T150405Z")
uid := fmt.Sprintf("date-wizard-%s-%d@date-wizard", dtStart, time.Now().UnixNano()) uid := fmt.Sprintf("date-wizard-%s-%d@date-wizard", dtStart, time.Now().UnixNano())
summary := fmt.Sprintf("Date: %s", payload.Activity) activityText := cleanActivityName(payload.Activity)
description := fmt.Sprintf("Date mit %s\\nAktivität(en): %s", payload.Name, 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 var sb strings.Builder
sb.WriteString("BEGIN:VCALENDAR\r\n") sb.WriteString("BEGIN:VCALENDAR\r\n")
+37
View File
@@ -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;
} }
+12
View File
@@ -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
View File
@@ -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";
}); });
})(); })();
+2 -1
View File
@@ -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" },
+15 -10
View File
@@ -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,13 +88,9 @@
}) })
.then((data) => { .then((data) => {
if (data.success) { if (data.success) {
try { // Alle lokal gespeicherten Daten (inkl. bookedDates und ausgewählter Werte) leeren,
const localBooked = JSON.parse(localStorage.getItem("bookedDates") || "[]"); // damit bei der nächsten Buchung alles frisch geladen / neu ausgewählt wird.
if (!localBooked.includes(selectedDate)) { localStorage.clear();
localBooked.push(selectedDate);
localStorage.setItem("bookedDates", JSON.stringify(localBooked));
}
} catch (e) {}
summarySection.classList.add("hidden"); summarySection.classList.add("hidden");
if (backLink) { if (backLink) {