Merge pull request 'dev' (#39) from dev into main
Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
@@ -3,7 +3,7 @@ import { getCategoryIcon } from "@/lib/category-icons";
|
||||
import { cn } from "@sase/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Category {
|
||||
@@ -31,7 +31,7 @@ export function CategoryTree({
|
||||
variantSearch?: { body?: string; engine?: string; gearbox?: string };
|
||||
}) {
|
||||
if (!categories || categories.length === 0) {
|
||||
return <p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>;
|
||||
return <p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadı.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -157,70 +157,78 @@ function CategoryNode({
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
const isShimmering = parentPrefetching && isLeaf && !category.schemaImageUrl;
|
||||
|
||||
const rowClass = cn(
|
||||
"flex w-full items-center gap-3 rounded-lg text-left text-sm",
|
||||
"min-h-[44px] px-3 py-2",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-accent",
|
||||
"active:bg-accent/80",
|
||||
"focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
category.unavailable && "pointer-events-none opacity-40",
|
||||
);
|
||||
|
||||
const indentStyle = { paddingLeft: `${level * 16 + 12}px` };
|
||||
|
||||
const rowInner = (
|
||||
<>
|
||||
<span aria-hidden className="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
) : isLeaf ? null : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<SchemaIcon
|
||||
Icon={Icon}
|
||||
schemaImageUrl={category.schemaImageUrl}
|
||||
name={category.name}
|
||||
shimmer={isShimmering}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{category.name}</span>
|
||||
{category.partCount != null && category.partCount > 0 && (
|
||||
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||
{category.partCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent",
|
||||
category.unavailable && "opacity-40",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex h-5 w-5 items-center justify-center">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</span>
|
||||
) : isLeaf ? (
|
||||
<span className="h-5 w-5" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<SchemaIcon
|
||||
Icon={Icon}
|
||||
schemaImageUrl={category.schemaImageUrl}
|
||||
name={category.name}
|
||||
shimmer={isShimmering}
|
||||
/>
|
||||
{isLeaf ? (
|
||||
<Link
|
||||
to={
|
||||
catalogMode
|
||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
: "/dashboard/vehicles/$id/categories/$categoryId"
|
||||
}
|
||||
params={
|
||||
catalogMode
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id }
|
||||
}
|
||||
search={catalogMode && variantSearch ? variantSearch : undefined}
|
||||
className="flex-1 truncate hover:underline"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className="flex-1 truncate text-left hover:underline"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
)}
|
||||
{category.partCount != null && category.partCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{category.partCount}</span>
|
||||
)}
|
||||
</div>
|
||||
{isLeaf ? (
|
||||
<Link
|
||||
to={
|
||||
catalogMode
|
||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
: "/dashboard/vehicles/$id/categories/$categoryId"
|
||||
}
|
||||
params={
|
||||
catalogMode
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id }
|
||||
}
|
||||
search={catalogMode && variantSearch ? variantSearch : undefined}
|
||||
className={cn(rowClass, "no-underline")}
|
||||
style={indentStyle}
|
||||
>
|
||||
{rowInner}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
aria-expanded={expanded}
|
||||
className={rowClass}
|
||||
style={indentStyle}
|
||||
>
|
||||
{rowInner}
|
||||
</button>
|
||||
)}
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
|
||||
59
apps/web/src/components/categories/category-view-toggle.tsx
Normal file
59
apps/web/src/components/categories/category-view-toggle.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { cn } from "@sase/ui";
|
||||
import { Columns2, LayoutGrid, List } from "lucide-react";
|
||||
|
||||
export type CategoryViewMode = "grid" | "tree" | "columns";
|
||||
|
||||
const OPTIONS: Array<{
|
||||
value: CategoryViewMode;
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
}> = [
|
||||
{ value: "grid", label: "Izgara görünümü", Icon: LayoutGrid },
|
||||
{ value: "tree", label: "Ağaç görünümü", Icon: List },
|
||||
{ value: "columns", label: "Sütun görünümü", Icon: Columns2 },
|
||||
];
|
||||
|
||||
export function CategoryViewToggle({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: CategoryViewMode;
|
||||
onChange: (mode: CategoryViewMode) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Kategori görünümü"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{OPTIONS.map(({ value: v, label, Icon }) => {
|
||||
const active = value === v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(v)}
|
||||
data-faro-user-action-name={`category-view-${v}`}
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-md",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import { toast } from "@/lib/toast";
|
||||
import { Badge, Button, Input, Separator } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle, Car, Clock, Loader2, RotateCcw, Search, Send } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, Car, Clock, Loader2, Search, Send } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -188,18 +188,6 @@ function SearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
const vinToRetry = lastAttemptedVinRef.current;
|
||||
if (!vinToRetry || loading) return;
|
||||
attemptCountRef.current += 1;
|
||||
capture("vin_decode_retry_clicked", {
|
||||
vin: vinToRetry,
|
||||
attempt: attemptCountRef.current,
|
||||
previous_error: error,
|
||||
});
|
||||
runDecode(vinToRetry, attemptCountRef.current);
|
||||
}
|
||||
|
||||
// ─── Submit ────────────────────────────────────────────────────────────────
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -240,6 +228,32 @@ function SearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Debounced auto-correction toast ──────────────────────────────────────
|
||||
// Character-by-character typing was firing one toast per keystroke for users
|
||||
// who held a key or rapidly typed several invalid chars. Accumulate the set
|
||||
// of corrections and emit a single consolidated toast after a quiet period.
|
||||
const correctionsRef = useRef<Set<string>>(new Set());
|
||||
const correctionTimerRef = useRef<number | null>(null);
|
||||
|
||||
const flushCorrectionToast = useCallback(() => {
|
||||
correctionTimerRef.current = null;
|
||||
if (correctionsRef.current.size === 0) return;
|
||||
const list = [...correctionsRef.current];
|
||||
correctionsRef.current = new Set();
|
||||
toast.info(`Otomatik düzeltildi: ${list.join(", ")}`, {
|
||||
description: "Şase numarasında I, O, Q harfleri kullanılamaz",
|
||||
duration: 2500,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (correctionTimerRef.current !== null) {
|
||||
window.clearTimeout(correctionTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleVinChange(raw: string) {
|
||||
const upper = raw.toUpperCase();
|
||||
const { cleaned, corrections } = sanitizeVin(upper);
|
||||
@@ -247,11 +261,11 @@ function SearchPage() {
|
||||
setError(null);
|
||||
setReportSent(false);
|
||||
if (corrections.length > 0) {
|
||||
const unique = [...new Set(corrections)];
|
||||
toast.info(`Otomatik düzeltildi: ${unique.join(", ")}`, {
|
||||
description: "Şase numarasında I, O, Q harfleri kullanılamaz",
|
||||
duration: 2500,
|
||||
});
|
||||
for (const c of corrections) correctionsRef.current.add(c);
|
||||
if (correctionTimerRef.current !== null) {
|
||||
window.clearTimeout(correctionTimerRef.current);
|
||||
}
|
||||
correctionTimerRef.current = window.setTimeout(flushCorrectionToast, 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,24 +320,26 @@ function SearchPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* ─── SECTION 1: Hero Input Card ─────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-border bg-background p-6 sm:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="inline-flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Search className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
{/* ─── SECTION 1: Header + Form (no card) ─────────────────────────── */}
|
||||
<header className="flex items-start gap-4">
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted"
|
||||
>
|
||||
<Search className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
|
||||
Şase Arama
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Araç şase numarasını girerek yedek parça kataloğuna erişin
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Separator className="my-6 bg-border" />
|
||||
|
||||
<div>
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
{/* Input */}
|
||||
@@ -408,51 +424,35 @@ function SearchPage() {
|
||||
Şase Çöz
|
||||
</Button>
|
||||
|
||||
{/* Error banner */}
|
||||
{/* Error banner — the main submit button above doubles as the retry,
|
||||
so we don't render a second CTA here (avoids two near-identical
|
||||
buttons stacked on top of each other). */}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="space-y-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4"
|
||||
className="flex items-start gap-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-destructive">Şase çözümlenemedi</p>
|
||||
<p className="mt-1 text-sm text-destructive/90">
|
||||
{error.includes("abone olun") ? (
|
||||
<>
|
||||
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
|
||||
>
|
||||
abone olun
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!error.includes("abone olun") && !error.includes("tanınamadı") && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={loading || !lastAttemptedVinRef.current}
|
||||
className="h-11 w-full rounded-xl"
|
||||
data-faro-user-action-name="vin-decode-retry"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-destructive">Şase çözümlenemedi</p>
|
||||
<p className="mt-1 text-sm text-destructive/90">
|
||||
{error.includes("abone olun") ? (
|
||||
<>
|
||||
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
|
||||
>
|
||||
abone olun
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
error
|
||||
)}
|
||||
Tekrar Dene
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
|
||||
import { useCategoryParts } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react";
|
||||
import { Fragment, Suspense, lazy, useMemo, useState } from "react";
|
||||
|
||||
import { KEYS_6, KEYS_8 } from "@/lib/keys";
|
||||
const SchemaViewer = lazy(() =>
|
||||
@@ -45,13 +48,48 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/categories_/$cate
|
||||
component: VehicleCategoryPage,
|
||||
});
|
||||
|
||||
interface CategoryTreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: CategoryTreeNode[];
|
||||
}
|
||||
|
||||
function findCategoryPath(
|
||||
nodes: CategoryTreeNode[] | undefined,
|
||||
targetId: string,
|
||||
trail: CategoryTreeNode[] = [],
|
||||
): CategoryTreeNode[] | null {
|
||||
if (!nodes) return null;
|
||||
for (const node of nodes) {
|
||||
const next = [...trail, node];
|
||||
if (node.id === targetId) return next;
|
||||
const found = findCategoryPath(node.children, targetId, next);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function VehicleCategoryPage() {
|
||||
const { id, categoryId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useCategoryParts(id, categoryId);
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, error, refetch, isFetching } = useCategoryParts(id, categoryId);
|
||||
|
||||
// Vehicle for breadcrumb root — cached if user arrived from /dashboard/vehicles/$id
|
||||
const { data: vehicle } = useQuery({
|
||||
queryKey: ["vehicle", id],
|
||||
queryFn: () => api.get<{ brandName?: string; model?: string; year?: number }>(`/vehicles/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const hasChildren = data?.children && data.children.length > 0;
|
||||
|
||||
// Resolve breadcrumb trail from the cached category tree (if available)
|
||||
const breadcrumbTrail = useMemo(() => {
|
||||
const tree = queryClient.getQueryData<CategoryTreeNode[]>(["category-tree", id]);
|
||||
return findCategoryPath(tree, categoryId) ?? [];
|
||||
}, [queryClient, id, categoryId]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
);
|
||||
@@ -61,80 +99,128 @@ function VehicleCategoryPage() {
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
|
||||
// Prefer the breadcrumb parent (works even before `data` loads). Fall back to
|
||||
// data.parentId, then to the vehicle page.
|
||||
const parentFromTrail =
|
||||
breadcrumbTrail.length >= 2 ? breadcrumbTrail[breadcrumbTrail.length - 2] : null;
|
||||
|
||||
const handleBack = () => {
|
||||
if (parentFromTrail) {
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: { id, categoryId: parentFromTrail.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data?.parentId) {
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: { id, categoryId: data.parentId },
|
||||
});
|
||||
} else {
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id",
|
||||
params: { id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate({ to: "/dashboard/vehicles/$id", params: { id } });
|
||||
};
|
||||
|
||||
const vehicleLabel = vehicle?.brandName
|
||||
? `${vehicle.brandName}${vehicle.model ? ` ${vehicle.model}` : ""}`
|
||||
: "Araç";
|
||||
|
||||
// Breadcrumb segments excluding the current page (last item rendered as text below)
|
||||
const trailWithoutCurrent = breadcrumbTrail.slice(0, -1);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id"
|
||||
params={{ id }}
|
||||
data-faro-user-action-name="breadcrumb-vehicle"
|
||||
className="truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{vehicleLabel}
|
||||
</Link>
|
||||
{trailWithoutCurrent.map((node) => (
|
||||
<Fragment key={node.id}>
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id/categories/$categoryId"
|
||||
params={{ id, categoryId: node.id }}
|
||||
data-faro-user-action-name="breadcrumb-category"
|
||||
className="max-w-[180px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{node.name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
{(breadcrumbTrail.length > 0 || data?.name) && (
|
||||
<>
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<span aria-current="page" className="truncate px-1 text-foreground">
|
||||
{data?.name ?? breadcrumbTrail[breadcrumbTrail.length - 1]?.name ?? "…"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} title="Geri don">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
data-faro-user-action-name="category-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{data?.name || "Kategori Detayi"}</h1>
|
||||
<h1 className="text-xl font-bold">
|
||||
{data?.name ?? (
|
||||
isLoading ? (
|
||||
<span className="inline-block h-6 w-48 animate-pulse rounded-md bg-primary/10 align-middle" />
|
||||
) : (
|
||||
"Kategori Detayı"
|
||||
)
|
||||
)}
|
||||
</h1>
|
||||
{data?.description && (
|
||||
<p className="text-sm text-muted-foreground">{data.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Agac"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && <CategoryViewToggle value={viewMode} onChange={changeViewMode} />}
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Veriler yuklenirken bir hata olustu. Lutfen tekrar deneyin.
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Kategori yüklenemedi</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Veriler yüklenirken bir hata oluştu."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
data-faro-user-action-name="category-retry"
|
||||
>
|
||||
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Card, CardContent, CardHeader, CardTitle, cn } from "@sase/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
import { KEYS_6, KEYS_8 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
||||
component: VehicleDetailPage,
|
||||
});
|
||||
@@ -43,9 +44,36 @@ function VehicleDetailPage() {
|
||||
if (vehicleLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
{/* Header skeleton: back button + logo + 2-line title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-9 w-9 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-6 w-56" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Vehicle info card skeleton: 4-cell attribute grid */}
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
<Skeleton className="mb-4 h-5 w-32" />
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
{KEYS_6.map((__k) => (
|
||||
<div key={__k} className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Categories skeleton: 8 rows */}
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
<Skeleton className="mb-4 h-5 w-44" />
|
||||
<div className="space-y-2">
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-11 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +82,13 @@ function VehicleDetailPage() {
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => window.history.back()} title="Geri don">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.history.back()}
|
||||
title="Geri dön"
|
||||
data-faro-user-action-name="vehicle-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -73,7 +107,7 @@ function VehicleDetailPage() {
|
||||
{/* Vehicle Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Arac Bilgileri</CardTitle>
|
||||
<CardTitle className="text-base">Araç Bilgileri</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VehicleAttributes vehicle={vehicle} />
|
||||
@@ -83,44 +117,8 @@ function VehicleDetailPage() {
|
||||
{/* Categories */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Yedek Parca Kategorileri</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={cn(
|
||||
"p-1.5 rounded",
|
||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={cn(
|
||||
"p-1.5 rounded",
|
||||
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Agac"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={cn(
|
||||
"p-1.5 rounded",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<CardTitle className="text-base">Yedek Parça Kategorileri</CardTitle>
|
||||
<CategoryViewToggle value={viewMode} onChange={changeViewMode} />
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}
|
||||
@@ -163,39 +161,36 @@ function getVehicleAttributes(vehicle: any): Array<{ label: string; value: strin
|
||||
}
|
||||
|
||||
function VehicleAttributes({ vehicle }: { vehicle: any }) {
|
||||
const attrs = getVehicleAttributes(vehicle);
|
||||
const richAttrs = getVehicleAttributes(vehicle);
|
||||
const fallbackAttrs = [
|
||||
{ label: "Marka", value: vehicle?.brandName },
|
||||
{ label: "Model", value: vehicle?.model },
|
||||
{ label: "Yıl", value: vehicle?.year },
|
||||
{ label: "Motor", value: vehicle?.engine },
|
||||
{ label: "Vites", value: vehicle?.transmission },
|
||||
{ label: "Kasa", value: vehicle?.bodyType },
|
||||
].filter((a): a is { label: string; value: string } => Boolean(a.value));
|
||||
|
||||
// Prefer the rich VIN-decoded attributes when available; otherwise show DB
|
||||
// fallback fields. Single layout for both — keeps semantics consistent.
|
||||
const attrs = richAttrs.length > 0 ? richAttrs : fallbackAttrs;
|
||||
|
||||
if (attrs.length === 0) {
|
||||
// Fallback: show basic DB fields
|
||||
const fallback = [
|
||||
{ label: "Marka", value: vehicle?.brandName },
|
||||
{ label: "Model", value: vehicle?.model },
|
||||
{ label: "Yil", value: vehicle?.year },
|
||||
{ label: "Motor", value: vehicle?.engine },
|
||||
{ label: "Vites", value: vehicle?.transmission },
|
||||
{ label: "Kasa", value: vehicle?.bodyType },
|
||||
].filter((a) => a.value);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||
{fallback.map((attr) => (
|
||||
<div key={attr.label}>
|
||||
<span className="text-muted-foreground">{attr.label}:</span>{" "}
|
||||
<span className="font-medium">{attr.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bu araç için ayrıntı bilgisi bulunamadı.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
{attrs.map((attr: { label: string; value: string }) => (
|
||||
<div key={attr.label}>
|
||||
<dt className="text-muted-foreground">{attr.label}</dt>
|
||||
<dd className="font-medium">{attr.value}</dd>
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 text-sm sm:grid-cols-2 md:grid-cols-3">
|
||||
{attrs.map((attr) => (
|
||||
<div key={attr.label} className="flex flex-col">
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{attr.label}</dt>
|
||||
<dd className="font-medium tabular-nums">{attr.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user