add legislation detail page with vote widget, hooks, route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 16:55:28 +01:00
parent 3baf126a55
commit beb343cc00
6 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
import { useState } from "react"
import { useLegislation } from "../hooks/use-legislation"
import { useUserVote } from "../hooks/use-user-vote"
import { VoteWidget } from "./vote-widget"
interface LegislationDetailProps {
legislationId: number
}
export function LegislationDetail({ legislationId }: LegislationDetailProps) {
const { legislation, loading, error } = useLegislation(legislationId)
const { vote, castVote } = useUserVote(legislationId)
const [showFullText, setShowFullText] = useState(false)
if (loading) {
return <div className="p-4 text-center text-muted-foreground">Laden</div>
}
if (error || !legislation) {
return (
<div className="p-4 text-center text-destructive">
{error ?? "Gesetzesvorlage nicht gefunden"}
</div>
)
}
return (
<div className="p-4 space-y-6">
<div>
<h1 className="text-lg font-semibold leading-tight">
{legislation.title}
</h1>
{legislation.beratungsstand && (
<span className="inline-block mt-2 text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-800">
{legislation.beratungsstand}
</span>
)}
</div>
{/* Summary or abstract */}
<div className="space-y-2">
{legislation.summary ? (
<p className="text-sm leading-relaxed">{legislation.summary}</p>
) : legislation.abstract ? (
<p className="text-sm leading-relaxed text-muted-foreground">
{legislation.abstract}
</p>
) : (
<p className="text-sm text-muted-foreground italic">
Keine Zusammenfassung verfügbar
</p>
)}
</div>
{/* Full text toggle */}
{legislation.fullText && (
<div>
<button
type="button"
onClick={() => setShowFullText(!showFullText)}
className="text-sm text-blue-600 hover:underline"
>
{showFullText ? "Volltext ausblenden" : "Volltext anzeigen"}
</button>
{showFullText && (
<pre className="mt-2 p-3 bg-muted rounded-lg text-xs overflow-auto max-h-96 whitespace-pre-wrap">
{legislation.fullText}
</pre>
)}
</div>
)}
{/* Vote section */}
<div className="space-y-3">
<h2 className="text-sm font-medium">Dein Vote</h2>
<VoteWidget currentVote={vote} onVote={castVote} />
{vote && (
<p className="text-xs text-muted-foreground text-center">
Du hast mit{" "}
<span className="font-medium">
{vote === "ja" ? "Ja" : vote === "nein" ? "Nein" : "Enthaltung"}
</span>{" "}
gestimmt. Du kannst deine Stimme jederzeit ändern.
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import type { UserVoteChoice } from "../../../../shared/legislation-types"
interface VoteWidgetProps {
currentVote: UserVoteChoice | null
onVote: (choice: UserVoteChoice) => void
disabled?: boolean
}
const choices: { value: UserVoteChoice; label: string; color: string }[] = [
{ value: "ja", label: "Ja", color: "bg-green-600 hover:bg-green-700" },
{ value: "nein", label: "Nein", color: "bg-red-600 hover:bg-red-700" },
{
value: "enthaltung",
label: "Enthaltung",
color: "bg-gray-500 hover:bg-gray-600",
},
]
export function VoteWidget({ currentVote, onVote, disabled }: VoteWidgetProps) {
return (
<div className="flex gap-3">
{choices.map((c) => {
const isSelected = currentVote === c.value
return (
<button
key={c.value}
type="button"
onClick={() => onVote(c.value)}
disabled={disabled}
className={`flex-1 py-3 rounded-lg text-sm font-medium transition-all ${
isSelected
? `${c.color} text-white ring-2 ring-offset-2 ring-current`
: "bg-muted text-muted-foreground hover:bg-accent"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{c.label}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react"
import type { LegislationDetail } from "../../../../shared/legislation-types"
import { fetchLegislation } from "../lib/legislation-api"
interface UseLegislationReturn {
legislation: LegislationDetail | null
loading: boolean
error: string | null
}
export function useLegislation(legislationId: number): UseLegislationReturn {
const [legislation, setLegislation] = useState<LegislationDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const data = await fetchLegislation(legislationId)
if (!cancelled) setLegislation(data)
} catch (err) {
if (!cancelled) setError(String(err))
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [legislationId])
return { legislation, loading, error }
}

View File

@@ -0,0 +1,66 @@
import { useDb } from "@/shared/db/provider"
import { useDeviceId } from "@/shared/hooks/use-device-id"
import { useCallback, useEffect, useState } from "react"
import type { UserVoteChoice } from "../../../../shared/legislation-types"
import { castVote as apiCastVote, fetchUserVote } from "../lib/legislation-api"
import { getUserVote, saveUserVote } from "../lib/user-votes-db"
interface UseUserVoteReturn {
vote: UserVoteChoice | null
loading: boolean
castVote: (choice: UserVoteChoice) => Promise<void>
}
export function useUserVote(legislationId: number): UseUserVoteReturn {
const db = useDb()
const deviceId = useDeviceId()
const [vote, setVote] = useState<UserVoteChoice | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
// load from local DB first for instant UI
const local = await getUserVote(db, legislationId)
if (!cancelled && local) {
setVote(local.vote as UserVoteChoice)
setLoading(false)
}
// sync from backend if device ID is available
if (deviceId) {
const remote = await fetchUserVote(legislationId, deviceId).catch(
() => null,
)
if (!cancelled && remote) {
setVote(remote.vote)
await saveUserVote(db, legislationId, remote.vote)
}
}
if (!cancelled) setLoading(false)
}
load()
return () => {
cancelled = true
}
}, [db, legislationId, deviceId])
const cast = useCallback(
async (choice: UserVoteChoice) => {
// optimistic local update
setVote(choice)
await saveUserVote(db, legislationId, choice)
// sync to backend
if (deviceId) {
await apiCastVote(legislationId, deviceId, choice)
}
},
[db, legislationId, deviceId],
)
return { vote, loading, castVote: cast }
}

View File

@@ -0,0 +1 @@
export { LegislationDetail } from "./components/legislation-detail"

View File

@@ -0,0 +1,11 @@
import { LegislationDetail } from "@/features/legislation"
import { createFileRoute } from "@tanstack/react-router"
function LegislationPage() {
const { legislationId } = Route.useParams()
return <LegislationDetail legislationId={Number(legislationId)} />
}
export const Route = createFileRoute("/app/legislation/$legislationId")({
component: LegislationPage,
})