diff --git a/assets/stargilnails_logo.png b/assets/stargilnails_logo.png new file mode 100644 index 0000000..adf4c81 Binary files /dev/null and b/assets/stargilnails_logo.png differ diff --git a/assets/stargilnails_logo_transparent.png b/assets/stargilnails_logo_transparent.png new file mode 100644 index 0000000..e7e9ef1 Binary files /dev/null and b/assets/stargilnails_logo_transparent.png differ diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 0000000..30bcc4b --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,40 @@ +## Backlog – Terminplanung & Infrastruktur + +### Kalender & Workflow +- ICS-Anhang/Link in E‑Mails (Kalendereintrag) +- Erinnerungsmails (24h/3h vor Termin) +- Umbuchen/Stornieren per sicherem Kundenlink (Token) +- Pufferzeiten und Sperrtage/Feiertage konfigurierbar +- Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern + +### Sicherheit & Qualität +- Rate‑Limiting (IP/E‑Mail) für Formularspam +- CAPTCHA im Buchungsformular +- E‑Mail‑Verifizierung (Double‑Opt‑In) optional +- Audit‑Log (wer/was/wann) +- DSGVO: Einwilligungstexte, Löschkonzept + +### E‑Mail & Infrastruktur +- Retry/Backoff + Fallback‑Queue bei Resend‑Fehlern +- Health‑Check für Resend‑Erreichbarkeit +- Transaktionale Template‑IDs (anbieteraustauschbar) +- Admin‑Digest (tägliche Übersicht) + +### UX/UI +- Mobiler Kalender mit klarer Slot‑Visualisierung +- Kunden‑Statusseite (pending/confirmed) +- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung + +### Internationalisierung & Zeitzonen +- Zeitzonenfest (UTC intern, lokale Anzeige, Sommerzeittests) +- String‑Bündelung für spätere Lokalisierung + +### Admin & Export +- CSV‑Export von Buchungen +- Filter (Status/Behandlung/Zeitraum), Schnellaktionen (Batch‑Bestätigen) + +### DevOps & Setup +- .env.local‑Unterstützung und Validierung (zod‑based) +- PowerShell‑Verbesserungen: pnpm‑Check, optionales Schreiben in .env.local, sichere Eingabe + + diff --git a/scripts/start-with-email.ps1 b/scripts/start-with-email.ps1 new file mode 100644 index 0000000..906ff67 --- /dev/null +++ b/scripts/start-with-email.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = "Stop" + +param( + [Parameter(Mandatory = $true)] + [string]$ResendApiKey, + + [Parameter(Mandatory = $false)] + [string]$EmailFrom = "Stargirlnails ", + + [Parameter(Mandatory = $false)] + [string]$AdminEmail +) + +Write-Host "Setting environment variables for Resend..." +$env:RESEND_API_KEY = $ResendApiKey +$env:EMAIL_FROM = $EmailFrom +if ($AdminEmail) { $env:ADMIN_EMAIL = $AdminEmail } + +Write-Host "Starting app with pnpm dev..." +pnpm dev + + diff --git a/src/client/app.tsx b/src/client/app.tsx index 5843db2..3cea443 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -6,10 +6,11 @@ import { BookingForm } from "@/client/components/booking-form"; import { AdminTreatments } from "@/client/components/admin-treatments"; import { AdminBookings } from "@/client/components/admin-bookings"; import { InitialDataLoader } from "@/client/components/initial-data-loader"; +import { AdminAvailability } from "@/client/components/admin-availability"; function App() { const { user, isLoading, isOwner } = useAuth(); - const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "profile">("booking"); + const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-availability" | "profile">("booking"); // Show loading spinner while checking authentication if (isLoading) { @@ -24,7 +25,7 @@ function App() { } // Show login form if user is not authenticated and trying to access admin features - const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "profile"); + const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-availability" || activeTab === "profile"); if (needsAuth) { return ; } @@ -33,6 +34,7 @@ function App() { { id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false }, { id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true }, { id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true }, + { id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true }, ...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []), ] as const; @@ -159,6 +161,20 @@ function App() { )} + {activeTab === "admin-availability" && isOwner && ( +
+
+

+ Verfügbarkeiten verwalten +

+

+ Lege freie Slots an und entferne sie bei Bedarf. +

+
+ +
+ )} + {activeTab === "profile" && user && (
diff --git a/src/client/components/admin-availability.tsx b/src/client/components/admin-availability.tsx new file mode 100644 index 0000000..19aea43 --- /dev/null +++ b/src/client/components/admin-availability.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { queryClient } from "@/client/rpc-client"; + +export function AdminAvailability() { + const today = new Date().toISOString().split("T")[0]; + const [selectedDate, setSelectedDate] = useState(today); + const [time, setTime] = useState("09:00"); + const [duration, setDuration] = useState(30); + + const { data: slots } = useQuery( + queryClient.availability.live.byDate.experimental_liveOptions(selectedDate) + ); + + const { mutate: createSlot, isPending: isCreating } = useMutation( + queryClient.availability.create.mutationOptions() + ); + const { mutate: removeSlot } = useMutation( + queryClient.availability.remove.mutationOptions() + ); + + const addSlot = () => { + if (!selectedDate || !time || !duration) return; + createSlot({ sessionId: localStorage.getItem("sessionId") || "", date: selectedDate, time, durationMinutes: duration }); + }; + + return ( +
+

Verfügbarkeiten verwalten

+ +
+ setSelectedDate(e.target.value)} + className="border rounded px-3 py-2" + /> + setTime(e.target.value)} + className="border rounded px-3 py-2" + /> + setDuration(Number(e.target.value))} + className="border rounded px-3 py-2 w-28" + /> + +
+ +
+

Slots am {selectedDate}

+
+ {slots?.sort((a, b) => a.time.localeCompare(b.time)).map((slot) => ( +
+
+ {slot.time} + {slot.durationMinutes} Min + + {slot.status === "free" ? "frei" : "reserviert"} + +
+
+ +
+
+ ))} + {slots?.length === 0 && ( +
Keine Slots vorhanden.
+ )} +
+
+
+ ); +} + + diff --git a/src/client/components/admin-bookings.tsx b/src/client/components/admin-bookings.tsx index e3e7570..52c4942 100644 --- a/src/client/components/admin-bookings.tsx +++ b/src/client/components/admin-bookings.tsx @@ -144,6 +144,9 @@ export function AdminBookings() {
{new Date(booking.appointmentDate).toLocaleDateString()}
{booking.appointmentTime}
+ {booking.slotId && ( +
Slot-ID: {booking.slotId}
+ )} @@ -155,13 +158,13 @@ export function AdminBookings() { {booking.status === "pending" && ( <>
diff --git a/src/server/lib/email-templates.ts b/src/server/lib/email-templates.ts new file mode 100644 index 0000000..d4520b0 --- /dev/null +++ b/src/server/lib/email-templates.ts @@ -0,0 +1,81 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +let cachedLogoDataUrl: string | null = null; + +async function getLogoDataUrl(): Promise { + if (cachedLogoDataUrl) return cachedLogoDataUrl; + try { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const logoPath = resolve(__dirname, "../../../assets/stargilnails_logo_transparent.png"); + const buf = await readFile(logoPath); + const base64 = buf.toString("base64"); + cachedLogoDataUrl = `data:image/png;base64,${base64}`; + return cachedLogoDataUrl; + } catch { + return null; + } +} + +async function renderBrandedEmail(title: string, bodyHtml: string): Promise { + const logo = await getLogoDataUrl(); + return ` +
+ + + + + + + +
+ ${logo ? `Stargirlnails` : `
💅
`} +

${title}

+
+
+ ${bodyHtml} +
+
+
+ © ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care +
+
+
`; +} + +export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) { + const { name, date, time } = params; + const inner = ` +

Hallo ${name},

+

wir haben deine Anfrage für ${date} um ${time} erhalten.

+

Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.

+

Liebe Grüße,
Stargirlnails Kiel

+ `; + return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner); +} + +export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string }) { + const { name, date, time } = params; + const inner = ` +

Hallo ${name},

+

wir haben deinen Termin am ${date} um ${time} bestätigt.

+

Wir freuen uns auf dich!

+

Liebe Grüße,
Stargirlnails Kiel

+ `; + return renderBrandedEmail("Termin bestätigt", inner); +} + +export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) { + const { name, date, time } = params; + const inner = ` +

Hallo ${name},

+

dein Termin am ${date} um ${time} wurde abgesagt.

+

Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.

+

Liebe Grüße,
Stargirlnails Kiel

+ `; + return renderBrandedEmail("Termin abgesagt", inner); +} + + diff --git a/src/server/lib/email.ts b/src/server/lib/email.ts new file mode 100644 index 0000000..71a0ed7 --- /dev/null +++ b/src/server/lib/email.ts @@ -0,0 +1,46 @@ +type SendEmailParams = { + to: string | string[]; + subject: string; + text?: string; + html?: string; + from?: string; + cc?: string | string[]; + bcc?: string | string[]; +}; + +const RESEND_API_KEY = process.env.RESEND_API_KEY; +const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails "; + +export async function sendEmail(params: SendEmailParams): Promise<{ success: boolean }> { + if (!RESEND_API_KEY) { + // In development or if not configured, skip sending but don't fail the flow + console.warn("Resend API key not configured. Skipping email send."); + return { success: false }; + } + + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Authorization": `Bearer ${RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: params.from || DEFAULT_FROM, + to: Array.isArray(params.to) ? params.to : [params.to], + subject: params.subject, + text: params.text, + html: params.html, + cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined, + bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined, + }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + console.error("Resend send error:", response.status, body); + return { success: false }; + } + return { success: true }; +} + + diff --git a/src/server/rpc/availability.ts b/src/server/rpc/availability.ts new file mode 100644 index 0000000..b97405d --- /dev/null +++ b/src/server/rpc/availability.ts @@ -0,0 +1,118 @@ +import { call, os } from "@orpc/server"; +import { z } from "zod"; +import { randomUUID } from "crypto"; +import { createKV } from "@/server/lib/create-kv"; + +const AvailabilitySchema = z.object({ + id: z.string(), + date: z.string(), // YYYY-MM-DD + time: z.string(), // HH:MM + durationMinutes: z.number().int().positive(), + status: z.enum(["free", "reserved"]), + reservedByBookingId: z.string().optional(), + createdAt: z.string(), +}); + +export type Availability = z.output; + +const kv = createKV("availability"); + +// Minimal Owner-Prüfung über Sessions/Users KV +type Session = { id: string; userId: string; expiresAt: string; createdAt: string }; +type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string }; +const sessionsKV = createKV("sessions"); +const usersKV = createKV("users"); + +async function assertOwner(sessionId: string): Promise { + const session = await sessionsKV.getItem(sessionId); + if (!session) throw new Error("Invalid session"); + if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired"); + const user = await usersKV.getItem(session.userId); + if (!user || user.role !== "owner") throw new Error("Forbidden"); +} + +const create = os + .input( + z.object({ + sessionId: z.string(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + time: z.string().regex(/^\d{2}:\d{2}$/), + durationMinutes: z.number().int().positive(), + }) + ) + .handler(async ({ input }) => { + await assertOwner(input.sessionId); + const id = randomUUID(); + const slot: Availability = { + id, + date: input.date, + time: input.time, + durationMinutes: input.durationMinutes, + status: "free", + createdAt: new Date().toISOString(), + }; + await kv.setItem(id, slot); + return slot; + }); + +const update = os + .input(AvailabilitySchema.extend({ sessionId: z.string() })) + .handler(async ({ input }) => { + await assertOwner(input.sessionId); + const { sessionId, ...rest } = input as any; + await kv.setItem(rest.id, rest as Availability); + return rest as Availability; + }); + +const remove = os + .input(z.object({ sessionId: z.string(), id: z.string() })) + .handler(async ({ input }) => { + await assertOwner(input.sessionId); + const slot = await kv.getItem(input.id); + if (slot && slot.status === "reserved") throw new Error("Cannot delete reserved slot"); + await kv.removeItem(input.id); + }); + +const list = os.handler(async () => { + return kv.getAllItems(); +}); + +const get = os.input(z.string()).handler(async ({ input }) => { + return kv.getItem(input); +}); + +const getByDate = os + .input(z.string()) // YYYY-MM-DD + .handler(async ({ input }) => { + const all = await kv.getAllItems(); + return all.filter((s) => s.date === input); + }); + +const live = { + list: os.handler(async function* ({ signal }) { + yield call(list, {}, { signal }); + for await (const _ of kv.subscribe()) { + yield call(list, {}, { signal }); + } + }), + byDate: os + .input(z.string()) + .handler(async function* ({ input, signal }) { + yield call(getByDate, input, { signal }); + for await (const _ of kv.subscribe()) { + yield call(getByDate, input, { signal }); + } + }), +}; + +export const router = { + create, + update, + remove, + list, + get, + getByDate, + live, +}; + + diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts index d3e2843..41966b3 100644 --- a/src/server/rpc/bookings.ts +++ b/src/server/rpc/bookings.ts @@ -2,6 +2,9 @@ import { call, os } from "@orpc/server"; import { z } from "zod"; import { randomUUID } from "crypto"; import { createKV } from "@/server/lib/create-kv"; +import { createKV as createAvailabilityKV } from "@/server/lib/create-kv"; +import { sendEmail } from "@/server/lib/email"; +import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML } from "@/server/lib/email-templates"; const BookingSchema = z.object({ id: z.string(), @@ -14,15 +17,36 @@ const BookingSchema = z.object({ status: z.enum(["pending", "confirmed", "cancelled", "completed"]), notes: z.string().optional(), createdAt: z.string(), + slotId: z.string().optional(), }); type Booking = z.output; const kv = createKV("bookings"); +type Availability = { + id: string; + date: string; + time: string; + durationMinutes: number; + status: "free" | "reserved"; + reservedByBookingId?: string; + createdAt: string; +}; +const availabilityKV = createAvailabilityKV("availability"); const create = os .input(BookingSchema.omit({ id: true, createdAt: true, status: true })) .handler(async ({ input }) => { + // Prevent double booking: same customer email with pending/confirmed on same date + const existing = await kv.getAllItems(); + const hasConflict = existing.some(b => + b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() && + b.appointmentDate === input.appointmentDate && + (b.status === "pending" || b.status === "confirmed") + ); + if (hasConflict) { + throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst."); + } const id = randomUUID(); const booking = { id, @@ -30,21 +54,118 @@ const create = os status: "pending" as const, createdAt: new Date().toISOString() }; + // If a slotId is provided, tentatively reserve the slot (mark reserved but pending) + if (booking.slotId) { + const slot = await availabilityKV.getItem(booking.slotId); + if (!slot) throw new Error("Availability slot not found"); + if (slot.status !== "free") throw new Error("Slot not available"); + const updatedSlot: Availability = { + ...slot, + status: "reserved", + reservedByBookingId: id, + }; + await availabilityKV.setItem(slot.id, updatedSlot); + } await kv.setItem(id, booking); + + // Notify customer: request received (pending) + void (async () => { + const html = await renderBookingPendingHTML({ name: input.customerName, date: input.appointmentDate, time: input.appointmentTime }); + await sendEmail({ + to: input.customerEmail, + subject: "Deine Terminanfrage ist eingegangen", + text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${input.appointmentDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze.\n\nLiebe Grüße\nStargirlnails Kiel`, + html, + cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, + }).catch(() => {}); + })(); return booking; }); +// Owner check reuse (simple inline version) +type Session = { id: string; userId: string; expiresAt: string; createdAt: string }; +type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string }; +const sessionsKV = createAvailabilityKV("sessions"); +const usersKV = createAvailabilityKV("users"); +async function assertOwner(sessionId: string): Promise { + const session = await sessionsKV.getItem(sessionId); + if (!session) throw new Error("Invalid session"); + if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired"); + const user = await usersKV.getItem(session.userId); + if (!user || user.role !== "owner") throw new Error("Forbidden"); +} + const updateStatus = os .input(z.object({ + sessionId: z.string(), id: z.string(), status: z.enum(["pending", "confirmed", "cancelled", "completed"]) })) .handler(async ({ input }) => { + await assertOwner(input.sessionId); const booking = await kv.getItem(input.id); if (!booking) throw new Error("Booking not found"); + const previousStatus = booking.status; const updatedBooking = { ...booking, status: input.status }; await kv.setItem(input.id, updatedBooking); + + // Manage availability slot state transitions + if (booking.slotId) { + const slot = await availabilityKV.getItem(booking.slotId); + if (slot) { + if (input.status === "cancelled") { + // Free the slot again + await availabilityKV.setItem(slot.id, { + ...slot, + status: "free", + reservedByBookingId: undefined, + }); + } else if (input.status === "pending") { + // keep reserved as pending + if (slot.status !== "reserved") { + await availabilityKV.setItem(slot.id, { + ...slot, + status: "reserved", + reservedByBookingId: booking.id, + }); + } + } else if (input.status === "confirmed" || input.status === "completed") { + // keep reserved; optionally noop + if (slot.status !== "reserved" || slot.reservedByBookingId !== booking.id) { + await availabilityKV.setItem(slot.id, { + ...slot, + status: "reserved", + reservedByBookingId: booking.id, + }); + } + } + } + } + // Email notifications on status changes + try { + if (input.status === "confirmed") { + const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime }); + await sendEmail({ + to: booking.customerEmail, + subject: "Dein Termin wurde bestätigt", + text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${booking.appointmentDate} um ${booking.appointmentTime} bestätigt.\n\nBis bald!\nStargirlnails Kiel`, + html, + cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, + }); + } else if (input.status === "cancelled") { + const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime }); + await sendEmail({ + to: booking.customerEmail, + subject: "Dein Termin wurde abgesagt", + text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${booking.appointmentDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nLiebe Grüße\nStargirlnails Kiel`, + html, + cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, + }); + } + } catch (e) { + console.error("Email send failed:", e); + } return updatedBooking; }); diff --git a/src/server/rpc/index.ts b/src/server/rpc/index.ts index 8f55d27..38c847b 100644 --- a/src/server/rpc/index.ts +++ b/src/server/rpc/index.ts @@ -2,10 +2,12 @@ import { demo } from "./demo"; import { router as treatments } from "./treatments"; import { router as bookings } from "./bookings"; import { router as auth } from "./auth"; +import { router as availability } from "./availability"; export const router = { demo, treatments, bookings, auth, + availability, };