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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user