feat(web): history production refactor — search, sort, delete, plan-lock, i18n, analytics

The old 66-line history page was a raw list with `any[]` typing, no search,
no sort, no delete, no dates, no i18n and no analytics — opening a vehicle
detail was the only action available.

Replaces it with a real management surface:
- Search-by-text (brand, model, VIN, year, engine) — client-side over the
  paginated payload, Turkish-locale case-folding
- Sort dropdown (newest, oldest, brand A→Z, year DESC)
- Per-item Remove with a confirmation dialog; mutation does optimistic
  setQueryData so the card disappears immediately, then invalidates the
  search-page recent-history query
- Relative-time "decoded {when}" using Intl.RelativeTimeFormat with the active
  locale; absolute timestamp lives in title/aria for power users
- Plan-lock badge: when the user's plan no longer covers a brand in their
  history, that card surfaces a Lock chip linking to /subscription
- Brand logo, VIN with copy-to-clipboard, focus-visible rings, Link.preload
  intent so opening a vehicle is instant
- Empty state (no history) and no-results state (filter wiped everything)
  are now distinct, both with the right CTA
- PostHog events: history_viewed, history_search_used, history_sort_changed,
  history_load_more, history_item_clicked, history_vin_copied,
  history_item_deleted — full funnel coverage from open to action
- Full i18n via new `history.*` block in tr.json/en.json, plus the page now
  fetches paginated (PAGE_SIZE × 8 first chunk, "show more" client-side)
This commit is contained in:
2026-06-01 00:10:50 +03:00
parent 02efa8c35d
commit adbf23945e
3 changed files with 507 additions and 48 deletions

View File

@@ -1,66 +1,453 @@
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
const HISTORY_SKEL_KEYS = ["h0", "h1", "h2"];
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Skeleton,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowRight, Car, Clock, Copy, Lock, Search as SearchIcon, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
interface VehicleHistoryItem {
id: string;
vin: string;
brandId: string;
brandName: string;
model: string | null;
year: number | null;
engine: string | null;
bodyType: string | null;
lastAccessedAt: string | null;
}
interface Subscription {
status: string;
plan?: { name: string; key: string };
brands?: { brandId: string; brandName: string }[];
}
const PAGE_SIZE = 24;
type SortMode = "newest" | "oldest" | "brand" | "yearDesc";
export const Route = createFileRoute("/dashboard/history")({
component: HistoryPage,
});
function relativeFromNow(iso: string | null, locale: "tr" | "en"): string {
if (!iso) return "";
const ts = new Date(iso).getTime();
if (Number.isNaN(ts)) return "";
const diffSec = Math.round((ts - Date.now()) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale === "tr" ? "tr" : "en", {
numeric: "auto",
});
const abs = Math.abs(diffSec);
if (abs < 60) return rtf.format(diffSec, "second");
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), "minute");
if (abs < 86_400) return rtf.format(Math.round(diffSec / 3600), "hour");
if (abs < 86_400 * 30) return rtf.format(Math.round(diffSec / 86_400), "day");
if (abs < 86_400 * 365) return rtf.format(Math.round(diffSec / (86_400 * 30)), "month");
return rtf.format(Math.round(diffSec / (86_400 * 365)), "year");
}
function absoluteDate(iso: string | null, locale: "tr" | "en"): string {
if (!iso) return "";
return new Date(iso).toLocaleString(locale === "tr" ? "tr-TR" : "en-US", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function HistoryPage() {
const { data, isLoading } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: () => api.get<any[]>("/vehicles/history"),
const { t, locale } = useTranslation();
const queryClient = useQueryClient();
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("newest");
const [page, setPage] = useState(1);
const [deleteTarget, setDeleteTarget] = useState<VehicleHistoryItem | null>(null);
const viewedRef = useRef(false);
const { data: pages, isLoading } = useQuery({
queryKey: ["vehicles", "history", "all"],
queryFn: () => api.get<VehicleHistoryItem[]>(`/vehicles/history?page=1&limit=${PAGE_SIZE * 8}`),
staleTime: 30 * 1000,
});
const { data: subData } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () => api.get<{ subscription: Subscription | null }>("/subscriptions/me"),
staleTime: 60 * 1000,
});
useEffect(() => {
if (pages && !viewedRef.current) {
viewedRef.current = true;
capture("history_viewed", { count: pages.length });
}
}, [pages]);
const accessibleBrandIds = useMemo(() => {
const sub = subData?.subscription;
if (!sub || sub.plan?.key === "full") return null; // null = no restriction
return new Set((sub.brands ?? []).map((b) => b.brandId));
}, [subData]);
const filteredSorted = useMemo(() => {
if (!pages) return [];
const q = query.trim().toLocaleLowerCase("tr");
const filtered = q
? pages.filter((v) => {
const hay = `${v.brandName} ${v.model ?? ""} ${v.vin} ${v.year ?? ""} ${
v.engine ?? ""
}`.toLocaleLowerCase("tr");
return hay.includes(q);
})
: pages;
const sorted = [...filtered];
switch (sort) {
case "oldest":
sorted.sort((a, b) => {
const ta = a.lastAccessedAt ? new Date(a.lastAccessedAt).getTime() : 0;
const tb = b.lastAccessedAt ? new Date(b.lastAccessedAt).getTime() : 0;
return ta - tb;
});
break;
case "brand":
sorted.sort((a, b) => a.brandName.localeCompare(b.brandName, "tr"));
break;
case "yearDesc":
sorted.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
break;
default:
sorted.sort((a, b) => {
const ta = a.lastAccessedAt ? new Date(a.lastAccessedAt).getTime() : 0;
const tb = b.lastAccessedAt ? new Date(b.lastAccessedAt).getTime() : 0;
return tb - ta;
});
}
return sorted;
}, [pages, query, sort]);
const visible = useMemo(() => filteredSorted.slice(0, page * PAGE_SIZE), [filteredSorted, page]);
const hasMore = visible.length < filteredSorted.length;
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/vehicles/${id}`),
onSuccess: (_data, id) => {
queryClient.setQueryData<VehicleHistoryItem[]>(["vehicles", "history", "all"], (prev) =>
prev ? prev.filter((v) => v.id !== id) : prev,
);
queryClient.invalidateQueries({ queryKey: ["vehicles", "history"] });
toast.success(t("history.deleted"));
capture("history_item_deleted", { vehicle_id: id });
},
onError: () => toast.error(t("history.deleteFailed")),
});
function onCopyVin(vin: string) {
navigator.clipboard.writeText(vin).then(
() => toast.success(t("history.vinCopied")),
() => toast.error(t("common.error")),
);
capture("history_vin_copied", { vin });
}
function clearFilters() {
setQuery("");
setSort("newest");
setPage(1);
}
const hasActiveFilter = query.trim() !== "" || sort !== "newest";
return (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h2 className="text-2xl font-bold">Arama Geçmişi</h2>
<p className="text-muted-foreground">Daha önce aradığınız araçlar</p>
<div className="mx-auto max-w-5xl space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl font-bold leading-tight">{t("history.title")}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t("history.subtitle")}</p>
</div>
<Link to="/dashboard/search">
<Button>
<SearchIcon className="mr-1.5 size-4" />
{t("history.newSearch")}
</Button>
</Link>
</div>
{isLoading ? (
<div className="space-y-4">
{HISTORY_SKEL_KEYS.map((k) => (
<Skeleton key={k} className="h-24 w-full" />
))}
</div>
) : !data || data.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Henüz arama yapmadınız.
<br />
<Link to="/dashboard/search" className="text-primary hover:underline">
Şase arama sayfasına gidin
</Link>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{data.map((vehicle: any) => (
<Link key={vehicle.id} to="/dashboard/vehicles/$id" params={{ id: vehicle.id }}>
<Card className="transition-shadow hover:shadow-md cursor-pointer">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{vehicle.brandName} {vehicle.model}
</CardTitle>
<Badge variant="secondary">{vehicle.year}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="font-mono text-sm text-muted-foreground">{vehicle.vin}</p>
</CardContent>
</Card>
</Link>
))}
{/* Toolbar */}
{!isLoading && pages && pages.length > 0 && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-md">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
if (e.target.value.trim()) {
capture("history_search_used", { length: e.target.value.length });
}
}}
placeholder={t("history.searchPlaceholder")}
className="pl-9"
aria-label={t("history.searchPlaceholder")}
/>
{query && (
<button
type="button"
onClick={() => {
setQuery("");
setPage(1);
}}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-xs tabular-nums text-muted-foreground">
{t("history.itemCount", { count: filteredSorted.length })}
</span>
<select
value={sort}
onChange={(e) => {
const v = e.target.value as SortMode;
setSort(v);
setPage(1);
capture("history_sort_changed", { sort: v });
}}
aria-label={t("history.sort.label")}
className="h-9 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<option value="newest">{t("history.sort.newest")}</option>
<option value="oldest">{t("history.sort.oldest")}</option>
<option value="brand">{t("history.sort.brandAZ")}</option>
<option value="yearDesc">{t("history.sort.yearDesc")}</option>
</select>
</div>
</div>
)}
{/* Loading */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{["s1", "s2", "s3", "s4", "s5", "s6"].map((k) => (
<Skeleton key={k} className="h-[148px] w-full rounded-2xl" />
))}
</div>
) : !pages || pages.length === 0 ? (
/* Empty — no history at all */
<div className="rounded-2xl border border-dashed border-border bg-muted/20 p-10 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Car className="size-6" />
</div>
<h3 className="mt-4 text-lg font-semibold">{t("history.empty.title")}</h3>
<p className="mt-2 text-sm text-muted-foreground">{t("history.empty.description")}</p>
<Link to="/dashboard/search">
<Button className="mt-4">
{t("history.empty.cta")}
<ArrowRight className="ml-1.5 size-4" />
</Button>
</Link>
</div>
) : filteredSorted.length === 0 ? (
/* Filter wiped everything */
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
<p className="text-sm font-medium text-foreground">{t("history.noResults.title")}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("history.noResults.subtitle")}</p>
<Button variant="outline" size="sm" className="mt-3" onClick={clearFilters}>
{t("history.noResults.clear")}
</Button>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((v) => (
<HistoryCard
key={v.id}
vehicle={v}
locked={accessibleBrandIds ? !accessibleBrandIds.has(v.brandId) : false}
onCopyVin={onCopyVin}
onDelete={() => setDeleteTarget(v)}
t={t}
locale={locale === "en" ? "en" : "tr"}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={() => {
setPage((p) => p + 1);
capture("history_load_more", { page: page + 1 });
}}
>
{t("history.loadMore")}
</Button>
</div>
)}
{hasActiveFilter && (
<div className="flex justify-center">
<Button variant="ghost" size="sm" onClick={clearFilters}>
{t("history.noResults.clear")}
</Button>
</div>
)}
</>
)}
{/* Delete confirm */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("history.deleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("history.deleteConfirmDescription", {
label: deleteTarget
? `${deleteTarget.brandName} ${deleteTarget.model ?? ""}`.trim()
: "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t("history.deleteCancelAction")}
</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (!deleteTarget) return;
const id = deleteTarget.id;
deleteMutation.mutate(id);
setDeleteTarget(null);
}}
>
<Trash2 className="mr-1.5 size-4" />
{t("history.deleteConfirmAction")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function HistoryCard({
vehicle,
locked,
onCopyVin,
onDelete,
t,
locale,
}: {
vehicle: VehicleHistoryItem;
locked: boolean;
onCopyVin: (vin: string) => void;
onDelete: () => void;
t: (key: string, params?: Record<string, string | number>) => string;
locale: "tr" | "en";
}) {
const when = relativeFromNow(vehicle.lastAccessedAt, locale);
const whenAbs = absoluteDate(vehicle.lastAccessedAt, locale);
return (
<div
className={`group flex flex-col rounded-2xl border bg-background p-4 transition-colors ${
locked
? "border-border opacity-80"
: "border-border hover:border-primary/40 hover:bg-accent/40"
}`}
>
<div className="flex items-start justify-between gap-3">
<Link
to="/dashboard/vehicles/$id"
params={{ id: vehicle.id }}
preload="intent"
onClick={() => capture("history_item_clicked", { vehicle_id: vehicle.id })}
className="flex flex-1 items-center gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-lg"
>
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold leading-5">
{vehicle.brandName} {vehicle.model ?? ""}
</p>
{vehicle.engine && (
<p className="truncate text-xs text-muted-foreground">{vehicle.engine}</p>
)}
</div>
</Link>
{vehicle.year && (
<Badge variant="secondary" className="shrink-0 tabular-nums">
{vehicle.year}
</Badge>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 truncate rounded bg-muted px-2 py-1 font-mono text-xs">
{vehicle.vin}
</code>
<button
type="button"
onClick={() => onCopyVin(vehicle.vin)}
aria-label={t("history.copyVin")}
title={t("history.copyVin")}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Copy className="size-3.5" />
</button>
</div>
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1" title={whenAbs} aria-label={whenAbs}>
<Clock className="size-3" />
{when}
</span>
<div className="flex items-center gap-1">
{locked && (
<Link
to="/dashboard/subscription"
className="inline-flex items-center gap-1 rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-950/40 dark:text-amber-300"
title={t("history.planLockedTooltip")}
aria-label={t("history.planLockedTooltip")}
>
<Lock className="size-3" />
{t("history.planLocked")}
</Link>
)}
<button
type="button"
onClick={onDelete}
aria-label={t("history.deleteAction")}
title={t("history.deleteAction")}
className="rounded-md p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
</div>
);
}