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:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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
|
BIN
assets/stargilnails_logo_transparent_112.png
Normal file
BIN
assets/stargilnails_logo_transparent_112.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
BIN
public/assets/stargilnails_logo_transparent_112.png
Normal file
BIN
public/assets/stargilnails_logo_transparent_112.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
@@ -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>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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">
|
||||||
|
@@ -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" />
|
||||||
) : (
|
) : (
|
||||||
|
@@ -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
|
||||||
|
@@ -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(),
|
||||||
|
Reference in New Issue
Block a user