Add Stargil Nails logo and favicon

- Replace emoji icons with Stargil Nails logo in header and loading spinner
- Add favicon.png to public directory
- Copy logo to public/assets for browser access
- Update vite.config.ts to serve public directory
- Add favicon link to HTML head section
This commit is contained in:
2025-09-29 19:50:10 +02:00
parent b33036300f
commit ab96114295
10 changed files with 175 additions and 88 deletions

13
.gitignore vendored
View File

@@ -7,13 +7,16 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
node_modules/
dist/
dist-ssr/
.vite/
coverage/
# Editor directories and files
.idea
.DS_Store
Thumbs.db
*.suo
*.ntvs*
*.njsproj
@@ -37,8 +40,10 @@ dist-ssr
etilqs_*
# Environment variables
.env*
.env
.env.*
!.env.example
.env.local
# TypeScript cache
*.tsbuildinfo

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -17,7 +17,11 @@ function App() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-pink-50 to-purple-50">
<div className="text-center">
<div className="text-6xl mb-4">💅</div>
<img
src="/assets/stargilnails_logo_transparent_112.png"
alt="Stargil Nails Logo"
className="w-16 h-16 mx-auto mb-4 object-contain animate-pulse"
/>
<div className="text-lg text-gray-600">Lade...</div>
</div>
</div>
@@ -45,9 +49,13 @@ function App() {
{/* Header */}
<header className="bg-white shadow-sm border-b border-pink-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex justify-between items-center py-6">
<div className="flex items-center space-x-3">
<div className="text-3xl">💅</div>
<img
src="/assets/stargilnails_logo_transparent_112.png"
alt="Stargil Nails Logo"
className="w-12 h-12 object-contain"
/>
<div>
<h1 className="text-2xl font-bold text-gray-900">Stargirlnails Kiel</h1>
<p className="text-sm text-gray-600">Professional Nail Design & Care</p>

View File

@@ -8,10 +8,13 @@ export function AdminAvailability() {
const [time, setTime] = useState<string>("09:00");
const [duration, setDuration] = useState<number>(30);
const { data: slots } = useQuery(
queryClient.availability.live.byDate.experimental_liveOptions(selectedDate)
const { data: allSlots } = useQuery(
queryClient.availability.live.list.experimental_liveOptions()
);
const [errorMsg, setErrorMsg] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>("");
const { mutate: createSlot, isPending: isCreating } = useMutation(
queryClient.availability.create.mutationOptions()
);
@@ -20,8 +23,38 @@ export function AdminAvailability() {
);
const addSlot = () => {
if (!selectedDate || !time || !duration) return;
createSlot({ sessionId: localStorage.getItem("sessionId") || "", date: selectedDate, time, durationMinutes: duration });
setErrorMsg("");
setSuccessMsg("");
if (!selectedDate || !time || !duration) {
setErrorMsg("Bitte Datum, Uhrzeit und Dauer angeben.");
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createSlot(
{ sessionId, date: selectedDate, time, durationMinutes: duration },
{
onSuccess: () => {
setSuccessMsg("Slot angelegt.");
// advance time to next 30-minute step
const [hStr, mStr] = time.split(":");
let h = parseInt(hStr, 10);
let m = parseInt(mStr, 10);
m += 30;
if (m >= 60) { h += 1; m -= 60; }
if (h >= 24) { h = 0; }
const next = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
setTime(next);
},
onError: (err: any) => {
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Anlegen.";
setErrorMsg(msg);
},
}
);
};
return (
@@ -58,32 +91,43 @@ export function AdminAvailability() {
</button>
</div>
{(errorMsg || successMsg) && (
<div className="text-sm">
{errorMsg && <div className="text-red-600">{errorMsg}</div>}
{successMsg && <div className="text-green-700">{successMsg}</div>}
</div>
)}
<div className="space-y-2">
<h3 className="font-medium">Slots am {selectedDate}</h3>
<h3 className="font-medium">Alle freien Slots</h3>
<div className="grid grid-cols-1 gap-2">
{slots?.sort((a, b) => a.time.localeCompare(b.time)).map((slot) => (
<div key={slot.id} className="flex items-center justify-between border rounded px-3 py-2">
<div className="flex items-center gap-3">
<span className="font-mono">{slot.time}</span>
<span className="text-sm text-gray-600">{slot.durationMinutes} Min</span>
<span className={`text-xs px-2 py-1 rounded ${slot.status === "free" ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"}`}>
{slot.status === "free" ? "frei" : "reserviert"}
</span>
{allSlots
?.filter((s) => s.status === "free")
.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date)))
.map((slot) => (
<div key={slot.id} className="flex items-center justify-between border rounded px-3 py-2">
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600">{slot.date}</span>
<span className="font-mono">{slot.time}</span>
<span className="text-sm text-gray-600">{slot.durationMinutes} Min</span>
<span className={`text-xs px-2 py-1 rounded ${slot.status === "free" ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"}`}>
{slot.status === "free" ? "frei" : "reserviert"}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => removeSlot({ sessionId: localStorage.getItem("sessionId") || "", id: slot.id })}
className="text-red-600 hover:underline"
disabled={slot.status === "reserved"}
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
>
Löschen
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => removeSlot({ sessionId: localStorage.getItem("sessionId") || "", id: slot.id })}
className="text-red-600 hover:underline"
disabled={slot.status === "reserved"}
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
>
Löschen
</button>
</div>
</div>
))}
{slots?.length === 0 && (
<div className="text-sm text-gray-600">Keine Slots vorhanden.</div>
))}
{allSlots?.filter((s) => s.status === "free").length === 0 && (
<div className="text-sm text-gray-600">Keine freien Slots vorhanden.</div>
)}
</div>
</div>

View File

@@ -14,18 +14,23 @@ export function BookingForm() {
const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions()
);
const { data: slotsByDate } = useQuery(
appointmentDate
? queryClient.availability.live.byDate.experimental_liveOptions(appointmentDate)
: queryClient.availability.live.byDate.experimental_liveOptions("")
// Lade alle Slots live und filtere freie Slots
const { data: allSlots } = useQuery(
queryClient.availability.live.list.experimental_liveOptions()
);
const freeSlots = (allSlots || []).filter((s) => s.status === "free");
const availableDates = Array.from(new Set(freeSlots.map((s) => s.date))).sort();
const slotsByDate = appointmentDate
? freeSlots.filter((s) => s.date === appointmentDate)
: [];
const { mutate: createBooking, isPending } = useMutation(
queryClient.bookings.create.mutationOptions()
);
const selectedTreatmentData = treatments?.find(t => t.id === selectedTreatment);
const availableSlots = (slotsByDate || []).filter(s => s.status === "free");
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
const availableSlots = (slotsByDate || []).filter((s) => s.status === "free");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -33,33 +38,36 @@ export function BookingForm() {
alert("Bitte fülle alle erforderlichen Felder aus");
return;
}
const slot = availableSlots.find(s => s.id === selectedSlotId);
const slot = availableSlots.find((s) => s.id === selectedSlotId);
const appointmentTime = slot?.time || "";
createBooking({
treatmentId: selectedTreatment,
customerName,
customerEmail,
customerPhone,
appointmentDate,
appointmentTime,
notes,
slotId: selectedSlotId,
}, {
onSuccess: () => {
setSelectedTreatment("");
setCustomerName("");
setCustomerEmail("");
setCustomerPhone("");
setAppointmentDate("");
setSelectedSlotId("");
setNotes("");
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen.");
createBooking(
{
treatmentId: selectedTreatment,
customerName,
customerEmail,
customerPhone,
appointmentDate,
appointmentTime,
notes,
slotId: selectedSlotId,
},
{
onSuccess: () => {
setSelectedTreatment("");
setCustomerName("");
setCustomerEmail("");
setCustomerPhone("");
setAppointmentDate("");
setSelectedSlotId("");
setNotes("");
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen.");
},
}
});
);
};
// Get minimum date (today)
const today = new Date().toISOString().split('T')[0];
// Get minimum date (today) nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots
const today = new Date().toISOString().split("T")[0];
return (
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
@@ -134,16 +142,22 @@ export function BookingForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Gewünschtes Datum *
Datum (nur freie Termine) *
</label>
<input
type="date"
<select
value={appointmentDate}
onChange={(e) => setAppointmentDate(e.target.value)}
min={today}
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedSlotId(""); }}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
>
<option value="">Datum auswählen</option>
{availableDates.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
{availableDates.length === 0 && (
<p className="mt-2 text-sm text-gray-500">Aktuell keine freien Termine verfügbar.</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">

View File

@@ -10,7 +10,8 @@ export function clientEntry(c: Context<BlankEnv>) {
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<title>New Quest</title>
<title>Stargirlnails Kiel</title>
<link rel="icon" type="image/png" href="/favicon.png" />
{import.meta.env.PROD ? (
<script src="/static/main.js" type="module" />
) : (

View File

@@ -41,18 +41,29 @@ const create = os
})
)
.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;
try {
await assertOwner(input.sessionId);
// Prevent duplicate slot on same date+time
const existing = await kv.getAllItems();
const conflict = existing.some((s) => s.date === input.date && s.time === input.time);
if (conflict) {
throw new Error("Es existiert bereits ein Slot zu diesem Datum und dieser Uhrzeit.");
}
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;
} catch (err) {
console.error("availability.create error", err);
throw err;
}
});
const update = os

View File

@@ -1,9 +1,9 @@
import devServer, { defaultOptions } from "@hono/vite-dev-server";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig, loadEnv } from "vite";
import devServer, { defaultOptions } from "@hono/vite-dev-server";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
if (process.env.QUESTS_INSIDE_STUDIO !== "true") {
// When app is run outside Quests, this ensure .env* files are loaded
@@ -15,8 +15,12 @@ export default defineConfig(({ mode }) => {
server: {
host: "0.0.0.0",
port: 5173,
// Erlaube Zugriffe von beliebigen Hosts (lokal + Proxy/Funnel)
allowedHosts: "all",
cors: true,
// Keine explizite HMR/Origin-Konfiguration, Vite-Defaults für localhost funktionieren am stabilsten
},
publicDir: "public",
plugins: [
tsconfigPaths(),
react(),