From 76d2cdfdd90e5cb5601f2c2719b0f80e53b28a15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 19:57:35 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20Phase=203=20Core=20CRUD=20=E2=80=94?= =?UTF-8?q?=20Listings,=20Geocoding,=20Detail-Ansicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useListings Hook mit CRUD, Gruppierung nach Stadt, Type-Filter - useGeolocation Hook fuer Browser GPS - useGeocoding Hook mit Photon Autocomplete + Nominatim Reverse - useProfiles Hook fuer Profil-Laden nach User-IDs - AutocompleteInput mit debounced Photon-Suche - GpsButton fuer GPS + Reverse Geocoding - ListingCard mit Avatar, Route, Zeit, Plaetze, Kontakt-Buttons - ListingList mit Sticky-City-Headers und Empty-State - ListingForm fuer Erstellen/Bearbeiten (Type-Toggle, Autocomplete, Stepper) - ListingDetail mit Profil-Card, Kontakt-Buttons, Edit/Delete - Avatar-Komponente mit Initialen und deterministischer Farbe - AppShell Layout mit Header, Logo, FAB - Home-Seite mit Segmented Control (Alle/Angebote/Anfragen) - Create, Edit, Detail Seiten mit vollem CRUD - format.ts mit Datum/Zeit/Telefon-Formatierung https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- src/App.tsx | 27 +++ src/components/common/avatar.tsx | 39 ++++ src/components/layout/app-shell.tsx | 29 +++ src/components/layout/header.tsx | 32 +++ src/components/listing/listing-card.tsx | 91 ++++++++ src/components/listing/listing-detail.tsx | 157 ++++++++++++++ src/components/listing/listing-form.tsx | 197 ++++++++++++++++++ src/components/listing/listing-list.tsx | 50 +++++ .../location/autocomplete-input.tsx | 87 ++++++++ src/components/location/gps-button.tsx | 36 ++++ src/hooks/use-geocoding.ts | 120 +++++++++++ src/hooks/use-geolocation.ts | 54 +++++ src/hooks/use-listings.ts | 109 ++++++++++ src/hooks/use-profiles.ts | 35 ++++ src/lib/format.ts | 76 +++++++ src/pages/create.tsx | 45 ++++ src/pages/detail.tsx | 77 +++++++ src/pages/edit.tsx | 101 +++++++++ src/pages/home.tsx | 58 +++++- 19 files changed, 1411 insertions(+), 9 deletions(-) create mode 100644 src/components/common/avatar.tsx create mode 100644 src/components/layout/app-shell.tsx create mode 100644 src/components/layout/header.tsx create mode 100644 src/components/listing/listing-card.tsx create mode 100644 src/components/listing/listing-detail.tsx create mode 100644 src/components/listing/listing-form.tsx create mode 100644 src/components/listing/listing-list.tsx create mode 100644 src/components/location/autocomplete-input.tsx create mode 100644 src/components/location/gps-button.tsx create mode 100644 src/hooks/use-geocoding.ts create mode 100644 src/hooks/use-geolocation.ts create mode 100644 src/hooks/use-listings.ts create mode 100644 src/hooks/use-profiles.ts create mode 100644 src/lib/format.ts create mode 100644 src/pages/create.tsx create mode 100644 src/pages/detail.tsx create mode 100644 src/pages/edit.tsx diff --git a/src/App.tsx b/src/App.tsx index c940365..0abf5b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,9 @@ import { AuthGuard } from "@/components/auth/auth-guard"; import { ROUTES } from "@/lib/constants"; import HomePage from "@/pages/home"; import LoginPage from "@/pages/login"; +import CreatePage from "@/pages/create"; +import EditPage from "@/pages/edit"; +import DetailPage from "@/pages/detail"; import NotFoundPage from "@/pages/not-found"; export default function App() { @@ -20,6 +23,30 @@ export default function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/src/components/common/avatar.tsx b/src/components/common/avatar.tsx new file mode 100644 index 0000000..c98512d --- /dev/null +++ b/src/components/common/avatar.tsx @@ -0,0 +1,39 @@ +import { cn } from "@/lib/utils"; + +interface AvatarProps { + firstName: string; + lastName: string; + color: string; + size?: "sm" | "md" | "lg"; + className?: string; +} + +const sizes = { + sm: "h-8 w-8 text-xs", + md: "h-10 w-10 text-sm", + lg: "h-16 w-16 text-xl", +}; + +export function Avatar({ + firstName, + lastName, + color, + size = "md", + className, +}: AvatarProps) { + const initials = + `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || "?"; + + return ( +
+ {initials} +
+ ); +} diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..3f6c5d8 --- /dev/null +++ b/src/components/layout/app-shell.tsx @@ -0,0 +1,29 @@ +import { useNavigate } from "react-router"; +import { Plus } from "lucide-react"; +import { Header } from "./header"; +import { ROUTES } from "@/lib/constants"; + +interface AppShellProps { + children: React.ReactNode; + showFab?: boolean; +} + +export function AppShell({ children, showFab = false }: AppShellProps) { + const navigate = useNavigate(); + + return ( +
+
+
{children}
+ {showFab && ( + + )} +
+ ); +} diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx new file mode 100644 index 0000000..0467c70 --- /dev/null +++ b/src/components/layout/header.tsx @@ -0,0 +1,32 @@ +import { useNavigate } from "react-router"; +import { Avatar } from "@/components/common/avatar"; +import { useAuthContext } from "@/hooks/use-auth-context"; +import { ROUTES } from "@/lib/constants"; + +export function Header() { + const { profile } = useAuthContext(); + const navigate = useNavigate(); + + return ( +
+
+ Kommit navigate(ROUTES.HOME)} + /> + {profile && ( + + )} +
+
+ ); +} diff --git a/src/components/listing/listing-card.tsx b/src/components/listing/listing-card.tsx new file mode 100644 index 0000000..fc713a5 --- /dev/null +++ b/src/components/listing/listing-card.tsx @@ -0,0 +1,91 @@ +import { useNavigate } from "react-router"; +import { ArrowRight, Car, Clock, MessageCircle, Phone, Users } from "lucide-react"; +import { motion } from "framer-motion"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Avatar } from "@/components/common/avatar"; +import { formatTime, relativeDay } from "@/lib/format"; +import type { Listing, Profile } from "@/types"; + +interface ListingCardProps { + listing: Listing; + profile?: Profile; +} + +export function ListingCard({ listing, profile }: ListingCardProps) { + const navigate = useNavigate(); + + return ( + + navigate(`/listing/${listing.id}`)} + > +
+ + +
+
+ + {profile?.first_name ?? "Unbekannt"} + + + {listing.type === "angebot" ? "Angebot" : "Anfrage"} + +
+ +
+ {listing.origin_label} + + {listing.destination_label} +
+ +
+ + + {relativeDay(listing.departure_at)}, {formatTime(listing.departure_at)} + + + {listing.type === "angebot" ? ( + + ) : ( + + )} + {listing.seats} {listing.seats === 1 ? "Platz" : "Plaetze"} + +
+
+ + {profile?.show_phone && ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/src/components/listing/listing-detail.tsx b/src/components/listing/listing-detail.tsx new file mode 100644 index 0000000..6cb7cc2 --- /dev/null +++ b/src/components/listing/listing-detail.tsx @@ -0,0 +1,157 @@ +import { + ArrowLeft, + ArrowRight, + Calendar, + Car, + Clock, + MessageCircle, + Pencil, + Phone, + Trash2, + Users, +} from "lucide-react"; +import { useNavigate } from "react-router"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Avatar } from "@/components/common/avatar"; +import { formatDate, formatTime } from "@/lib/format"; +import type { Listing, Profile } from "@/types"; + +interface ListingDetailProps { + listing: Listing; + profile: Profile | null; + isOwner: boolean; + isAdmin: boolean; + onDelete: () => void; +} + +export function ListingDetail({ + listing, + profile, + isOwner, + isAdmin, + onDelete, +}: ListingDetailProps) { + const navigate = useNavigate(); + const canEdit = isOwner || isAdmin; + + return ( +
+ + +
+ + {listing.type === "angebot" ? "Angebot" : "Anfrage"} + +
+ {listing.origin_label} + + {listing.destination_label} +
+
+ + {profile && ( + + +
+

+ {profile.first_name} {profile.last_name} +

+

+ Mitglied seit{" "} + {new Date(profile.created_at).toLocaleDateString("de-DE", { + month: "long", + year: "numeric", + })} +

+
+
+ )} + + {profile?.show_phone && !isOwner && ( +
+ + +
+ )} + + +
+ + {formatDate(listing.departure_at)} +
+
+ + {formatTime(listing.departure_at)} Uhr +
+
+ {listing.type === "angebot" ? ( + + ) : ( + + )} + + {listing.seats} {listing.seats === 1 ? "Platz" : "Plaetze"} + +
+
+ + {listing.notes && ( + +

{listing.notes}

+
+ )} + + {canEdit && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/components/listing/listing-form.tsx b/src/components/listing/listing-form.tsx new file mode 100644 index 0000000..fef18e7 --- /dev/null +++ b/src/components/listing/listing-form.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { Car, Hand, Minus, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { AutocompleteInput } from "@/components/location/autocomplete-input"; +import { GpsButton } from "@/components/location/gps-button"; +import type { GeocodingResult } from "@/hooks/use-geocoding"; +import type { ListingType } from "@/types"; + +export interface ListingFormData { + type: ListingType; + origin: GeocodingResult; + destination: GeocodingResult; + departureAt: string; + seats: number; + notes: string; +} + +interface ListingFormProps { + initialData?: Partial; + onSubmit: (data: ListingFormData) => Promise; + submitLabel?: string; +} + +export function ListingForm({ + initialData, + onSubmit, + submitLabel = "Einstellen", +}: ListingFormProps) { + const [type, setType] = useState( + initialData?.type ?? "angebot", + ); + const [originText, setOriginText] = useState( + initialData?.origin?.label ?? "", + ); + const [origin, setOrigin] = useState( + initialData?.origin ?? null, + ); + const [destText, setDestText] = useState( + initialData?.destination?.label ?? "", + ); + const [destination, setDestination] = useState( + initialData?.destination ?? null, + ); + const [departureAt, setDepartureAt] = useState( + initialData?.departureAt ?? "", + ); + const [seats, setSeats] = useState(initialData?.seats ?? 3); + const [notes, setNotes] = useState(initialData?.notes ?? ""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!origin || !destination || !departureAt) return; + + setError(""); + setLoading(true); + try { + await onSubmit({ type, origin, destination, departureAt, seats, notes }); + } catch (err) { + setError((err as Error).message || "Etwas ist schiefgelaufen."); + } finally { + setLoading(false); + } + }; + + const handleGpsResult = (result: GeocodingResult) => { + setOrigin(result); + setOriginText(result.label); + }; + + return ( +
+
+ + +
+ +
+ +
+
+ { + setOriginText(v); + if (origin && v !== origin.label) setOrigin(null); + }} + onSelect={(r) => setOrigin(r)} + placeholder="Ort eingeben..." + /> +
+ +
+
+ +
+ + { + setDestText(v); + if (destination && v !== destination.label) setDestination(null); + }} + onSelect={(r) => setDestination(r)} + placeholder="Zielort eingeben..." + /> +
+ +
+ + setDepartureAt(e.target.value)} + min={new Date().toISOString().slice(0, 16)} + required + /> +
+ +
+ +
+ + {seats} + +
+
+ +
+ +