From 3e72786644ee2045a6ce5c3ae51e481b9fbeb4f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:26:57 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Demo-Modus=20=E2=80=94=20UI=20ohne=20Lo?= =?UTF-8?q?gin=20durchklicken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/demo.ts: zentraler Demo-State, Mock-User + 7 Mock-Listings - Hooks (use-auth, use-listings, use-profiles, use-matches, use-profile, use-push) pruefen isDemoMode() und liefern Mock-Daten statt Supabase-Calls - CRUD-Ops mutieren in Demo nur lokalen State (verfallen mit Reload) - Matching im Demo: clientseitige Haversine + Type/Seats-Check (kein RPC) - LoginForm: "Demo ohne Login →" Button (ghost, am unteren Ende) - AppShell: oranges Banner oben mit "Beenden"-Link bei aktivem Demo-Mode Geeignet zum Durchklicken des Vercel-Previews ohne Supabase-Setup. Demo-User ist Admin, kann also auch den Admin-Bereich oeffnen. https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- src/components/auth/login-form.tsx | 14 ++ src/components/layout/app-shell.tsx | 16 ++ src/hooks/use-auth.ts | 43 ++++- src/hooks/use-listings.ts | 119 +++++++++++--- src/hooks/use-matches.ts | 72 ++++++++ src/hooks/use-profile.ts | 6 + src/hooks/use-profiles.ts | 6 +- src/hooks/use-push.ts | 5 + src/lib/demo.ts | 246 ++++++++++++++++++++++++++++ 9 files changed, 497 insertions(+), 30 deletions(-) create mode 100644 src/lib/demo.ts diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index ffa2b6f..a94af76 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -2,6 +2,7 @@ import { useState, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { enableDemoMode } from "@/lib/demo"; interface LoginFormProps { onSendOtp: (phone: string) => Promise<{ request_id: string }>; @@ -132,6 +133,19 @@ export function LoginForm({ onSendOtp, onVerifyOtp }: LoginFormProps) { )} + +
+ +
); } diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 3f6c5d8..5f5dc8c 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -2,6 +2,7 @@ import { useNavigate } from "react-router"; import { Plus } from "lucide-react"; import { Header } from "./header"; import { ROUTES } from "@/lib/constants"; +import { disableDemoMode, isDemoMode } from "@/lib/demo"; interface AppShellProps { children: React.ReactNode; @@ -10,9 +11,24 @@ interface AppShellProps { export function AppShell({ children, showFab = false }: AppShellProps) { const navigate = useNavigate(); + const demo = isDemoMode(); return (
+ {demo && ( +
+ Demo-Modus — Aenderungen werden nicht gespeichert + +
+ )}
{children}
{showFab && ( diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index c15cdf0..bcf6104 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -1,6 +1,13 @@ import { useCallback, useEffect, useState } from "react"; import type { Session, User } from "@supabase/supabase-js"; import { supabase } from "@/lib/supabase"; +import { + DEMO_PROFILE, + DEMO_SESSION, + DEMO_USER, + disableDemoMode, + isDemoMode, +} from "@/lib/demo"; import type { Profile } from "@/types"; interface AuthState { @@ -12,13 +19,25 @@ interface AuthState { } export function useAuth() { - const [state, setState] = useState({ - session: null, - user: null, - profile: null, - loading: true, - isNewUser: false, - }); + const demo = isDemoMode(); + + const [state, setState] = useState(() => + demo + ? { + session: DEMO_SESSION, + user: DEMO_USER, + profile: DEMO_PROFILE, + loading: false, + isNewUser: false, + } + : { + session: null, + user: null, + profile: null, + loading: true, + isNewUser: false, + }, + ); const fetchProfile = useCallback(async (userId: string) => { const { data } = await supabase @@ -30,6 +49,7 @@ export function useAuth() { }, []); useEffect(() => { + if (demo) return; supabase.auth.getSession().then(async ({ data: { session } }) => { if (session?.user) { const profile = await fetchProfile(session.user.id); @@ -69,7 +89,7 @@ export function useAuth() { }); return () => subscription.unsubscribe(); - }, [fetchProfile]); + }, [fetchProfile, demo]); const sendOtp = useCallback(async (phone: string) => { const { data, error } = await supabase.functions.invoke("send-otp", { @@ -117,8 +137,13 @@ export function useAuth() { ); const signOut = useCallback(async () => { + if (demo) { + disableDemoMode(); + window.location.reload(); + return; + } await supabase.auth.signOut(); - }, []); + }, [demo]); return { ...state, diff --git a/src/hooks/use-listings.ts b/src/hooks/use-listings.ts index 22e3e98..f193c6f 100644 --- a/src/hooks/use-listings.ts +++ b/src/hooks/use-listings.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { supabase } from "@/lib/supabase"; +import { DEMO_LISTINGS, isDemoMode } from "@/lib/demo"; import type { Listing, ListingInsert, ListingUpdate, ListingType } from "@/types"; interface UseListingsOptions { @@ -8,13 +9,26 @@ interface UseListingsOptions { export function useListings(options: UseListingsOptions = {}) { const { typeFilter = "alle" } = options; - const [listings, setListings] = useState([]); - const [loading, setLoading] = useState(true); + const demo = isDemoMode(); + + const filterDemo = (all: Listing[]) => + typeFilter === "alle" ? all : all.filter((l) => l.type === typeFilter); + + const [listings, setListings] = useState(() => + demo ? filterDemo(DEMO_LISTINGS) : [], + ); + const [loading, setLoading] = useState(!demo); const [error, setError] = useState(null); const [newCount, setNewCount] = useState(0); const pendingRef = useRef([]); const fetchListings = useCallback(async () => { + if (demo) { + setListings(filterDemo(DEMO_LISTINGS)); + setLoading(false); + return; + } + setLoading(true); setError(null); @@ -38,13 +52,15 @@ export function useListings(options: UseListingsOptions = {}) { setLoading(false); setNewCount(0); pendingRef.current = []; - }, [typeFilter]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [typeFilter, demo]); useEffect(() => { fetchListings(); }, [fetchListings]); useEffect(() => { + if (demo) return; const channel = supabase .channel("listings-realtime") .on( @@ -83,7 +99,7 @@ export function useListings(options: UseListingsOptions = {}) { return () => { supabase.removeChannel(channel); }; - }, []); + }, [demo]); const showPending = useCallback(() => { setListings((prev) => { @@ -101,6 +117,37 @@ export function useListings(options: UseListingsOptions = {}) { const createListing = useCallback( async (listing: Omit, userId: string) => { + if (demo) { + const now = new Date().toISOString(); + const mock: Listing = { + id: crypto.randomUUID(), + user_id: userId, + type: listing.type, + origin_label: listing.origin_label, + origin_city: listing.origin_city, + origin_lat: listing.origin_lat, + origin_lng: listing.origin_lng, + destination_label: listing.destination_label, + destination_city: listing.destination_city, + destination_lat: listing.destination_lat, + destination_lng: listing.destination_lng, + departure_at: listing.departure_at, + seats: listing.seats, + notes: listing.notes ?? null, + created_at: now, + updated_at: now, + }; + DEMO_LISTINGS.push(mock); + setListings((prev) => + [...prev, mock].sort( + (a, b) => + new Date(a.departure_at).getTime() - + new Date(b.departure_at).getTime(), + ), + ); + return mock; + } + const { data, error } = await supabase .from("listings") .insert({ ...listing, user_id: userId }) @@ -117,11 +164,26 @@ export function useListings(options: UseListingsOptions = {}) { ); return data; }, - [], + [demo], ); const updateListing = useCallback( async (id: string, updates: ListingUpdate) => { + if (demo) { + const idx = DEMO_LISTINGS.findIndex((l) => l.id === id); + if (idx === -1) throw new Error("Eintrag nicht gefunden"); + const updated: Listing = { + ...DEMO_LISTINGS[idx]!, + ...updates, + updated_at: new Date().toISOString(), + }; + DEMO_LISTINGS[idx] = updated; + setListings((prev) => + prev.map((l) => (l.id === id ? updated : l)), + ); + return updated; + } + const { data, error } = await supabase .from("listings") .update(updates) @@ -132,25 +194,42 @@ export function useListings(options: UseListingsOptions = {}) { if (error) throw error; return data; }, - [], + [demo], ); - const deleteListing = useCallback(async (id: string) => { - const { error } = await supabase.from("listings").delete().eq("id", id); - if (error) throw error; - setListings((prev) => prev.filter((l) => l.id !== id)); - }, []); + const deleteListing = useCallback( + async (id: string) => { + if (demo) { + const idx = DEMO_LISTINGS.findIndex((l) => l.id === id); + if (idx !== -1) DEMO_LISTINGS.splice(idx, 1); + setListings((prev) => prev.filter((l) => l.id !== id)); + return; + } + const { error } = await supabase.from("listings").delete().eq("id", id); + if (error) throw error; + setListings((prev) => prev.filter((l) => l.id !== id)); + }, + [demo], + ); - const getListing = useCallback(async (id: string) => { - const { data, error } = await supabase - .from("listings") - .select("*") - .eq("id", id) - .single(); + const getListing = useCallback( + async (id: string) => { + if (demo) { + const found = DEMO_LISTINGS.find((l) => l.id === id); + if (!found) throw new Error("Eintrag nicht gefunden"); + return found; + } + const { data, error } = await supabase + .from("listings") + .select("*") + .eq("id", id) + .single(); - if (error) throw error; - return data; - }, []); + if (error) throw error; + return data; + }, + [demo], + ); const groupedByCity = listings.reduce>( (acc, listing) => { diff --git a/src/hooks/use-matches.ts b/src/hooks/use-matches.ts index 4e6e5fc..6e54907 100644 --- a/src/hooks/use-matches.ts +++ b/src/hooks/use-matches.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from "react"; import { supabase } from "@/lib/supabase"; +import { DEMO_LISTINGS, isDemoMode } from "@/lib/demo"; export interface MatchResult { listing_id: string; @@ -12,6 +13,22 @@ export interface MatchResult { distance_destination_km: number; } +function haversineKm( + lat1: number, + lng1: number, + lat2: number, + lng2: number, +): number { + const r = 6371; + const toRad = (d: number) => (d * Math.PI) / 180; + const dlat = toRad(lat2 - lat1); + const dlng = toRad(lng2 - lng1); + const a = + Math.sin(dlat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dlng / 2) ** 2; + return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + export function useMatches() { const [matches, setMatches] = useState([]); const [loading, setLoading] = useState(false); @@ -19,6 +36,61 @@ export function useMatches() { const findMatches = useCallback(async (listingId: string) => { setLoading(true); try { + if (isDemoMode()) { + const src = DEMO_LISTINGS.find((l) => l.id === listingId); + if (!src) { + setMatches([]); + return []; + } + const counterType = src.type === "angebot" ? "anfrage" : "angebot"; + const results: MatchResult[] = DEMO_LISTINGS.filter((l) => { + if (l.type !== counterType) return false; + if (l.user_id === src.user_id) return false; + const dOrigin = haversineKm( + src.origin_lat, + src.origin_lng, + l.origin_lat, + l.origin_lng, + ); + const dDest = haversineKm( + src.destination_lat, + src.destination_lng, + l.destination_lat, + l.destination_lng, + ); + if (dOrigin > 15 || dDest > 15) return false; + const timeDiff = Math.abs( + new Date(l.departure_at).getTime() - + new Date(src.departure_at).getTime(), + ); + if (timeDiff > 2 * 3600_000) return false; + if (src.type === "anfrage" && l.seats < src.seats) return false; + if (src.type === "angebot" && l.seats > src.seats) return false; + return true; + }).map((l) => ({ + listing_id: l.id, + user_id: l.user_id, + origin_label: l.origin_label, + destination_label: l.destination_label, + departure_at: l.departure_at, + seats: l.seats, + distance_origin_km: haversineKm( + src.origin_lat, + src.origin_lng, + l.origin_lat, + l.origin_lng, + ), + distance_destination_km: haversineKm( + src.destination_lat, + src.destination_lng, + l.destination_lat, + l.destination_lng, + ), + })); + setMatches(results); + return results; + } + const { data, error } = await supabase.rpc("find_matches", { p_listing_id: listingId, }); diff --git a/src/hooks/use-profile.ts b/src/hooks/use-profile.ts index 987046b..b9e8b40 100644 --- a/src/hooks/use-profile.ts +++ b/src/hooks/use-profile.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { supabase } from "@/lib/supabase"; import { useAuthContext } from "@/hooks/use-auth-context"; +import { isDemoMode } from "@/lib/demo"; export function useProfile() { const { profile, user } = useAuthContext(); @@ -13,6 +14,11 @@ export function useProfile() { }) => { if (!user) throw new Error("Nicht eingeloggt."); + if (isDemoMode()) { + // Aenderungen verfallen mit Reload — Demo-Profile bleibt unveraendert + return; + } + const { error } = await supabase .from("profiles") .update(updates) diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 4898cce..06d369c 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,11 +1,15 @@ import { useCallback, useEffect, useState } from "react"; import { supabase } from "@/lib/supabase"; +import { DEMO_PROFILES, isDemoMode } from "@/lib/demo"; import type { Profile } from "@/types"; export function useProfiles(userIds: string[]) { - const [profiles, setProfiles] = useState>({}); + const [profiles, setProfiles] = useState>(() => + isDemoMode() ? { ...DEMO_PROFILES } : {}, + ); const fetchProfiles = useCallback(async (ids: string[]) => { + if (isDemoMode()) return; const missing = ids.filter((id) => !profiles[id]); if (missing.length === 0) return; diff --git a/src/hooks/use-push.ts b/src/hooks/use-push.ts index 5eaa8cb..e215c69 100644 --- a/src/hooks/use-push.ts +++ b/src/hooks/use-push.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { supabase } from "@/lib/supabase"; +import { isDemoMode } from "@/lib/demo"; interface UsePushOptions { userId: string | undefined; @@ -11,6 +12,10 @@ export function usePush({ userId }: UsePushOptions) { const [permission, setPermission] = useState("default"); useEffect(() => { + if (isDemoMode()) { + setIsSupported(false); + return; + } const supported = "serviceWorker" in navigator && "PushManager" in window && diff --git a/src/lib/demo.ts b/src/lib/demo.ts new file mode 100644 index 0000000..e0ad129 --- /dev/null +++ b/src/lib/demo.ts @@ -0,0 +1,246 @@ +import type { Listing, Profile } from "@/types"; +import type { User, Session } from "@supabase/supabase-js"; + +const DEMO_KEY = "kommit_demo"; + +export function isDemoMode(): boolean { + if (typeof window === "undefined") return false; + return localStorage.getItem(DEMO_KEY) === "1"; +} + +export function enableDemoMode() { + localStorage.setItem(DEMO_KEY, "1"); +} + +export function disableDemoMode() { + localStorage.removeItem(DEMO_KEY); +} + +const DEMO_USER_ID = "demo-user-00000000-0000-0000-0000-000000000001"; +const NOW = new Date(); + +function hoursFromNow(h: number): string { + return new Date(NOW.getTime() + h * 3600_000).toISOString(); +} + +function daysAgo(d: number): string { + return new Date(NOW.getTime() - d * 86400_000).toISOString(); +} + +export const DEMO_USER: User = { + id: DEMO_USER_ID, + aud: "authenticated", + role: "authenticated", + email: "", + phone: "+491701234567", + app_metadata: {}, + user_metadata: {}, + created_at: daysAgo(120), + updated_at: daysAgo(1), +}; + +export const DEMO_SESSION: Session = { + access_token: "demo", + refresh_token: "demo", + expires_in: 3600, + expires_at: Math.floor(NOW.getTime() / 1000) + 3600, + token_type: "bearer", + user: DEMO_USER, +}; + +export const DEMO_PROFILE: Profile = { + id: DEMO_USER_ID, + first_name: "Max", + last_name: "Mustermann", + phone: "+491701234567", + show_phone: true, + telegram_chat_id: null, + role: "admin", + avatar_color: "#FF8A4C", + created_at: daysAgo(120), + updated_at: daysAgo(1), +}; + +const OTHER_PROFILES: Profile[] = [ + { + id: "demo-user-00000000-0000-0000-0000-000000000002", + first_name: "Anna", + last_name: "Schmidt", + phone: "+491729876543", + show_phone: true, + telegram_chat_id: null, + role: "user", + avatar_color: "#3DA9FC", + created_at: daysAgo(80), + updated_at: daysAgo(2), + }, + { + id: "demo-user-00000000-0000-0000-0000-000000000003", + first_name: "Lukas", + last_name: "Mueller", + phone: "+491601112233", + show_phone: false, + telegram_chat_id: null, + role: "user", + avatar_color: "#10B981", + created_at: daysAgo(45), + updated_at: daysAgo(5), + }, + { + id: "demo-user-00000000-0000-0000-0000-000000000004", + first_name: "Sophie", + last_name: "Weber", + phone: "+491775556677", + show_phone: true, + telegram_chat_id: null, + role: "user", + avatar_color: "#F97066", + created_at: daysAgo(15), + updated_at: daysAgo(3), + }, +]; + +export const DEMO_PROFILES: Record = { + [DEMO_PROFILE.id]: DEMO_PROFILE, + ...Object.fromEntries(OTHER_PROFILES.map((p) => [p.id, p])), +}; + +// Lat/lng grob fuer drei Orte im Breisgau +const BOETZINGEN = { city: "Boetzingen", lat: 48.0689, lng: 7.7172 }; +const FREIBURG = { city: "Freiburg im Breisgau", lat: 47.9959, lng: 7.8494 }; +const ENDINGEN = { city: "Endingen am Kaiserstuhl", lat: 48.1417, lng: 7.7042 }; +const LILIENHOF = { + city: "Freiburg im Breisgau", + label: "Lilienhof, Freiburg", + lat: 47.9892, + lng: 7.8627, +}; + +export const DEMO_LISTINGS: Listing[] = [ + { + id: "demo-listing-00000000-0000-0000-0000-000000000001", + user_id: OTHER_PROFILES[0]!.id, + type: "angebot", + origin_label: `Bahnhof ${BOETZINGEN.city}`, + origin_city: BOETZINGEN.city, + origin_lat: BOETZINGEN.lat, + origin_lng: BOETZINGEN.lng, + destination_label: LILIENHOF.label, + destination_city: LILIENHOF.city, + destination_lat: LILIENHOF.lat, + destination_lng: LILIENHOF.lng, + departure_at: hoursFromNow(5), + seats: 3, + notes: "Treffpunkt am Bahnhofsvorplatz. Bin puenktlich!", + created_at: hoursFromNow(-2), + updated_at: hoursFromNow(-2), + }, + { + id: "demo-listing-00000000-0000-0000-0000-000000000002", + user_id: OTHER_PROFILES[1]!.id, + type: "anfrage", + origin_label: `Marktplatz ${BOETZINGEN.city}`, + origin_city: BOETZINGEN.city, + origin_lat: BOETZINGEN.lat + 0.002, + origin_lng: BOETZINGEN.lng + 0.001, + destination_label: LILIENHOF.label, + destination_city: LILIENHOF.city, + destination_lat: LILIENHOF.lat, + destination_lng: LILIENHOF.lng, + departure_at: hoursFromNow(5.5), + seats: 1, + notes: null, + created_at: hoursFromNow(-1), + updated_at: hoursFromNow(-1), + }, + { + id: "demo-listing-00000000-0000-0000-0000-000000000003", + user_id: DEMO_USER_ID, + type: "angebot", + origin_label: `Freiburg Hauptbahnhof`, + origin_city: FREIBURG.city, + origin_lat: FREIBURG.lat, + origin_lng: FREIBURG.lng, + destination_label: `Endingen Rathaus`, + destination_city: ENDINGEN.city, + destination_lat: ENDINGEN.lat, + destination_lng: ENDINGEN.lng, + departure_at: hoursFromNow(28), + seats: 2, + notes: "Habe Kofferraum frei fuer Instrumente.", + created_at: hoursFromNow(-5), + updated_at: hoursFromNow(-5), + }, + { + id: "demo-listing-00000000-0000-0000-0000-000000000004", + user_id: OTHER_PROFILES[2]!.id, + type: "anfrage", + origin_label: `Endingen Bahnhof`, + origin_city: ENDINGEN.city, + origin_lat: ENDINGEN.lat, + origin_lng: ENDINGEN.lng, + destination_label: LILIENHOF.label, + destination_city: LILIENHOF.city, + destination_lat: LILIENHOF.lat, + destination_lng: LILIENHOF.lng, + departure_at: hoursFromNow(6), + seats: 1, + notes: "Kann bei Bedarf etwas Spritgeld zahlen.", + created_at: hoursFromNow(-3), + updated_at: hoursFromNow(-3), + }, + { + id: "demo-listing-00000000-0000-0000-0000-000000000005", + user_id: OTHER_PROFILES[0]!.id, + type: "angebot", + origin_label: `Endingen Marktplatz`, + origin_city: ENDINGEN.city, + origin_lat: ENDINGEN.lat + 0.001, + origin_lng: ENDINGEN.lng + 0.001, + destination_label: LILIENHOF.label, + destination_city: LILIENHOF.city, + destination_lat: LILIENHOF.lat, + destination_lng: LILIENHOF.lng, + departure_at: hoursFromNow(29), + seats: 4, + notes: "Faehrt morgens auch Richtung Freiburg!", + created_at: hoursFromNow(-4), + updated_at: hoursFromNow(-4), + }, + { + id: "demo-listing-00000000-0000-0000-0000-000000000006", + user_id: OTHER_PROFILES[1]!.id, + type: "anfrage", + origin_label: `Freiburg Stuehlinger`, + origin_city: FREIBURG.city, + origin_lat: FREIBURG.lat - 0.005, + origin_lng: FREIBURG.lng - 0.01, + destination_label: `Endingen Rathaus`, + destination_city: ENDINGEN.city, + destination_lat: ENDINGEN.lat, + destination_lng: ENDINGEN.lng, + departure_at: hoursFromNow(29.5), + seats: 2, + notes: null, + created_at: hoursFromNow(-6), + updated_at: hoursFromNow(-6), + }, + { + id: "demo-listing-00000000-0000-0000-0000-000000000007", + user_id: OTHER_PROFILES[3]!.id, + type: "angebot", + origin_label: `Boetzingen Kirche`, + origin_city: BOETZINGEN.city, + origin_lat: BOETZINGEN.lat - 0.001, + origin_lng: BOETZINGEN.lng - 0.001, + destination_label: LILIENHOF.label, + destination_city: LILIENHOF.city, + destination_lat: LILIENHOF.lat, + destination_lng: LILIENHOF.lng, + departure_at: hoursFromNow(50), + seats: 3, + notes: "Naechste Woche Mittwoch.", + created_at: hoursFromNow(-8), + updated_at: hoursFromNow(-8), + }, +];