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* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules/
dist dist/
dist-ssr dist-ssr/
.vite/
coverage/
# Editor directories and files # Editor directories and files
.idea .idea
.DS_Store .DS_Store
Thumbs.db
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
@@ -37,8 +40,10 @@ dist-ssr
etilqs_* etilqs_*
# Environment variables # Environment variables
.env* .env
.env.*
!.env.example !.env.example
.env.local
# TypeScript cache # TypeScript cache
*.tsbuildinfo *.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 ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-pink-50 to-purple-50"> <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-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 className="text-lg text-gray-600">Lade...</div>
</div> </div>
</div> </div>
@@ -45,9 +49,13 @@ function App() {
{/* Header */} {/* Header */}
<header className="bg-white shadow-sm border-b border-pink-100"> <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="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="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> <div>
<h1 className="text-2xl font-bold text-gray-900">Stargirlnails Kiel</h1> <h1 className="text-2xl font-bold text-gray-900">Stargirlnails Kiel</h1>
<p className="text-sm text-gray-600">Professional Nail Design & Care</p> <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 [time, setTime] = useState<string>("09:00");
const [duration, setDuration] = useState<number>(30); const [duration, setDuration] = useState<number>(30);
const { data: slots } = useQuery( const { data: allSlots } = useQuery(
queryClient.availability.live.byDate.experimental_liveOptions(selectedDate) queryClient.availability.live.list.experimental_liveOptions()
); );
const [errorMsg, setErrorMsg] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>("");
const { mutate: createSlot, isPending: isCreating } = useMutation( const { mutate: createSlot, isPending: isCreating } = useMutation(
queryClient.availability.create.mutationOptions() queryClient.availability.create.mutationOptions()
); );
@@ -20,8 +23,38 @@ export function AdminAvailability() {
); );
const addSlot = () => { const addSlot = () => {
if (!selectedDate || !time || !duration) return; setErrorMsg("");
createSlot({ sessionId: localStorage.getItem("sessionId") || "", date: selectedDate, time, durationMinutes: duration }); 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 ( return (
@@ -58,32 +91,43 @@ export function AdminAvailability() {
</button> </button>
</div> </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"> <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"> <div className="grid grid-cols-1 gap-2">
{slots?.sort((a, b) => a.time.localeCompare(b.time)).map((slot) => ( {allSlots
<div key={slot.id} className="flex items-center justify-between border rounded px-3 py-2"> ?.filter((s) => s.status === "free")
<div className="flex items-center gap-3"> .sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date)))
<span className="font-mono">{slot.time}</span> .map((slot) => (
<span className="text-sm text-gray-600">{slot.durationMinutes} Min</span> <div key={slot.id} className="flex items-center justify-between border rounded px-3 py-2">
<span className={`text-xs px-2 py-1 rounded ${slot.status === "free" ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"}`}> <div className="flex items-center gap-3">
{slot.status === "free" ? "frei" : "reserviert"} <span className="text-sm text-gray-600">{slot.date}</span>
</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>
<div className="flex items-center gap-2"> ))}
<button {allSlots?.filter((s) => s.status === "free").length === 0 && (
onClick={() => removeSlot({ sessionId: localStorage.getItem("sessionId") || "", id: slot.id })} <div className="text-sm text-gray-600">Keine freien Slots vorhanden.</div>
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>
)} )}
</div> </div>
</div> </div>

View File

@@ -14,18 +14,23 @@ export function BookingForm() {
const { data: treatments } = useQuery( const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions() queryClient.treatments.live.list.experimental_liveOptions()
); );
const { data: slotsByDate } = useQuery(
appointmentDate // Lade alle Slots live und filtere freie Slots
? queryClient.availability.live.byDate.experimental_liveOptions(appointmentDate) const { data: allSlots } = useQuery(
: queryClient.availability.live.byDate.experimental_liveOptions("") 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( const { mutate: createBooking, isPending } = useMutation(
queryClient.bookings.create.mutationOptions() queryClient.bookings.create.mutationOptions()
); );
const selectedTreatmentData = treatments?.find(t => t.id === selectedTreatment); const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
const availableSlots = (slotsByDate || []).filter(s => s.status === "free"); const availableSlots = (slotsByDate || []).filter((s) => s.status === "free");
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -33,33 +38,36 @@ export function BookingForm() {
alert("Bitte fülle alle erforderlichen Felder aus"); alert("Bitte fülle alle erforderlichen Felder aus");
return; return;
} }
const slot = availableSlots.find(s => s.id === selectedSlotId); const slot = availableSlots.find((s) => s.id === selectedSlotId);
const appointmentTime = slot?.time || ""; const appointmentTime = slot?.time || "";
createBooking({ createBooking(
treatmentId: selectedTreatment, {
customerName, treatmentId: selectedTreatment,
customerEmail, customerName,
customerPhone, customerEmail,
appointmentDate, customerPhone,
appointmentTime, appointmentDate,
notes, appointmentTime,
slotId: selectedSlotId, notes,
}, { slotId: selectedSlotId,
onSuccess: () => { },
setSelectedTreatment(""); {
setCustomerName(""); onSuccess: () => {
setCustomerEmail(""); setSelectedTreatment("");
setCustomerPhone(""); setCustomerName("");
setAppointmentDate(""); setCustomerEmail("");
setSelectedSlotId(""); setCustomerPhone("");
setNotes(""); setAppointmentDate("");
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen."); setSelectedSlotId("");
setNotes("");
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen.");
},
} }
}); );
}; };
// Get minimum date (today) // Get minimum date (today) nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split("T")[0];
return ( return (
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6"> <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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Gewünschtes Datum * Datum (nur freie Termine) *
</label> </label>
<input <select
type="date"
value={appointmentDate} value={appointmentDate}
onChange={(e) => setAppointmentDate(e.target.value)} onChange={(e) => { setAppointmentDate(e.target.value); setSelectedSlotId(""); }}
min={today}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500" className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required 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>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <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> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" /> <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 ? ( {import.meta.env.PROD ? (
<script src="/static/main.js" type="module" /> <script src="/static/main.js" type="module" />
) : ( ) : (

View File

@@ -41,18 +41,29 @@ const create = os
}) })
) )
.handler(async ({ input }) => { .handler(async ({ input }) => {
await assertOwner(input.sessionId); try {
const id = randomUUID(); await assertOwner(input.sessionId);
const slot: Availability = { // Prevent duplicate slot on same date+time
id, const existing = await kv.getAllItems();
date: input.date, const conflict = existing.some((s) => s.date === input.date && s.time === input.time);
time: input.time, if (conflict) {
durationMinutes: input.durationMinutes, throw new Error("Es existiert bereits ein Slot zu diesem Datum und dieser Uhrzeit.");
status: "free", }
createdAt: new Date().toISOString(), const id = randomUUID();
}; const slot: Availability = {
await kv.setItem(id, slot); id,
return slot; 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 const update = os

View File

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