Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is localized, and now deterministically: every timestamp renders pinned to Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and the browser agree on any runtime. No layout/visible-format change — only the tz. - New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate / formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull / formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey / israelMidnightMs / israelParts + formatRelative (long/short/tight wording). - Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString + hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call sites (30 files) through it. Number toLocaleString left untouched. - Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display). Display-only; no layout/design/IA change → Claude Design gate N/A. Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
8.8 KiB
TypeScript
218 lines
8.8 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { Search } from "lucide-react";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
useLibrarySearch, type PracticeArea, type SearchHit,
|
||
} from "@/lib/api/precedent-library";
|
||
import { PRACTICE_AREAS, PRECEDENT_LEVELS } from "./practice-area";
|
||
import { AuthorityBadge } from "./halacha-meta";
|
||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||
|
||
/** Score chip — boxed, gold-deep, tabular (mockup 07 `.score`). Sits at the
|
||
* end of the meta row via `ms-auto`. White fill on gold-wash halacha cards. */
|
||
function ScoreChip({ score, onWash }: { score: number; onWash?: boolean }) {
|
||
return (
|
||
<span
|
||
className={`ms-auto rounded-md border border-rule px-2.5 py-0.5 text-xs font-semibold text-gold-deep tabular-nums ${
|
||
onWash ? "bg-white" : "bg-surface"
|
||
}`}
|
||
>
|
||
דירוג {score.toFixed(2)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function HalachaCard({ hit }: { hit: Extract<SearchHit, { type: "halacha" }> }) {
|
||
return (
|
||
<div className="rounded-lg border border-rule bg-gold-wash p-4 shadow-sm space-y-2.5">
|
||
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
|
||
<Badge className="rounded bg-gold text-white border-0 text-[0.68rem] font-bold tracking-wide">
|
||
הלכה
|
||
</Badge>
|
||
<span className="text-navy font-semibold" dir="ltr">{hit.case_number}</span>
|
||
{(hit.court || hit.decision_date || hit.precedent_level) && (
|
||
<span className="text-ink-muted">
|
||
{hit.court ? `· ${hit.court}` : ""}
|
||
{hit.decision_date ? ` · ${formatDate(hit.decision_date)}` : ""}
|
||
{hit.precedent_level ? ` · ${hit.precedent_level}` : ""}
|
||
</span>
|
||
)}
|
||
{/* PRE-3/PRE-5 (INV-IA5): derived authority (binding/persuasive)
|
||
rides on the wire — render the pill as in the review tab. */}
|
||
<AuthorityBadge authority={hit.authority} />
|
||
<ScoreChip score={hit.score} onWash />
|
||
</div>
|
||
<p className="text-ink font-medium text-[0.95rem] leading-7" dir="rtl">
|
||
{hit.rule_statement}
|
||
</p>
|
||
<blockquote
|
||
className="rounded-e border-s-[3px] border-gold bg-white/50 px-3 py-2 text-sm text-ink-soft leading-7"
|
||
dir="rtl"
|
||
>
|
||
“{hit.supporting_quote}”
|
||
{hit.page_reference && (
|
||
<span className="text-ink-muted text-[0.72rem] ms-2">({hit.page_reference})</span>
|
||
)}
|
||
</blockquote>
|
||
{hit.subject_tags?.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{hit.subject_tags.map((t) => (
|
||
<Badge key={t} variant="outline" className="text-[0.65rem] bg-white">
|
||
{t}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PassageCard({ hit }: { hit: Extract<SearchHit, { type: "passage" }> }) {
|
||
return (
|
||
<div className="rounded-lg border border-rule bg-surface p-4 shadow-sm space-y-2.5">
|
||
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
|
||
<Badge variant="outline" className="rounded bg-info-bg text-info border-transparent text-[0.68rem] font-bold tracking-wide">
|
||
קטע
|
||
</Badge>
|
||
<span className="text-navy font-semibold" dir="ltr">{hit.case_number}</span>
|
||
{(hit.court || hit.decision_date) && (
|
||
<span className="text-ink-muted">
|
||
{hit.court ? `· ${hit.court}` : ""}
|
||
{hit.decision_date ? ` · ${formatDate(hit.decision_date)}` : ""}
|
||
</span>
|
||
)}
|
||
<span className="rounded bg-rule-soft text-ink-muted text-[0.68rem] px-2 py-0.5 font-medium">
|
||
{hit.section_type}
|
||
</span>
|
||
<ScoreChip score={hit.score} />
|
||
</div>
|
||
<p className="text-ink-soft text-sm leading-7" dir="rtl">
|
||
{hit.content.slice(0, 600)}
|
||
{hit.content.length > 600 && <span>…</span>}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function LibrarySearchPanel() {
|
||
const [draft, setDraft] = useState("");
|
||
const [query, setQuery] = useState("");
|
||
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
|
||
const [precedentLevel, setPrecedentLevel] = useState("");
|
||
const [includeHalachot, setIncludeHalachot] = useState(true);
|
||
|
||
const { data, isFetching, error } = useLibrarySearch(query, {
|
||
practiceArea: practiceArea || undefined,
|
||
precedentLevel: precedentLevel || undefined,
|
||
includeHalachot,
|
||
limit: 20,
|
||
});
|
||
|
||
const onSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setQuery(draft.trim());
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* search panel — boxed surface with query row + two-up filter row +
|
||
controls strip (checkbox start, gold CTA end) per mockup 07. */}
|
||
<form
|
||
onSubmit={onSubmit}
|
||
className="rounded-lg border border-rule bg-surface shadow-sm p-5 space-y-3.5"
|
||
>
|
||
<div>
|
||
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">
|
||
שאילתת חיפוש
|
||
</label>
|
||
<Input value={draft} onChange={(e) => setDraft(e.target.value)}
|
||
placeholder="השבחה אובייקטיבית" dir="rtl" />
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
|
||
<div>
|
||
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">תחום</label>
|
||
<Select value={practiceArea || "_all"}
|
||
onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
|
||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_all">הכל</SelectItem>
|
||
{PRACTICE_AREAS.map((a) => (
|
||
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">רמת תקדים</label>
|
||
<Select value={precedentLevel || "_all"}
|
||
onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
|
||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_all">הכל</SelectItem>
|
||
{PRECEDENT_LEVELS.map((l) => (
|
||
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 flex-wrap pt-1">
|
||
<label className="flex items-center gap-2 cursor-pointer text-[0.85rem] text-ink-soft">
|
||
<input type="checkbox" className="w-[15px] h-[15px] accent-gold" checked={includeHalachot}
|
||
onChange={(e) => setIncludeHalachot(e.target.checked)} />
|
||
כלול הלכות
|
||
</label>
|
||
<Button type="submit" className="ms-auto bg-gold text-white hover:bg-gold-deep border-transparent">
|
||
<Search className="w-4 h-4 me-1" />
|
||
חפש
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
|
||
{!query.trim() ? (
|
||
<div className="text-center text-ink-muted py-12">
|
||
הקלד שאילתא כדי לחפש בקורפוס. החיפוש סמנטי — לא טקסטואלי.
|
||
</div>
|
||
) : error ? (
|
||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||
{error.message}
|
||
</div>
|
||
) : isFetching ? (
|
||
<div className="space-y-3">
|
||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||
</div>
|
||
) : !data?.items.length ? (
|
||
<div className="text-center text-ink-muted py-12">
|
||
לא נמצאו תוצאות. נסה ניסוח אחר או הסר פילטרים.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3.5">
|
||
<p className="text-[0.82rem] text-ink-muted flex items-center gap-2">
|
||
<span>תוצאות</span>
|
||
<span className="inline-flex items-center rounded-full bg-success-bg text-success text-[0.72rem] font-semibold px-2.5 py-0.5">
|
||
הלכות מאושרות בלבד
|
||
</span>
|
||
<span aria-hidden>·</span>
|
||
<span className="tabular-nums">{data.count} תוצאות</span>
|
||
</p>
|
||
{data.items.map((hit, i) =>
|
||
hit.type === "halacha" ? (
|
||
<HalachaCard key={`h-${hit.halacha_id ?? i}`} hit={hit} />
|
||
) : (
|
||
<PassageCard key={`p-${hit.chunk_id ?? i}`} hit={hit} />
|
||
),
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|